diff --git a/packages/adapter-chiselstrike/README.md b/packages/adapter-chiselstrike/README.md new file mode 100644 index 0000000000..498d07a072 --- /dev/null +++ b/packages/adapter-chiselstrike/README.md @@ -0,0 +1,79 @@ +

+
+      +

ChiselStrike Adapter - NextAuth.js

+

+ Open Source. Full Stack. Own Your Data. +

+

+ Build Test + Bundle Size + @next-auth/chiselstrike-adapter Version +

+

+ +## Overview + +This is the ChiselStrike Adapter for [`next-auth`](https://next-auth.js.org). This package can only be used in conjunction with the primary next-auth package. It is not a standalone package. + +## Getting Started + +Install `next-auth` and `@next-auth/chiselstrike-adapter` in your project: +```js +npm install next-auth @next-auth/chiselstrike-adapter +``` + +Configure NextAuth with the ChiselStrike adapter and a session callback to record the user ID. In +`pages/api/auth/[...nextauth].js`: +```js +const adapter = ChiselStrikeAdapter(new ChiselStrikeAuthFetcher(, )) + +export default NextAuth({ + adapter, + /// ... + callbacks: { + async session({ session, token, user }) { + session.userId = user.id + return session + } + } +}) + +``` + +When accessing ChiselStrike endpoints, you can provide the user ID value in a `ChiselUID` header. This is how +ChiselStrike knows which user is logged into the current session. For example: + +```js +await fetch('//', { headers: { "ChiselUID": session.userId } }) +``` + +## Contributing + +Initial setup: +```bash +git clone git@github.com:nextauthjs/next-auth.git +cd next-auth +pnpm i +pnpm build +cd packages/adapter-chiselstrike +pnpm i +``` + +Before running a build/test cycle, please set up a ChiselStrike backend, either locally or in the cloud. If locally, please create/edit a `.env` file in the directory where `chiseld` runs and put the following line in it: +```json +{ "CHISELD_AUTH_SECRET" : "1234" } +``` +If running a ChiselStrike backend in the cloud, please edit all instances of `new ChiselStrikeAuthFetcher` in the `tests/` directory to reflect your backend's URL and auth secret. + +Build and test: +```bash +cd next-auth/packages/adapter-chiselstrike/ +pnpm test -- tests/basic.test.ts +pnpm test -- tests/custom.test.ts +``` +(Unfortunately, the custom tests interfere with basic tests if run in parallel, so we run them separately.) + +## License + +ISC diff --git a/packages/adapter-chiselstrike/jest.config.js b/packages/adapter-chiselstrike/jest.config.js new file mode 100644 index 0000000000..5b8f8fcc1e --- /dev/null +++ b/packages/adapter-chiselstrike/jest.config.js @@ -0,0 +1,5 @@ +/** @type { import('@jest/types').Config.InitialOptions } */ +module.exports = { + preset: "@next-auth/adapter-test/jest", + setupFilesAfterEnv: ["./tests/jest-setup.js"] +}; diff --git a/packages/adapter-chiselstrike/logo.svg b/packages/adapter-chiselstrike/logo.svg new file mode 100644 index 0000000000..122ad76256 --- /dev/null +++ b/packages/adapter-chiselstrike/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/adapter-chiselstrike/package.json b/packages/adapter-chiselstrike/package.json new file mode 100644 index 0000000000..ef0f6d0d85 --- /dev/null +++ b/packages/adapter-chiselstrike/package.json @@ -0,0 +1,42 @@ +{ + "name": "@next-auth/chiselstrike-adapter", + "version": "0.10.0", + "description": "ChiselStrike adapter for next-auth.", + "homepage": "https://next-auth.js.org", + "repository": "https://github.com/nextauthjs/next-auth", + "bugs": { + "url": "https://github.com/nextauthjs/next-auth/issues" + }, + "author": "Dejan Mircevski", + "main": "dist/index.js", + "license": "ISC", + "keywords": [ + "next-auth", + "next.js", + "oauth", + "chiselstrike" + ], + "private": false, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc", + "test": "jest" + }, + "files": [ + "README.md", + "dist" + ], + "peerDependencies": { + "next-auth": "^4.6.0" + }, + "devDependencies": { + "@next-auth/adapter-test": "workspace:^0.0.0", + "@next-auth/tsconfig": "workspace:^0.0.0", + "@types/jest": "^26.0.24", + "jest": "^27.4.3", + "next-auth": "workspace:*", + "node-fetch": "^2.6.1" + } +} diff --git a/packages/adapter-chiselstrike/src/index.ts b/packages/adapter-chiselstrike/src/index.ts new file mode 100644 index 0000000000..503e3f4e1f --- /dev/null +++ b/packages/adapter-chiselstrike/src/index.ts @@ -0,0 +1,193 @@ +import type { Account } from "next-auth"; +import type { Adapter, AdapterSession, AdapterUser, VerificationToken } from "next-auth/adapters" + +export class ChiselStrikeAuthFetcher { + url: string; /// ChiselStrike backend. + secret: string; + + /// Makes a fetcher pointing to the ChiselStrike backend at this URL. Use the secret provided by the ChiselStrike platform. + constructor(url: string, secret: string) { + this.url = url + this.secret = secret + } + + /// Returns a ChiselStrike server auth path with this suffix. + private auth(suffix: string): string { + return `${this.url}/__chiselstrike/auth/${suffix}` + } + + public sessions(suffix?: string): string { + return this.auth('sessions') + (suffix ?? ''); + } + + public accounts(suffix?: string): string { + return this.auth('accounts') + (suffix ?? ''); + } + + public users(suffix?: string): string { + return this.auth('users') + (suffix ?? ''); + } + + public tokens(suffix?: string): string { + return this.auth('tokens') + (suffix ?? ''); + } + + /// Like regular fetch(), but adds a header with this.secret. + public async fetch(input: RequestInfo, init?: RequestInit | undefined): Promise { + init ??= {} + init.headers = new Headers(init.headers) + init.headers.set('ChiselAuth', this.secret) + return fetch(input, init) + } + + /// Fetches url and returns the first element of the resulting array (or null if anything goes wrong). + public async filter(url: string): Promise { + const res = await this.fetch(url) + if (!res.ok) { return null } + const jres = await res.json() + if (!Array.isArray(jres.results) || jres.results.length < 1) { return null } + return jres.results[0] + } + + public async deleteEverything() { + await Promise.all([ + this.fetch(this.sessions('?all=true'), { method: 'DELETE' }), + this.fetch(this.accounts('?all=true'), { method: 'DELETE' }), + this.fetch(this.users('?all=true'), { method: 'DELETE' }), + this.fetch(this.tokens('?all=true'), { method: 'DELETE' }) + ]) + } +} + +export function ChiselStrikeAdapter(fetcher: ChiselStrikeAuthFetcher): Adapter { + return { + createUser: async (user: Omit): Promise => { + const resp = await fetcher.fetch(fetcher.users(), { method: 'POST', body: JSON.stringify(user) }) + await ensureOK(resp, `posting user ${JSON.stringify(user)}`); + return userFromJson(await resp.json()) + }, + getUser: async (id: string): Promise => { + const resp = await fetcher.fetch(fetcher.users(`/${id}`)) + if (!resp.ok) { return null } + return userFromJson(await resp.json()) + }, + getUserByEmail: async (email: string): Promise => { + return userFromJson(await fetcher.filter(fetcher.users(`?${makeFilter({ email })}`))) + }, + updateUser: async (user: Partial): Promise => { + // TODO: use PATCH + const get = await fetcher.fetch(fetcher.users(`/${user.id}`)) + await ensureOK(get, `getting user ${user.id}`) + let u = await get.json() + Object.keys(user).forEach(key => u[key] = user[key]) + const body = JSON.stringify(u) + const put = await fetcher.fetch(fetcher.users(`/${user.id}`), { method: 'PUT', body }) + await ensureOK(put, `writing user ${body}`) + return userFromJson(u) + }, + getUserByAccount: async (providerAccountId: Pick): Promise => { + const acct = await fetcher.filter(fetcher.accounts(`?${makeFilter(providerAccountId)}`)) + if (!acct) { return null } + const uresp = await fetcher.fetch(fetcher.users(`/${acct.userId}`)) + await ensureOK(uresp, `getting user ${acct.userId}`) + return userFromJson(await uresp.json()) + }, + // deleteUser?: ((userId: string) => Promise | Awaitable) | undefined; + deleteUser: async (userId: string): Promise => { + let resp = await fetcher.fetch(fetcher.users(`/${userId}`), { method: 'DELETE' }) + await ensureOK(resp, `deleting user ${userId}`) + resp = await fetcher.fetch(fetcher.sessions(`?.userId=${userId}`), { method: 'DELETE' }) + await ensureOK(resp, `deleting sessions for ${userId}`) + resp = await fetcher.fetch(fetcher.accounts(`?.userId=${userId}`), { method: 'DELETE' }) + await ensureOK(resp, `deleting accounts for ${userId}`) + return null + }, + // linkAccount: (account: Account) => Promise | Awaitable; + linkAccount: async (account: Account): Promise => { + const body = JSON.stringify(account); + const resp = await fetcher.fetch(fetcher.accounts(), { method: 'POST', body }) + await ensureOK(resp, `posting account ${body}`); + return (await resp.json()).results + }, + // unlinkAccount?: ((providerAccountId: Pick) => Promise | Awaitable) | undefined; + unlinkAccount: async (providerAccountId: Pick): Promise => { + const resp = await fetcher.fetch(fetcher.accounts(`?${makeFilter(providerAccountId)}`), { method: 'DELETE' }) + await ensureOK(resp, `deleting account ${JSON.stringify(providerAccountId)}`) + }, + // createSession: (session: { sessionToken: string; userId: string; expires: Date; }) => Awaitable; + createSession: async (session: { sessionToken: string; userId: string; expires: Date; }): Promise => { + const str = JSON.stringify(session); + const resp = await fetcher.fetch(fetcher.sessions(), { method: 'POST', body: str }) + await ensureOK(resp, `posting session ${JSON.stringify(session)}`); + return sessionFromJson(await resp.json()) + }, + // getSessionAndUser: (sessionToken: string) => Awaitable<{ session: AdapterSession; user: AdapterUser; } | null>; + getSessionAndUser: async (sessionToken: string): Promise<{ session: AdapterSession; user: AdapterUser; } | null> => { + const session = await fetcher.filter(fetcher.sessions(`?.sessionToken=${sessionToken}`)) + if (!session) { return null } + const rUser = await fetcher.fetch(fetcher.users(`/${session.userId}`)) + await ensureOK(rUser, `fetching user ${session.userId}`) + return { session: sessionFromJson(session), user: userFromJson(await rUser.json()) } + }, + // updateSession: (session: Partial & Pick) => Awaitable; + updateSession: async (session: Partial & Pick): Promise => { + // TODO: use PATCH + const dbSession = await fetcher.filter(fetcher.sessions(`?.sessionToken=${session.sessionToken}`)) + if (!dbSession) { return null } + Object.entries(session).forEach(([key, entry]) => { + if (entry instanceof Date) { + dbSession[key] = entry.toISOString(); + } else { + dbSession[key] = entry; + } + }); + const body = JSON.stringify(dbSession) + const put = await fetcher.fetch(fetcher.sessions(`/${dbSession.id}`), { method: 'PUT', body }) + await ensureOK(put, `writing user ${body}`) + return dbSession as unknown as AdapterSession + }, + // deleteSession: (sessionToken: string) => Promise | Awaitable; + deleteSession: async (sessionToken: string): Promise => { + const get = await fetcher.fetch(fetcher.sessions(`?${makeFilter({ sessionToken })}`), { method: 'DELETE' }) + await ensureOK(get, `getting session with token ${sessionToken}`) + }, + // createVerificationToken?: ((verificationToken: VerificationToken) => Awaitable) | undefined; + createVerificationToken: async (verificationToken: VerificationToken): Promise => { + const str = JSON.stringify(verificationToken); + const resp = await fetcher.fetch(fetcher.tokens(), { method: 'POST', body: str }); + await ensureOK(resp, `posting token ${str}`) + const token = await resp.json() + return { identifier: token.identifier, token: token.token, expires: new Date(token.expires) } + }, + // useVerificationToken?: ((params: { identifier: string; token: string; }) => Awaitable) | undefined; + useVerificationToken: async (params: { identifier: string; token: string; }): Promise => { + const token = await fetcher.filter(fetcher.tokens(`?${makeFilter(params)}`)) + if (!token) { return null } + await fetcher.fetch(fetcher.tokens(`/${token.id}`), { method: 'DELETE' }) + return { identifier: token.identifier, token: token.token, expires: new Date(token.expires) } + } + } +} + +function userFromJson(json: any): AdapterUser { + const user = json?.results ?? json + return user ? { ...user, emailVerified: new Date(user.emailVerified) } : null +} + +async function ensureOK(resp: Response, during: string) { + if (!resp.ok) { + const msg = `Error ${resp.status} during ${during}: ${await resp.text()}`; + console.error(msg); + throw msg; + } +} + +function sessionFromJson(json: any): AdapterSession { + const session = json?.results ?? json + return { ...session, expires: new Date(session.expires), id: session.id ?? 'impossible: fetched CSession has null ID' } +} + +function makeFilter(propertyValues: Record) { + let filters = Object.entries(propertyValues).map(e => `.${e[0]}=${e[1]}`) + return filters.join('&') +} \ No newline at end of file diff --git a/packages/adapter-chiselstrike/tests/basic.test.ts b/packages/adapter-chiselstrike/tests/basic.test.ts new file mode 100644 index 0000000000..df21cf0f08 --- /dev/null +++ b/packages/adapter-chiselstrike/tests/basic.test.ts @@ -0,0 +1,40 @@ +import { runBasicTests } from "@next-auth/adapter-test" +import { ChiselStrikeAdapter, ChiselStrikeAuthFetcher } from "../src" + +const fetcher = new ChiselStrikeAuthFetcher('http://localhost:8080', '1234') +const adapter = ChiselStrikeAdapter(fetcher) + +runBasicTests( + { + adapter, + db: { + connect: async () => { + await fetcher.deleteEverything() + }, + session: async (sessionToken: string) => { + const s = await fetcher.filter(fetcher.sessions(`?.sessionToken=${sessionToken}`)) + return s ? { ...s, expires: new Date(s.expires) } : null + }, + user: async (id: string) => { + const resp = await fetcher.fetch(fetcher.users(`/${id}`)) + if (!resp.ok) { return null } + const json = await resp.json() + return { ...json, emailVerified: new Date(json.emailVerified) } + }, + account: async (providerAccountId: { provider: string; providerAccountId: string }) => { + const providerFilter = providerAccountId.provider ? `.provider=${providerAccountId.provider}` : '' + const providerAccountIdFilter = + providerAccountId.providerAccountId ? `.providerAccountId=${providerAccountId.providerAccountId}` : '' + return await fetcher.filter( + fetcher.accounts(`?${providerFilter}&${providerAccountIdFilter}`)) + }, + verificationToken: async (params: { identifier: string; token: string }) => { + const idFilter = `.identifier=${params.identifier}` + const tokenFilter = `.token=${params.token}` + let token = await fetcher.filter( + fetcher.tokens(`?${idFilter}&${tokenFilter}`)) + return token ? { ...token, expires: new Date(token.expires), id: undefined } : null + } + } + } +) \ No newline at end of file diff --git a/packages/adapter-chiselstrike/tests/custom.test.ts b/packages/adapter-chiselstrike/tests/custom.test.ts new file mode 100644 index 0000000000..4bc78d5ca5 --- /dev/null +++ b/packages/adapter-chiselstrike/tests/custom.test.ts @@ -0,0 +1,134 @@ +/** @file + * Unit tests for the ChiselStrike adapter for NextAuth.js. To run, first create a .env file + * with the following contents: + * + * { "CHISELD_AUTH_SECRET" : "1234" } + * + * Start `chiseld` in the directory of that .env file, then run `jest` here in this directory. + */ + +import { Account } from "next-auth" +import { ChiselStrikeAdapter, ChiselStrikeAuthFetcher } from "../src" +export { } + +const fetcher = new ChiselStrikeAuthFetcher('http://localhost:8080', '1234') +const a = ChiselStrikeAdapter(fetcher) + +beforeEach(async () => { + await fetcher.deleteEverything() +}) + +test('createUser returns payload', async () => { + const uMinimal = await a.createUser({ emailVerified: '2022-03-01' }) + expect(uMinimal).not.toBeNull() + expect(uMinimal?.emailVerified).toStrictEqual(new Date('2022-03-01')) + + const u2 = await a.createUser({ emailVerified: '2022-03-02', name: 'Bono', email: 'a@b.co', image: '' }) + expect(u2).not.toBeNull() + expect(u2?.emailVerified).toStrictEqual(new Date('2022-03-02')) + expect(u2?.name).toBe('Bono') + expect(u2?.email).toBe('a@b.co') + expect(u2?.image).toBe('') +}) + +test('getUser gets the right user', async () => { + const created = await a.createUser({ emailVerified: '2022-03-01', name: 'Johnny' }) + expect(created).not.toBeNull() + if (!created) return + const obtained = await a.getUser(created.id) + expect(obtained).toStrictEqual(created) +}) + +test('getUser returns null for non-existent ID', async () => { + expect(await a.getUser('never-going-to-exist-because-this-is-just-a-test')).toBeNull() +}) + +test('getUserByEmail gets the right user', async () => { + const email = 'dejan@chiselstrike.com' + const u1 = await a.createUser({ emailVerified: '2022-03-03', email, name: 'Heimdall' }) + expect(u1).not.toBeNull() + const u2 = await a.createUser({ emailVerified: '2022-03-03', email: '', name: 'Heimdall' }); + expect(u2).not.toBeNull() + expect(await a.getUserByEmail(email)).toStrictEqual(u1) + expect(await a.getUserByEmail('')).toStrictEqual(u2) +}) + +test('getUserByEmail returns null for non-existing email', async () => { + expect(await a.getUserByEmail('some absolutely non-existing email')).toBeNull() +}) + +test('updateUser updates correctly', async () => { + const id = (await a.createUser({ emailVerified: '2022-03-03', name: 'Popeye' }))?.id + const res = await a.updateUser({ id, name: 'Bluto', image: 'jpg' }) + expect(res).toStrictEqual({ name: 'Bluto', image: 'jpg', emailVerified: new Date('2022-03-03'), id }) + if (!id) { return } + const get = await a.getUser(id) + expect(get).toStrictEqual(res) +}) + +test('updateUser rejects non-existing id', async () => { + await expect(a.updateUser({ id: 'something completely unexpected and unique', email: 'rhododendron' })).rejects.toMatch('completely unexpected') +}) + +test('linkAccount results in getUserByAccount finding the right account', async () => { + const ghUser = await a.createUser({ emailVerified: '2022-03-03', name: 'GHUser' }) + await a.linkAccount({ provider: 'github', providerAccountId: '1234', userId: ghUser.id, type: 'oauth' }) + const twUser = await a.createUser({ emailVerified: '2022-03-03', name: 'TwUser' }) + await a.linkAccount({ provider: 'twitter', providerAccountId: '4321', userId: twUser.id, type: 'oauth' }) + expect(await a.getUserByAccount({ provider: 'github', providerAccountId: '1234' })).toStrictEqual(ghUser) + expect(await a.getUserByAccount({ provider: 'twitter', providerAccountId: '4321' })).toStrictEqual(twUser) +}) + +test('getUserByAccount returns null on mismatch', async () => { + expect(await a.getUserByAccount({ providerAccountId: 'impossible to exist', provider: 'fffff' })).toBeNull() +}) + +test('user deletion works', async () => { + const u = await a.createUser({ emailVerified: '2022-03-04', name: 'Tinker Bell' }) + expect(await a.getUser(u.id)).toStrictEqual(u) + expect(await a.deleteUser(u.id)).toBeNull() + expect(await a.getUser(u.id)).toBeNull() +}) + +test('unlinkAccount makes getUserByAccount not find the account (but getUser still finds the user)', async () => { + const u = await a.createUser({ emailVerified: '2022-03-03', name: 'Popeye' }) + const acct = { provider: 'github', providerAccountId: '1234', userId: u.id, type: 'oauth' } as Account + await a.linkAccount(acct) + expect(await a.getUserByAccount({ provider: 'github', providerAccountId: '1234' })).toStrictEqual(u) + await a.unlinkAccount(acct) + expect(await a.getUserByAccount({ provider: 'github', providerAccountId: '1234' })).toBeNull() + expect(await a.getUser(u.id)).toStrictEqual(u) +}) + +test('createSession makes getSessionAndUser find the created session', async () => { + const user = await a.createUser({ emailVerified: '2022-03-07', name: 'Oscar' }) + await a.createSession({ sessionToken: 'dummy', expires: new Date, userId: user.id }) + const session = await a.createSession({ sessionToken: 'token1', expires: new Date, userId: user.id }) + expect(await a.getSessionAndUser('token1')).toStrictEqual({ user, session }) +}) + +test('updateSession updates correctly', async () => { + const user = await a.createUser({ emailVerified: '2022-03-07', name: 'Oscar' }) + const s1 = await a.createSession({ sessionToken: 's1', expires: new Date, userId: user.id }) + const s2 = await a.createSession({ sessionToken: 's2', expires: new Date(10000), userId: user.id }) + await a.updateSession({ sessionToken: 's2', expires: new Date(50000) }) + expect(await a.getSessionAndUser('s2')).toStrictEqual({ user, session: { ...s2, expires: new Date(50000) } }) + expect(await a.getSessionAndUser('s1')).toStrictEqual({ user, session: s1 }) +}) + +test('deleteSession deletes the right session', async () => { + const user = await a.createUser({ emailVerified: '2022-03-07', name: 'Oscar' }) + const s1 = await a.createSession({ sessionToken: 's1', expires: new Date, userId: user.id }) + const s2 = await a.createSession({ sessionToken: 's2', expires: new Date, userId: user.id }) + await a.deleteSession('s1') + expect(await a.getSessionAndUser('s1')).toBeNull() + expect(await a.getSessionAndUser('s2')).toStrictEqual({ user, session: s2 }) +}) + +test('useVerificationToken finds token, deletes it', async () => { + const user = await a.createUser({ emailVerified: '2022-03-07', name: 'Oscar' }) + await a.createVerificationToken({ expires: new Date('2022-03-10'), identifier: '321', token: 'dummy' }) + const token = await a.createVerificationToken({ expires: new Date('2022-03-10'), identifier: '123', token: 'abc' }) + expect(await a.useVerificationToken({ identifier: '123', token: 'abc' })).toStrictEqual(token) + expect(await a.useVerificationToken({ identifier: '123', token: 'abc' })).toBeNull() +}) diff --git a/packages/adapter-chiselstrike/tests/jest-setup.js b/packages/adapter-chiselstrike/tests/jest-setup.js new file mode 100644 index 0000000000..96c9aa1a31 --- /dev/null +++ b/packages/adapter-chiselstrike/tests/jest-setup.js @@ -0,0 +1,3 @@ +import fetch from "node-fetch" +globalThis.fetch = fetch; +globalThis.Headers = fetch.Headers; diff --git a/packages/adapter-chiselstrike/tsconfig.json b/packages/adapter-chiselstrike/tsconfig.json new file mode 100644 index 0000000000..bf0a7cabce --- /dev/null +++ b/packages/adapter-chiselstrike/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@next-auth/tsconfig/adapters.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "exclude": ["tests", "dist", "jest.config.js"] +}