Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/adapter-chiselstrike/README.md
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>&nbsp;&nbsp;&nbsp;&nbsp;<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
5 changes: 5 additions & 0 deletions packages/adapter-chiselstrike/jest.config.js
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"]
};
9 changes: 9 additions & 0 deletions packages/adapter-chiselstrike/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions packages/adapter-chiselstrike/package.json
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"
}
}
193 changes: 193 additions & 0 deletions packages/adapter-chiselstrike/src/index.ts
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 {
Copy link
Member

@balazsorban44 balazsorban44 Jun 26, 2022

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 return statement instead.

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('&')
}
40 changes: 40 additions & 0 deletions packages/adapter-chiselstrike/tests/basic.test.ts
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')
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Author

Choose a reason for hiding this comment

The 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 index.ts? I'm not sure that would increase simplicity or reliability. In case I misunderstood and you did not have duplication in mind, how would fetch calls here differ from the ones in index.ts?

Copy link
Author

@dekimir dekimir Jun 27, 2022

Choose a reason for hiding this comment

The 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.

Copy link
Author

@dekimir dekimir Jun 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(And by fetcher methods, I don't primarily mean fetcher.fetch(). It's more about, eg, fetcher.filter() or fetcher.users().)

Copy link
Author

Choose a reason for hiding this comment

The 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
}
}
}
)
Loading