-
Notifications
You must be signed in to change notification settings - Fork 4k
Add a ChiselStrike database adapter #4573
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f9c6eb7
03fd7a5
5f08947
24a60b7
ab20bf7
125ca13
42903e1
98841a0
58243aa
9b00fcd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| <p align="center"> | ||
| <br/> | ||
| <a href="https://next-auth.js.org" target="_blank"><img height="64px" src="https://next-auth.js.org/img/logo/logo-sm.png" /></a> <img height="64px" src="logo.svg" /> | ||
| <h3 align="center"><b>ChiselStrike Adapter</b> - NextAuth.js</h3> | ||
| <p align="center"> | ||
| Open Source. Full Stack. Own Your Data. | ||
| </p> | ||
| <p align="center" style="align: center;"> | ||
| <img src="https://github.com/nextauthjs/adapters/actions/workflows/release.yml/badge.svg" alt="Build Test" /> | ||
| <img src="https://img.shields.io/bundlephobia/minzip/@next-auth/chiselstrike-adapter/latest" alt="Bundle Size"/> | ||
| <img src="https://img.shields.io/npm/v/@next-auth/chiselstrike-adapter" alt="@next-auth/chiselstrike-adapter Version" /> | ||
| </p> | ||
| </p> | ||
|
|
||
| ## 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(<url>, <auth-secret>)) | ||
|
|
||
| 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('<url>/<branch>/<endpoint-name>', { 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| /** @type { import('@jest/types').Config.InitialOptions } */ | ||
| module.exports = { | ||
| preset: "@next-auth/adapter-test/jest", | ||
| setupFilesAfterEnv: ["./tests/jest-setup.js"] | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Response> { | ||
| 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<null | { [_: string]: string }> { | ||
| 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<AdapterUser, "id">): Promise<AdapterUser> => { | ||
| 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<AdapterUser | null> => { | ||
| const resp = await fetcher.fetch(fetcher.users(`/${id}`)) | ||
| if (!resp.ok) { return null } | ||
| return userFromJson(await resp.json()) | ||
| }, | ||
| getUserByEmail: async (email: string): Promise<AdapterUser | null> => { | ||
| return userFromJson(await fetcher.filter(fetcher.users(`?${makeFilter({ email })}`))) | ||
| }, | ||
| updateUser: async (user: Partial<AdapterUser>): Promise<AdapterUser> => { | ||
| // 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<Account, "provider" | "providerAccountId">): Promise<AdapterUser | null> => { | ||
| 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<void> | Awaitable<AdapterUser | null | undefined>) | undefined; | ||
| deleteUser: async (userId: string): Promise<AdapterUser | null | undefined> => { | ||
| 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<void> | Awaitable<Account | null | undefined>; | ||
| linkAccount: async (account: Account): Promise<Account | null | undefined> => { | ||
| 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<Account, "provider" | "providerAccountId">) => Promise<void> | Awaitable<Account | undefined>) | undefined; | ||
| unlinkAccount: async (providerAccountId: Pick<Account, "provider" | "providerAccountId">): Promise<void> => { | ||
| 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<AdapterSession>; | ||
| createSession: async (session: { sessionToken: string; userId: string; expires: Date; }): Promise<AdapterSession> => { | ||
| 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<AdapterSession> & Pick<AdapterSession, "sessionToken">) => Awaitable<AdapterSession | null | undefined>; | ||
| updateSession: async (session: Partial<AdapterSession> & Pick<AdapterSession, "sessionToken">): Promise<AdapterSession | null | undefined> => { | ||
| // 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<void> | Awaitable<AdapterSession | null | undefined>; | ||
| deleteSession: async (sessionToken: string): Promise<void> => { | ||
| const get = await fetcher.fetch(fetcher.sessions(`?${makeFilter({ sessionToken })}`), { method: 'DELETE' }) | ||
| await ensureOK(get, `getting session with token ${sessionToken}`) | ||
| }, | ||
| // createVerificationToken?: ((verificationToken: VerificationToken) => Awaitable<VerificationToken | null | undefined>) | undefined; | ||
| createVerificationToken: async (verificationToken: VerificationToken): Promise<VerificationToken | null | undefined> => { | ||
| 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<VerificationToken | null>) | undefined; | ||
| useVerificationToken: async (params: { identifier: string; token: string; }): Promise<VerificationToken | null> => { | ||
| 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<string, any>) { | ||
| let filters = Object.entries(propertyValues).map(e => `.${e[0]}=${e[1]}`) | ||
| return filters.join('&') | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { runBasicTests } from "@next-auth/adapter-test" | ||
| import { ChiselStrikeAdapter, ChiselStrikeAuthFetcher } from "../src" | ||
|
|
||
| const fetcher = new ChiselStrikeAuthFetcher('http://localhost:8080', '1234') | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think for the sake of simplicity and more reliable tests, we should do fetch calls instead of this. aligns with my proposal to remove the class altogether.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you mean we should duplicode the fetcher methods both here and in
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Or are you saying fetcher methods should become adapter methods instead? That we can easily do.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (And by fetcher methods, I don't primarily mean
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Fetcher class is modelled on other adapters' Client class, though its name is perhaps suboptimal. |
||
| 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 | ||
| } | ||
| } | ||
| } | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see a need for a class here. I recommend changing the argument here to be a single options object, and set up any helper methods before the
returnstatement instead.