diff --git a/package.json b/package.json index 187e8d76f..74172c3cd 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ }, "homepage": "https://github.com/xataio/client-ts#readme", "devDependencies": { + "@auth/core": "^0.15.0", + "@auth/xata-adapter": "^0.1.0", "@babel/core": "^7.22.20", "@babel/preset-env": "^7.22.20", "@babel/preset-typescript": "^7.22.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 750fd35cd..da66bb3e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,12 @@ settings: importers: .: devDependencies: + '@auth/core': + specifier: ^0.15.0 + version: 0.15.0 + '@auth/xata-adapter': + specifier: ^0.1.0 + version: 0.1.0(@xata.io/client@packages+client) '@babel/core': specifier: ^7.22.20 version: 7.22.20 @@ -525,6 +531,52 @@ packages: zen-observable-ts: 1.2.5 dev: true + /@auth/core@0.15.0: + resolution: + { integrity: sha512-pWchDMUT0TXUn5kencusX4IL9IfIuVKzRF/cy6RSKr6PgdwMvSPyqyxxhHU+nh3+C/3vYqV0k8SKzfgNo0A1jQ== } + peerDependencies: + nodemailer: ^6.8.0 + peerDependenciesMeta: + nodemailer: + optional: true + dependencies: + '@panva/hkdf': 1.1.1 + cookie: 0.5.0 + jose: 4.14.6 + oauth4webapi: 2.3.0 + preact: 10.11.3 + preact-render-to-string: 5.2.3(preact@10.11.3) + dev: true + + /@auth/core@0.8.2: + resolution: + { integrity: sha512-swqJ7tKFlqiYIl1znFGrrawUv1F6rwL+/f3DoeWDaZLnr2phiHFaZsvNvLK7WBJhnJbel78Vd3GznuR31AnFUw== } + peerDependencies: + nodemailer: ^6.8.0 + peerDependenciesMeta: + nodemailer: + optional: true + dependencies: + '@panva/hkdf': 1.1.1 + cookie: 0.5.0 + jose: 4.14.6 + oauth4webapi: 2.3.0 + preact: 10.11.3 + preact-render-to-string: 5.2.3(preact@10.11.3) + dev: true + + /@auth/xata-adapter@0.1.0(@xata.io/client@packages+client): + resolution: + { integrity: sha512-3Qpo/j4L2nxdDWbzOHG3u9/RBhFa8RZDZiKBTNgi8QcNUciM3w8WBDjPgcXVIifyVj/C9YUAe6QK3VG3CMzX+g== } + peerDependencies: + '@xata.io/client': '>=0.13.0' + dependencies: + '@auth/core': 0.8.2 + '@xata.io/client': link:packages/client + transitivePeerDependencies: + - nodemailer + dev: true + /@babel/code-frame@7.22.13: resolution: { integrity: sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w== } @@ -4640,6 +4692,11 @@ packages: { integrity: sha512-+fguCd2d8d2qruk0H0DsCEy2CTK3t0Tugg7MhZ/UQMvmewbZLNnJ6heSYyzIZWG5IPfAXzoj4f4F/qpM7l4VBA== } engines: { node: '>=14' } + /@panva/hkdf@1.1.1: + resolution: + { integrity: sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA== } + dev: true + /@pkgjs/parseargs@0.11.0: resolution: { integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== } @@ -7274,6 +7331,12 @@ packages: { integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== } engines: { node: '>= 0.6' } + /cookie@0.5.0: + resolution: + { integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== } + engines: { node: '>= 0.6' } + dev: true + /core-js-compat@3.32.0: resolution: { integrity: sha512-7a9a3D1k4UCVKnLhrgALyFcP7YCsLOQIxPd0dKjf/6GuPcgyiGP70ewWdCGrSK7evyhymi0qO4EqCmSJofDeYw== } @@ -10456,6 +10519,11 @@ packages: engines: { node: '>= 0.6.0' } dev: true + /jose@4.14.6: + resolution: + { integrity: sha512-EqJPEUlZD0/CSUMubKtMaYUOtWe91tZXTWMJZoKSbLk+KtdhNdcvppH8lA9XwVu2V4Ailvsj0GBZJ2ZwDjfesQ== } + dev: true + /joycon@3.1.1: resolution: { integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== } @@ -12358,6 +12426,11 @@ packages: yaml: 1.10.2 dev: true + /oauth4webapi@2.3.0: + resolution: + { integrity: sha512-JGkb5doGrwzVDuHwgrR4nHJayzN4h59VCed6EW8Tql6iHDfZIabCJvg6wtbn5q6pyB2hZruI3b77Nudvq7NmvA== } + dev: true + /object-assign@4.1.1: resolution: { integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== } @@ -13117,6 +13190,21 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 + /preact-render-to-string@5.2.3(preact@10.11.3): + resolution: + { integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA== } + peerDependencies: + preact: '>=10' + dependencies: + preact: 10.11.3 + pretty-format: 3.8.0 + dev: true + + /preact@10.11.3: + resolution: + { integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg== } + dev: true + /precinct@11.0.4(supports-color@9.3.1): resolution: { integrity: sha512-pYdAprJ7iUnXrfEfgp1AMAEbrSjaOQHJXqPk15nEDWlCPtB3TgkhjB27Ar1nVYMgbATm1hN1lGKruenIu9F94A== } @@ -13193,6 +13281,11 @@ packages: react-is: 18.2.0 dev: true + /pretty-format@3.8.0: + resolution: + { integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew== } + dev: true + /pretty-ms@7.0.1: resolution: { integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q== } diff --git a/test/external/authjs.test.ts b/test/external/authjs.test.ts new file mode 100644 index 000000000..052ddbb84 --- /dev/null +++ b/test/external/authjs.test.ts @@ -0,0 +1,532 @@ +import type { Adapter } from '@auth/core/adapters'; +import { XataAdapter } from '@auth/xata-adapter'; +import { createHash, randomUUID } from 'crypto'; +import { File, Suite, TestContext, afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; +import { Schemas } from '../../packages/client/src'; +import { setUpTestEnvironment } from '../utils/setup'; + +interface TestOptions { + adapter: Adapter; + hooks?: { + beforeAll: (ctx: Suite | File) => Promise; + afterAll: (ctx: Suite | File) => Promise; + beforeEach: (ctx: TestContext) => Promise; + afterEach: (ctx: TestContext) => Promise; + }; + fixtures?: { + user?: any; + session?: any; + account?: any; + sessionUpdateExpires?: Date; + verificationTokenExpires?: Date; + }; + db: { + /** Generates UUID v4 by default. Use it to override how the test suite should generate IDs, like user id. */ + id?: () => string; + /** + * Manually disconnect database after all tests have been run, + * if your adapter doesn't do it automatically + */ + disconnect?: () => Promise; + /** + * Manually establishes a db connection before all tests, + * if your db doesn't do this automatically + */ + connect?: () => Promise; + /** A simple query function that returns a session directly from the db. */ + session: (sessionToken: string) => any; + /** A simple query function that returns a user directly from the db. */ + user: (id: string) => any; + /** A simple query function that returns an account directly from the db. */ + account: (providerAccountId: { provider: string; providerAccountId: string }) => any; + /** + * A simple query function that returns an verification token directly from the db, + * based on the user identifier and the verification token (hashed). + */ + verificationToken: (params: { identifier: string; token: string }) => any; + }; + skipTests?: string[]; +} + +const schema: Schemas.Schema = { + tables: [ + { + name: 'nextauth_users', + columns: [ + { + name: 'email', + type: 'email' + }, + { + name: 'emailVerified', + type: 'datetime' + }, + { + name: 'name', + type: 'string' + }, + { + name: 'image', + type: 'string' + } + ] + }, + { + name: 'nextauth_accounts', + columns: [ + { + name: 'user', + type: 'link', + link: { + table: 'nextauth_users' + } + }, + { + name: 'type', + type: 'string' + }, + { + name: 'provider', + type: 'string' + }, + { + name: 'providerAccountId', + type: 'string' + }, + { + name: 'refresh_token', + type: 'string' + }, + { + name: 'access_token', + type: 'string' + }, + { + name: 'expires_at', + type: 'int' + }, + { + name: 'token_type', + type: 'string' + }, + { + name: 'scope', + type: 'string' + }, + { + name: 'id_token', + type: 'text' + }, + { + name: 'session_state', + type: 'string' + } + ] + }, + { + name: 'nextauth_verificationTokens', + columns: [ + { + name: 'identifier', + type: 'string' + }, + { + name: 'token', + type: 'string' + }, + { + name: 'expires', + type: 'datetime' + } + ] + }, + { + name: 'nextauth_users_accounts', + columns: [ + { + name: 'user', + type: 'link', + link: { + table: 'nextauth_users' + } + }, + { + name: 'account', + type: 'link', + link: { + table: 'nextauth_accounts' + } + } + ] + }, + { + name: 'nextauth_users_sessions', + columns: [ + { + name: 'user', + type: 'link', + link: { + table: 'nextauth_users' + } + }, + { + name: 'session', + type: 'link', + link: { + table: 'nextauth_sessions' + } + } + ] + }, + { + name: 'nextauth_sessions', + columns: [ + { + name: 'sessionToken', + type: 'string' + }, + { + name: 'expires', + type: 'datetime' + }, + { + name: 'user', + type: 'link', + link: { + table: 'nextauth_users' + } + } + ] + } + ] +}; + +describe('External Auth.js tests', async () => { + const { baseClient: client, hooks } = await setUpTestEnvironment('next-auth', { schema }); + + const options: TestOptions = { + adapter: XataAdapter(client), + hooks, + db: { + async user(id: string) { + const data = await client.db.nextauth_users.filter({ id }).getFirst(); + if (!data) return null; + return data; + }, + async account({ provider, providerAccountId }) { + const data = await client.db.nextauth_accounts.filter({ provider, providerAccountId }).getFirst(); + if (!data) return null; + return data; + }, + async session(sessionToken) { + const data = await client.db.nextauth_sessions.filter({ sessionToken }).getFirst(); + if (!data) return null; + return data; + }, + async verificationToken(where) { + const data = await client.db.nextauth_verificationTokens.filter(where).getFirst(); + if (!data) return null; + return data; + } + } + }; + + const id = options.db.id ?? randomUUID; + + // Init + beforeAll(async (suite) => { + options.hooks?.beforeAll?.(suite); + + await options.db.connect?.(); + }); + + const { adapter: _adapter, db } = options; + const adapter = _adapter as Required; + + afterAll(async (suite) => { + // @ts-expect-error + await adapter.__disconnect?.(); + await options.db.disconnect?.(); + + options.hooks?.afterAll?.(suite); + }); + + beforeEach(async (ctx) => { + await options.hooks?.beforeEach(ctx); + }); + + afterEach(async (ctx) => { + await options.hooks?.afterEach(ctx); + }); + + let user: any = options.fixtures?.user ?? { + email: 'fill@murray.com', + image: 'https://www.fillmurray.com/460/300', + name: 'Fill Murray', + emailVerified: new Date() + }; + + if (process.env.CUSTOM_MODEL === '1') { + user.role = 'admin'; + user.phone = '00000000000'; + } + + const session: any = options.fixtures?.session ?? { + sessionToken: randomUUID(), + expires: ONE_WEEK_FROM_NOW + }; + + const account: any = options.fixtures?.account ?? { + provider: 'github', + providerAccountId: randomUUID(), + type: 'oauth', + access_token: randomUUID(), + expires_at: ONE_MONTH / 1000, + id_token: randomUUID(), + refresh_token: randomUUID(), + token_type: 'bearer', + scope: 'user', + session_state: randomUUID() + }; + + // All adapters must define these methods + + test('Required (User, Account, Session) methods exist', () => { + const requiredMethods = [ + 'createUser', + 'getUser', + 'getUserByEmail', + 'getUserByAccount', + 'updateUser', + 'linkAccount', + 'createSession', + 'getSessionAndUser', + 'updateSession', + 'deleteSession' + ]; + requiredMethods.forEach((method) => { + expect(adapter).toHaveProperty(method); + }); + }); + + test('createUser', async () => { + const { id } = await adapter.createUser(user); + const dbUser = await db.user(id); + expect(dbUser).toEqual({ ...user, id }); + user = dbUser; + session.userId = dbUser.id; + account.userId = dbUser.id; + }); + + test('getUser', async () => { + expect(await adapter.getUser(id())).toBeNull(); + expect(await adapter.getUser(user.id)).toEqual(user); + }); + + test('getUserByEmail', async () => { + expect(await adapter.getUserByEmail('non-existent-email')).toBeNull(); + expect(await adapter.getUserByEmail(user.email)).toEqual(user); + }); + + test('createSession', async () => { + const { sessionToken } = await adapter.createSession(session); + const dbSession = await db.session(sessionToken); + + expect(dbSession).toEqual({ ...session, id: dbSession.id }); + session.userId = dbSession.userId; + session.id = dbSession.id; + }); + + test('getSessionAndUser', async () => { + let sessionAndUser = await adapter.getSessionAndUser('invalid-token'); + expect(sessionAndUser).toBeNull(); + + sessionAndUser = await adapter.getSessionAndUser(session.sessionToken); + if (!sessionAndUser) { + throw new Error('Session and User was not found, but they should exist'); + } + expect(sessionAndUser).toEqual({ + user, + session + }); + }); + + test('updateUser', async () => { + const newName = 'Updated Name'; + const returnedUser = await adapter.updateUser({ + id: user.id, + name: newName + }); + expect(returnedUser.name).toBe(newName); + + const dbUser = await db.user(user.id); + expect(dbUser.name).toBe(newName); + user.name = newName; + }); + + test('updateSession', async () => { + let dbSession = await db.session(session.sessionToken); + + const expires = options.fixtures?.sessionUpdateExpires ?? ONE_MONTH_FROM_NOW; + + expect(dbSession.expires.valueOf()).not.toBe(expires.valueOf()); + + await adapter.updateSession({ + sessionToken: session.sessionToken, + expires + }); + + dbSession = await db.session(session.sessionToken); + expect(dbSession.expires.valueOf()).toBe(expires.valueOf()); + }); + + test('linkAccount', async () => { + await adapter.linkAccount(account); + const dbAccount = await db.account({ + provider: account.provider, + providerAccountId: account.providerAccountId + }); + expect(dbAccount).toEqual({ ...account, id: dbAccount.id }); + }); + + test('getUserByAccount', async () => { + let userByAccount = await adapter.getUserByAccount({ + provider: 'invalid-provider', + providerAccountId: 'invalid-provider-account-id' + }); + expect(userByAccount).toBeNull(); + + userByAccount = await adapter.getUserByAccount({ + provider: account.provider, + providerAccountId: account.providerAccountId + }); + expect(userByAccount).toEqual(user); + }); + + test('deleteSession', async () => { + await adapter.deleteSession(session.sessionToken); + const dbSession = await db.session(session.sessionToken); + expect(dbSession).toBeNull(); + }); + + // These are optional for custom adapters, but we require them for the official adapters + + test('Verification Token methods exist', () => { + const requiredMethods = ['createVerificationToken', 'useVerificationToken']; + requiredMethods.forEach((method) => { + expect(adapter).toHaveProperty(method); + }); + }); + + test('createVerificationToken', async () => { + const identifier = 'info@example.com'; + const token = randomUUID(); + const hashedToken = hashToken(token); + + const verificationToken = { + token: hashedToken, + identifier, + expires: options.fixtures?.verificationTokenExpires ?? FIFTEEN_MINUTES_FROM_NOW + }; + await adapter.createVerificationToken?.(verificationToken); + + const dbVerificationToken = await db.verificationToken({ + token: hashedToken, + identifier + }); + + expect(dbVerificationToken).toEqual(verificationToken); + }); + + test('useVerificationToken', async () => { + const identifier = 'info@example.com'; + const token = randomUUID(); + const hashedToken = hashToken(token); + const verificationToken = { + token: hashedToken, + identifier, + expires: options.fixtures?.verificationTokenExpires ?? FIFTEEN_MINUTES_FROM_NOW + }; + await adapter.createVerificationToken?.(verificationToken); + + const dbVerificationToken1 = await adapter.useVerificationToken?.({ + identifier, + token: hashedToken + }); + + if (!dbVerificationToken1) { + throw new Error('Verification Token was not found, but it should exist'); + } + + expect(dbVerificationToken1).toEqual(verificationToken); + + const dbVerificationToken2 = await adapter.useVerificationToken?.({ + identifier, + token: hashedToken + }); + + expect(dbVerificationToken2).toBeNull(); + }); + + // Future methods + // These methods are not yet invoked in the core, but built-in adapters must implement them + test('Future methods exist', () => { + const requiredMethods = ['unlinkAccount', 'deleteUser']; + requiredMethods.forEach((method) => { + expect(adapter).toHaveProperty(method); + }); + }); + + test('unlinkAccount', async () => { + let dbAccount = await db.account({ + provider: account.provider, + providerAccountId: account.providerAccountId + }); + expect(dbAccount).toEqual({ ...account, id: dbAccount.id }); + + await adapter.unlinkAccount?.({ + provider: account.provider, + providerAccountId: account.providerAccountId + }); + dbAccount = await db.account({ + provider: account.provider, + providerAccountId: account.providerAccountId + }); + expect(dbAccount).toBeNull(); + }); + + test('deleteUser', async () => { + let dbUser = await db.user(user.id); + expect(dbUser).toEqual(user); + + // Re-populate db with session and account + delete session.id; + await adapter.createSession(session); + await adapter.linkAccount(account); + + await adapter.deleteUser?.(user.id); + dbUser = await db.user(user.id); + // User should not exist after it is deleted + expect(dbUser).toBeNull(); + + const dbSession = await db.session(session.sessionToken); + // Session should not exist after user is deleted + expect(dbSession).toBeNull(); + + const dbAccount = await db.account({ + provider: account.provider, + providerAccountId: account.providerAccountId + }); + // Account should not exist after user is deleted + expect(dbAccount).toBeNull(); + }); +}); + +function hashToken(token: string) { + return createHash('sha256').update(`${token}anything`).digest('hex'); +} + +const ONE_WEEK_FROM_NOW = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); +const FIFTEEN_MINUTES_FROM_NOW = new Date(Date.now() + 15 * 60 * 1000); +const ONE_MONTH = 1000 * 60 * 60 * 24 * 30; +const ONE_MONTH_FROM_NOW = new Date(Date.now() + ONE_MONTH); diff --git a/test/utils/setup.ts b/test/utils/setup.ts index 17d8e0da7..2202c7596 100644 --- a/test/utils/setup.ts +++ b/test/utils/setup.ts @@ -8,12 +8,12 @@ import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions' import dotenv from 'dotenv'; import { join } from 'path'; import { File, Mock, Suite, TestContext, vi } from 'vitest'; -import { BaseClient, CacheImpl, XataApiClient } from '../../packages/client/src'; +import { BaseClient, CacheImpl, Schemas, XataApiClient } from '../../packages/client/src'; import { getHostUrl, parseProviderString } from '../../packages/client/src/api/providers'; import { TraceAttributes } from '../../packages/client/src/schema/tracing'; import { XataClient } from '../../packages/codegen/example/xata'; import { buildTraceFunction } from '../../packages/plugin-client-opentelemetry'; -import { schema } from '../mock_data'; +import { schema as defaultSchema } from '../mock_data'; // Get environment variables before reading them dotenv.config({ path: join(process.cwd(), '.env') }); @@ -31,6 +31,7 @@ const host = parseProviderString(process.env.XATA_API_PROVIDER); export type EnvironmentOptions = { cache?: CacheImpl; fetch?: any; + schema?: Schemas.Schema; }; export type TestEnvironmentResult = { @@ -57,7 +58,7 @@ export type TestEnvironmentResult = { export async function setUpTestEnvironment( prefix: string, - { cache, fetch: envFetch }: EnvironmentOptions = {} + { cache, fetch: envFetch, schema = defaultSchema }: EnvironmentOptions = {} ): Promise { if (host === null) { throw new Error( @@ -65,7 +66,6 @@ export async function setUpTestEnvironment( ); } - // @ts-expect-error - Fetch doesn't appear in globalThis yet const fetch = vi.fn(envFetch ?? globalThis.fetch); const { trace, tracer } = await setupTracing();