diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml index 89af700064..f99f2741ca 100644 --- a/.github/pr-labeler.yml +++ b/.github/pr-labeler.yml @@ -16,6 +16,7 @@ hasura: ["packages/adapter-hasura/**/*"] frameworks: ["packages/frameworks-*/**/*"] mikro-orm: ["packages/adapter-mikro-orm/**/*"] mongodb: ["packages/adapter-mongodb/**/*"] +nats-kv: ["packages/adapter-nats-kv/**/*"] neo4j: ["packages/adapter-neo4j/**/*"] next-auth: ["packages/next-auth/**/*"] pg: ["packages/adapter-pg/**/*"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ee78afa19..70e630e2e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,7 @@ on: - "@auth/kysely-adapter" - "@auth/mikro-orm-adapter" - "@auth/mongodb-adapter" + - "@auth/nats-kv-adapter" - "@auth/neo4j-adapter" - "@auth/pg-adapter" - "@auth/pouchdb-adapter" diff --git a/docs/pages/getting-started/adapters/_meta.js b/docs/pages/getting-started/adapters/_meta.js index 153672f783..a790aac41c 100644 --- a/docs/pages/getting-started/adapters/_meta.js +++ b/docs/pages/getting-started/adapters/_meta.js @@ -11,6 +11,7 @@ export default { kysely: "Kysely", "mikro-orm": "MikroORM", mongodb: "MongoDB", + nats: "NATS KV", neo4j: "Neo4j", neon: "Neon", pg: "PostgreSQL", diff --git a/docs/pages/getting-started/adapters/nats.mdx b/docs/pages/getting-started/adapters/nats.mdx new file mode 100644 index 0000000000..a6f0563e34 --- /dev/null +++ b/docs/pages/getting-started/adapters/nats.mdx @@ -0,0 +1,319 @@ +import { Code } from "@/components/Code" + + + +# NATS KV Adapter + +## Resources + +- [NATS documentation](https://docs.nats.io/) + +## Setup + +### Installation + +```bash npm2yarn +npm install @nats-io/transport-node @nats-io/kv @auth/nats-kv-adapter +``` + +### Environment Variables + +```sh +NATS_SERVERS, +NATS_CREDS +``` + +### Configuration + +You can either use this with Symbol.asyncDispose or handle the disposal yourself. + +#### With explicit resource management + +If you do choose asyncDispose, make sure you environment is configured to handled that by targeting at least es2022 and the `lib` option to include `esnext` or `esnext.disposable`, or by providing a polyfill. Using this pattern the adapter will call the cleanup function when the adapter is after NATS operations. [https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management) + + + + +```ts filename="./auth.ts" +import NextAuth from "next-auth" +import { NatsKVAdapter } from "@auth/nats-kv-adapter" +import { connect } from "@nats-io/transport-node" +import { Kvm, KV } from "@nats-io/kv" + +async function getNats(): Promise< + { kv: KV } & { + [Symbol.asyncDispose]: () => Promise + } +> { + const nc = await connect({ + servers: process.env.NATS_SERVERS, + authenticator: process.env.NATS_CREDS, + }) + const kvm = new Kvm(nc) + const kv = await kvm.create("name-of-auth-bucket") + + return { + kv: kv, + [Symbol.asyncDispose]: async () => { + await nc.drain() + await nc.close() + }, + } +} + +export const { handlers, auth, signIn, signOut } = NextAuth({ + adapter: NatsKVAdapter(getNats), + providers: [], +}) +``` + + + + +```ts filename="/src/routes/plugin@auth.ts" +import { QwikAuth$ } from "@auth/qwik" +import { NatsKVAdapter } from "@auth/nats-kv-adapter" +import { connect } from "@nats-io/transport-node" +import { Kvm, KV } from "@nats-io/kv" + +async function getNats(): Promise< + { kv: KV } & { + [Symbol.asyncDispose]: () => Promise + } +> { + const nc = await connect({ + servers: process.env.NATS_SERVERS, + authenticator: process.env.NATS_CREDS, + }) + const kvm = new Kvm(nc) + const kv = await kvm.create("name-of-auth-bucket") + + return { + kv: kv, + [Symbol.asyncDispose]: async () => { + await nc.drain() + await nc.close() + }, + } +} + +export const { onRequest, useSession, useSignIn, useSignOut } = QwikAuth$( + () => ({ + providers: [], + adapter: NatsKVAdapter(getNats), + }) +) +``` + + + + +```ts filename="./src/auth.ts" +import { SvelteKitAuth } from "@auth/sveltekit" +import { NatsKVAdapter } from "@auth/nats-kv-adapter" +import { connect } from "@nats-io/transport-node" +import { Kvm, KV } from "@nats-io/kv" + +async function getNats(): Promise< + { kv: KV } & { + [Symbol.asyncDispose]: () => Promise + } +> { + const nc = await connect({ + servers: process.env.NATS_SERVERS, + authenticator: process.env.NATS_CREDS, + }) + const kvm = new Kvm(nc) + const kv = await kvm.create("name-of-auth-bucket") + + return { + kv: kv, + [Symbol.asyncDispose]: async () => { + await nc.drain() + await nc.close() + }, + } +} + +export const { handle, signIn, signOut } = SvelteKitAuth({ + adapter: NatsKVAdapter(getNats), + providers: [], +}) +``` + + + + +```ts filename="./src/routes/auth.route.ts" +import { ExpressAuth } from "@auth/express" +import { NatsKVAdapter } from "@auth/nats-kv-adapter" +import { connect } from "@nats-io/transport-node" +import { Kvm, KV } from "@nats-io/kv" + +async function getNats(): Promise< + { kv: KV } & { + [Symbol.asyncDispose]: () => Promise + } +> { + const nc = await connect({ + servers: process.env.NATS_SERVERS, + authenticator: process.env.NATS_CREDS, + }) + const kvm = new Kvm(nc) + const kv = await kvm.create("name-of-auth-bucket") + + return { + kv: kv, + [Symbol.asyncDispose]: async () => { + await nc.drain() + await nc.close() + }, + } +} + +const app = express() + +app.set("trust proxy", true) +app.use( + "/auth/*", + ExpressAuth({ + providers: [], + adapter: NatsKVAdapter(getNats), + }) +) +``` + + + + +#### Without explicit resource management + +You can instead provide the adapter with a KV instance, and handle the connection and disposal yourself. Useful if you want to keep the connection alive, or have explicit control over the connection. + + + + +```ts filename="./auth.ts" +import NextAuth from "next-auth" +import { NatsKVAdapter } from "@auth/nats-kv-adapter" +import { connect } from "@nats-io/transport-node" +import { Kvm, KV } from "@nats-io/kv" + +const nc = await connect({ + servers: process.env.NATS_SERVERS, + authenticator: process.env.NATS_CREDS, +}) +const kvm = new Kvm(nc) +const kv = await kvm.create("name-of-auth-bucket") + +export const { handlers, auth, signIn, signOut } = NextAuth({ + adapter: NatsKVAdapter(kv), + providers: [], +}) +``` + + + + +```ts filename="/src/routes/plugin@auth.ts" +import { QwikAuth$ } from "@auth/qwik" +import { NatsKVAdapter } from "@auth/nats-kv-adapter" +import { connect } from "@nats-io/transport-node" +import { Kvm, KV } from "@nats-io/kv" + +const nc = await connect({ + servers: process.env.NATS_SERVERS, + authenticator: process.env.NATS_CREDS, +}) +const kvm = new Kvm(nc) +const kv = await kvm.create("name-of-auth-bucket") + +export const { onRequest, useSession, useSignIn, useSignOut } = QwikAuth$( + () => ({ + providers: [], + adapter: NatsKVAdapter(kv), + }) +) +``` + + + + +```ts filename="./src/auth.ts" +import { SvelteKitAuth } from "@auth/sveltekit" +import { NatsKVAdapter } from "@auth/nats-kv-adapter" +import { connect } from "@nats-io/transport-node" +import { Kvm, KV } from "@nats-io/kv" + +const nc = await connect({ + servers: process.env.NATS_SERVERS, + authenticator: process.env.NATS_CREDS, +}) +const kvm = new Kvm(nc) +const kv = await kvm.create("name-of-auth-bucket") + +export const { handle, signIn, signOut } = SvelteKitAuth({ + adapter: NatsKVAdapter(kv), + providers: [], +}) +``` + + + + +```ts filename="./src/routes/auth.route.ts" +import { ExpressAuth } from "@auth/express" +import { NatsKVAdapter } from "@auth/nats-kv-adapter" +import { connect } from "@nats-io/transport-node" +import { Kvm, KV } from "@nats-io/kv" + +const nc = await connect({ + servers: process.env.NATS_SERVERS, + authenticator: process.env.NATS_CREDS, +}) +const kvm = new Kvm(nc) +const kv = await kvm.create("name-of-auth-bucket") + +const app = express() + +app.set("trust proxy", true) +app.use( + "/auth/*", + ExpressAuth({ + providers: [], + adapter: NatsKVAdapter(kv), + }) +) +``` + + + + +### Advanced usage + +If you have multiple Auth.js connected apps using this instance, you need different key prefixes for every app. + +You can change the prefixes by passing an `options` object as the second argument to the adapter factory function. + +The default values for this object are: + +```ts +const defaultOptions = { + baseKeyPrefix: "", + accountKeyPrefix: "user:account:", + accountByUserIdPrefix: "user:account:by-user-id:", + emailKeyPrefix: "user:email:", + sessionKeyPrefix: "user:session:", + sessionByUserIdKeyPrefix: "user:session:by-user-id:", + userKeyPrefix: "user:", + verificationTokenKeyPrefix: "user:token:", +} +``` + +Usually changing the `baseKeyPrefix` should be enough for this scenario, but for more custom setups, you can also change the prefixes of every single key. + +```ts +export const { handlers, auth, signIn, signOut } = NextAuth({ + adapter: NatsKVAdapter(kv, { baseKeyPrefix: "app2:" }), +}) +``` diff --git a/docs/public/img/adapters/nats.svg b/docs/public/img/adapters/nats.svg new file mode 100644 index 0000000000..4a44b41a78 --- /dev/null +++ b/docs/public/img/adapters/nats.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/adapter-nats-kv/README.md b/packages/adapter-nats-kv/README.md new file mode 100644 index 0000000000..10a8211f86 --- /dev/null +++ b/packages/adapter-nats-kv/README.md @@ -0,0 +1,28 @@ +

+
+ + + + + + +

NATS KeyValue-store Adapter - NextAuth.js / Auth.js

+

+ + TypeScript + + + npm + + + Downloads + + + GitHub Stars + +

+

+ +--- + +Check out the documentation at [authjs.dev](https://authjs.dev/reference/adapter/nats-kv). diff --git a/packages/adapter-nats-kv/docker-compose.yml b/packages/adapter-nats-kv/docker-compose.yml new file mode 100644 index 0000000000..c605bca47a --- /dev/null +++ b/packages/adapter-nats-kv/docker-compose.yml @@ -0,0 +1,6 @@ +services: + nats: + image: nats:latest + command: -js -p 5222 + ports: + - "5222:5222" diff --git a/packages/adapter-nats-kv/package.json b/packages/adapter-nats-kv/package.json new file mode 100644 index 0000000000..2e19b2937d --- /dev/null +++ b/packages/adapter-nats-kv/package.json @@ -0,0 +1,56 @@ +{ + "name": "@auth/nats-kv-adapter", + "version": "1.0.0", + "description": "NATS KeyValue-store adapter for Auth.js.", + "homepage": "https://authjs.dev", + "repository": "https://github.com/nextauthjs/next-auth", + "bugs": { + "url": "https://github.com/nextauthjs/next-auth/issues" + }, + "author": "Team Kjøttkontroll @ Mattilsynet", + "contributors": [ + "github.com/eljarh", + "github.com/lfbergee" + ], + "type": "module", + "types": "./index.d.ts", + "files": [ + "*.js", + "*.d.ts*", + "src" + ], + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./index.js" + } + }, + "license": "ISC", + "keywords": [ + "next-auth", + "next.js", + "oauth", + "NATS", + "nats.io" + ], + "private": false, + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "./test/test.sh", + "build": "tsc", + "clean": "rm -rf *.js *.d.ts*" + }, + "dependencies": { + "@auth/core": "workspace:*" + }, + "peerDependencies": { + "@nats-io/kv": "^3.1.0", + "@nats-io/transport-node": "^3.1.0" + }, + "devDependencies": { + "@types/uuid": "^8.3.3", + "dotenv": "^17.0.0" + } +} diff --git a/packages/adapter-nats-kv/src/index.ts b/packages/adapter-nats-kv/src/index.ts new file mode 100644 index 0000000000..8fc9759b17 --- /dev/null +++ b/packages/adapter-nats-kv/src/index.ts @@ -0,0 +1,322 @@ +/** + *
+ *

Official NATS KeyValue adapter for Auth.js / NextAuth.js.

+ * + * + * + *
+ * + * ## Installation + * + * ```bash npm2yarn + * npm install @nats-io/kv @nats-io/transport-node @auth/nats-kv-adapter + * ``` + * + * @module @auth/nats-kv-adapter + */ +import { + type Adapter, + type AdapterUser, + type AdapterAccount, + type AdapterSession, + type VerificationToken, + isDate, +} from "@auth/core/adapters" +import { KV } from "@nats-io/kv" + +/** + * + * You can either use this with Symbol.asyncDispose or handle the disposal yourself. + * https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management + * If you do choose asyncDispose, make sure you environment is configured to handled that + * by targeting at least es2022 and the `lib` option to include `esnext` or `esnext.disposable`, + * or by providing a polyfill. Using this pattern the adapter will call the cleanup function when the adapter is after NATS operations. + * + * You can instead provide the adapter with a KV instance, and handle the connection and disposal yourself. + * + * Usage: + * const kvm = new Kvm(client); + * const authKV = await kvm.create("authKV"); + * export const { handlers, auth, signIn, signOut } = NextAuth({ + * adapter: NatsKVAdapter(authKV), + * providers: [], + * }) + */ + +/** This is the interface of the Nats KV adapter options. */ +export interface NatsKVAdapterOptions { + /** + * The base prefix for your keys + */ + baseKeyPrefix?: string + /** + * The prefix for the `account` key + */ + accountKeyPrefix?: string + /** + * The prefix for the `accountByUserId` key + */ + accountByUserIdPrefix?: string + /** + * The prefix for the `emailKey` key + */ + emailKeyPrefix?: string + /** + * The prefix for the `sessionKey` key + */ + sessionKeyPrefix?: string + /** + * The prefix for the `sessionByUserId` key + */ + sessionByUserIdKeyPrefix?: string + /** + * The prefix for the `user` key + */ + userKeyPrefix?: string + /** + * The prefix for the `verificationToken` key + */ + verificationTokenKeyPrefix?: string +} + +export const defaultOptions = { + baseKeyPrefix: "", + accountKeyPrefix: "user.account.", + accountByUserIdPrefix: "user.account.by-user-id.", + emailKeyPrefix: "user.email.", + sessionKeyPrefix: "user.session.", + sessionByUserIdKeyPrefix: "user.session.by-user-id.", + userKeyPrefix: "user.", + verificationTokenKeyPrefix: "user.token.", +} + +export function hydrateDates(json: object) { + return Object.entries(json).reduce((acc, [key, val]) => { + acc[key] = isDate(val) ? new Date(val as string) : val + return acc + }, {} as any) +} + +// replace symbols that are not allowed in keys +export function natsKey(identifier: string) { + return identifier + .replace(/@/g, "_at_") + .replace(/:/g, "_colon_") + .replace(/ /g, "_") as string +} + +export function nats2json(value: any) { + return JSON.parse(value.toString()) +} + +export function NatsKVAdapter( + natsConnect: + | (() => Promise< + { kv: KV } & { + [Symbol.asyncDispose]: () => Promise + } + >) + | KV, + options: NatsKVAdapterOptions = {} +): Adapter { + const [disposableConnection, kv] = + typeof natsConnect === "function" + ? [natsConnect, null] + : [null, natsConnect] + + const mergedOptions = { + ...defaultOptions, + ...options, + } + + const { baseKeyPrefix } = mergedOptions + const accountKeyPrefix = baseKeyPrefix + mergedOptions.accountKeyPrefix + const accountByUserIdPrefix = + baseKeyPrefix + mergedOptions.accountByUserIdPrefix + const emailKeyPrefix = baseKeyPrefix + mergedOptions.emailKeyPrefix + const sessionKeyPrefix = baseKeyPrefix + mergedOptions.sessionKeyPrefix + const sessionByUserIdKeyPrefix = + baseKeyPrefix + mergedOptions.sessionByUserIdKeyPrefix + const userKeyPrefix = baseKeyPrefix + mergedOptions.userKeyPrefix + const verificationTokenKeyPrefix = + baseKeyPrefix + mergedOptions.verificationTokenKeyPrefix + + const natsPutJson = async (key: string, obj: any) => { + return await natsPut(key, JSON.stringify(obj)) + } + + const natsPut = async (key: string, obj: any) => { + if (disposableConnection) { + await using nc = await disposableConnection() + return await nc.kv.put(key, obj) + } else { + return await kv.put(key, obj) + } + } + + const natsPurge = async (key: string) => { + if (disposableConnection) { + await using nc = await disposableConnection() + return await nc.kv.purge(key) + } else { + return await kv.purge(key) + } + } + + const natsGet = async (key: string) => { + if (disposableConnection) { + await using nc = await disposableConnection() + return await nc.kv.get(key) + } else { + return await kv.get(key) + } + } + + const setAccount = async (id: string, account: AdapterAccount) => { + const accountKey = accountKeyPrefix + natsKey(id) + await natsPutJson(accountKey, account) + await natsPut(accountByUserIdPrefix + natsKey(account.userId), accountKey) + return account + } + + const getAccount = async (id: string) => { + const data = await natsGet(accountKeyPrefix + natsKey(id)) + if (!data || data.length == 0) return null + const account = data.json() + return hydrateDates(account) + } + + const setSession = async ( + id: string, + session: AdapterSession + ): Promise => { + const sessionKey = sessionKeyPrefix + natsKey(id) + await natsPutJson(sessionKey, session) + await natsPut( + sessionByUserIdKeyPrefix + natsKey(session.userId), + sessionKey + ) + return session + } + + const getSession = async (id: string) => { + const data = await natsGet(sessionKeyPrefix + natsKey(id)) + if (!data || data.length == 0) return null + const session = data.json() + return hydrateDates(session) + } + + const setUser = async ( + id: string, + user: AdapterUser + ): Promise => { + await natsPutJson(userKeyPrefix + natsKey(id), user) + await natsPut(`${emailKeyPrefix}${natsKey(user.email)}`, id) + return user + } + + const getUser = async (id: string) => { + const data = await natsGet(userKeyPrefix + natsKey(id)) + if (!data || data.length == 0) return null + const user = data.json() + + return hydrateDates(user) + } + + return { + async createUser(user) { + const id = crypto.randomUUID() + return await setUser(id, { ...user, id }) + }, + getUser, + async getUserByEmail(email) { + const data = await natsGet(emailKeyPrefix + natsKey(email)) + + if (!data || data.length == 0) return null + const userId = data.string() + return await getUser(userId) + }, + async getUserByAccount(account) { + const dbAccount = await getAccount( + `${account.provider}.${account.providerAccountId}` + ) + if (!dbAccount) return null + return await getUser(dbAccount.userId) + }, + async updateUser(updates) { + const userId = updates.id as string + const user = await getUser(userId) + return await setUser(userId, { ...(user as AdapterUser), ...updates }) + }, + async linkAccount(account) { + const id = `${account.provider}.${account.providerAccountId}` + return await setAccount(id, { ...account, id }) + }, + createSession: (session) => setSession(session.sessionToken, session), + async getSessionAndUser(sessionToken) { + const session = await getSession(sessionToken) + if (!session) return null + const user = await getUser(session.userId) + if (!user) return null + return { session, user } + }, + async updateSession(updates) { + const session = await getSession(updates.sessionToken) + if (!session) return null + return await setSession(updates.sessionToken, { ...session, ...updates }) + }, + async deleteSession(sessionToken) { + await natsPurge(sessionKeyPrefix + sessionToken) + }, + async createVerificationToken(verificationToken) { + await natsPutJson( + verificationTokenKeyPrefix + + natsKey(verificationToken.identifier) + + "." + + natsKey(verificationToken.token), + verificationToken + ) + return verificationToken + }, + async useVerificationToken(verificationToken) { + const tokenKey = + verificationTokenKeyPrefix + + natsKey(verificationToken.identifier) + + "." + + natsKey(verificationToken.token) + const data = await natsGet(tokenKey) + if (!data || data.length == 0) return null + const token = data.json() + await natsPurge(tokenKey) + return hydrateDates(token) + }, + async unlinkAccount(account) { + const id = `${account.provider}.${account.providerAccountId}` + const dbAccount = await getAccount(natsKey(id)) + if (!dbAccount) return + const accountKey = `${accountKeyPrefix}${natsKey(id)}` + await natsPurge(accountKey) + await natsPurge(`${(accountByUserIdPrefix + dbAccount.userId) as string}`) + }, + async deleteUser(userId) { + const user = await getUser(natsKey(userId)) + if (!user) return + const accountByUserKey = accountByUserIdPrefix + natsKey(userId) + const accountKey = await natsGet(accountByUserKey).then((data) => + data?.string() + ) + const sessionByUserIdKey = sessionByUserIdKeyPrefix + natsKey(userId) + const sessionKey = await natsGet(sessionByUserIdKey).then((data) => + data?.string() + ) + await Promise.all([ + natsPurge(userKeyPrefix + natsKey(userId)), + natsPurge(`${emailKeyPrefix}${natsKey(user.email)}`), + natsPurge(accountKey as string), + natsPurge(accountByUserKey), + natsPurge(sessionKey as string), + natsPurge(sessionByUserIdKey), + ]) + }, + } +} diff --git a/packages/adapter-nats-kv/test/index.test.ts b/packages/adapter-nats-kv/test/index.test.ts new file mode 100644 index 0000000000..b548b05213 --- /dev/null +++ b/packages/adapter-nats-kv/test/index.test.ts @@ -0,0 +1,112 @@ +import { runBasicTests } from "../../utils/adapter" +import { hydrateDates, NatsKVAdapter, natsKey } from "../src" +import "dotenv/config" +import { connect } from "@nats-io/transport-node" +import { Kvm, KV } from "@nats-io/kv" + +// This functions allows us to open a connection without having to worry about closing it +async function getNextAuthKVandCloseConnection(): Promise< + { kv: KV } & { + [Symbol.asyncDispose]: () => Promise + } +> { + const nc = await connect({ + servers: "nats://localhost:5222", + authenticator: undefined, + }) + const kvm = new Kvm(nc) + const kv = await kvm.create("authKV") + + return { + kv: kv, + [Symbol.asyncDispose]: async () => { + await nc.drain() + await nc.close() + }, + } +} + +await runBasicTests({ + adapter: NatsKVAdapter(getNextAuthKVandCloseConnection, { + baseKeyPrefix: "testApp.", + }), + db: { + disconnect: async () => { + //do nothing - since the connection itself handles this (was: await nc.close()) + }, + async account({ provider, providerAccountId }) { + await using nc = await getNextAuthKVandCloseConnection() + const data = await nc.kv.get( + `testApp.user.account.${natsKey(provider)}.${natsKey(providerAccountId)}` + ) + if (!data || data.length == 0) return null + return hydrateDates(data.json()) + }, + async user(id: string) { + await using nc = await getNextAuthKVandCloseConnection() + const data = await nc.kv.get(`testApp.user.${natsKey(id)}`) + if (!data || data.length == 0) return null + return hydrateDates(data.json()) + }, + async session(sessionToken) { + await using nc = await getNextAuthKVandCloseConnection() + const data = await nc.kv.get( + `testApp.user.session.${natsKey(sessionToken)}` + ) + if (!data || data.length == 0) return null + return hydrateDates(data.json()) + }, + async verificationToken(where) { + await using nc = await getNextAuthKVandCloseConnection() + const data = await nc.kv.get( + `testApp.user.token.${natsKey(where.identifier)}.${natsKey(where.token)}` + ) + if (!data || data.length == 0) return null + return hydrateDates(data.json()) + }, + }, +}) + +// Running the same tests again with a static KV natsconnection +const nc = await connect({ + servers: "nats://localhost:5222", + authenticator: undefined, +}) +const kvm = new Kvm(nc) +const kv = await kvm.open("authKV") + +await runBasicTests({ + adapter: NatsKVAdapter(kv, { + baseKeyPrefix: "testApp.", + }), + db: { + disconnect: async () => { + await nc.drain() + await nc.close() + }, + async account({ provider, providerAccountId }) { + const data = await kv.get( + `testApp.user.account.${natsKey(provider)}.${natsKey(providerAccountId)}` + ) + if (!data || data.length == 0) return null + return hydrateDates(data.json()) + }, + async user(id: string) { + const data = await kv.get(`testApp.user.${natsKey(id)}`) + if (!data || data.length == 0) return null + return hydrateDates(data.json()) + }, + async session(sessionToken) { + const data = await kv.get(`testApp.user.session.${natsKey(sessionToken)}`) + if (!data || data.length == 0) return null + return hydrateDates(data.json()) + }, + async verificationToken(where) { + const data = await kv.get( + `testApp.user.token.${natsKey(where.identifier)}.${natsKey(where.token)}` + ) + if (!data || data.length == 0) return null + return hydrateDates(data.json()) + }, + }, +}) diff --git a/packages/adapter-nats-kv/test/test.sh b/packages/adapter-nats-kv/test/test.sh new file mode 100755 index 0000000000..6d2c479ec5 --- /dev/null +++ b/packages/adapter-nats-kv/test/test.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +echo "Initializing container for NATS KV (nats:latest)..." + +# Init Redis + serverless-redis-http containers +docker compose up -d + +echo "Waiting 5s for nats to start..." +sleep 5 + +# Always stop container, but exit with 1 when tests are failing +if vitest run -c ../utils/vitest.config.ts; then + docker compose down -v +else + docker compose down -v && exit 1 +fi diff --git a/packages/adapter-nats-kv/tsconfig.json b/packages/adapter-nats-kv/tsconfig.json new file mode 100644 index 0000000000..6f3b51d36f --- /dev/null +++ b/packages/adapter-nats-kv/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../utils/tsconfig.json", + "compilerOptions": { + "outDir": ".", + "rootDir": "src" + }, + "exclude": ["*.js", "*.d.ts"], + "include": ["src/**/*"] +} diff --git a/packages/adapter-nats-kv/typedoc.config.cjs b/packages/adapter-nats-kv/typedoc.config.cjs new file mode 100644 index 0000000000..f2aa735adc --- /dev/null +++ b/packages/adapter-nats-kv/typedoc.config.cjs @@ -0,0 +1,14 @@ +// @ts-check + +/** + * @type {import('typedoc').TypeDocOptions & import('typedoc-plugin-markdown').MarkdownTheme} + */ +module.exports = { + entryPoints: ["src/index.ts"], + entryPointStrategy: "expand", + tsconfig: "./tsconfig.json", + entryModule: "@auth/nats-kv-adapter", + entryFileName: "../nats-kv-adapter.mdx", + includeVersion: true, + readme: 'none', +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6fe6e4264..4bfef91981 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,7 +249,7 @@ importers: version: 3.8.3(@algolia/client-search@5.20.0)(@types/react@18.2.78)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.2) '@inkeep/widgets': specifier: ^0.2.289 - version: 0.2.289(@internationalized/date@3.5.2)(@types/react@18.2.78)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) + version: 0.2.289(@internationalized/date@3.5.6)(@types/react@18.2.78)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) '@next/third-parties': specifier: ^14.2.15 version: 14.2.15(next@14.2.21(@opentelemetry/api@1.7.0)(@playwright/test@1.41.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.70.0))(react@18.3.1) @@ -498,6 +498,25 @@ importers: specifier: ^6.0.0 version: 6.9.0(@aws-sdk/credential-providers@3.499.0)(gcp-metadata@5.3.0)(socks@2.7.1) + packages/adapter-nats-kv: + dependencies: + '@auth/core': + specifier: workspace:* + version: link:../core + '@nats-io/kv': + specifier: ^3.1.0 + version: 3.1.0 + '@nats-io/transport-node': + specifier: ^3.1.0 + version: 3.1.0 + devDependencies: + '@types/uuid': + specifier: ^8.3.3 + version: 8.3.4 + dotenv: + specifier: ^17.0.0 + version: 17.2.1 + packages/adapter-neo4j: dependencies: '@auth/core': @@ -3843,6 +3862,27 @@ packages: resolution: {integrity: sha512-C5wRPw9waqL2jk3jEDeJv+f7ScuO3N0a39HVdyFLkwKxHH4Sya4ZbzZsu2JLi6eEqe7RuHipHL6mC7B2OfYZZw==} engines: {node: '>= 10'} + '@nats-io/jetstream@3.1.0': + resolution: {integrity: sha512-L+IEqEo2Bb8533tGNCsfsFW1kArYrQIkq3YMz8KDzBXJUjH3e5pFNaL2j7xeN/klToYFFLmuhsM6FwRYarkR0w==} + + '@nats-io/kv@3.1.0': + resolution: {integrity: sha512-PqtJMF8vKqnnDngN8ITYSChSchUHGdFZOO6pV3lxSS/SxXgvhaJ8FTAoC/70mSZNOmHu3ISB5SbH86gyQIugXQ==} + + '@nats-io/nats-core@3.1.0': + resolution: {integrity: sha512-xsSkLEGGcqNF+Ru8dMjPmKtfbBeq/U4meuJJX4Zi+5TBHpjpjNjs4YkCBC/pGYWnEum1/vdNPizjE1RdNHCyBg==} + + '@nats-io/nkeys@2.0.3': + resolution: {integrity: sha512-JVt56GuE6Z89KUkI4TXUbSI9fmIfAmk6PMPknijmuL72GcD+UgIomTcRWiNvvJKxA01sBbmIPStqJs5cMRBC3A==} + engines: {node: '>=18.0.0'} + + '@nats-io/nuid@2.0.3': + resolution: {integrity: sha512-TpA3HEBna/qMVudy+3HZr5M3mo/L1JPofpVT4t0HkFGkz2Cn9wrlrQC8tvR8Md5Oa9//GtGG26eN0qEWF5Vqew==} + engines: {node: '>= 18.x'} + + '@nats-io/transport-node@3.1.0': + resolution: {integrity: sha512-k5pH7IOKUetwXOMraVgcB5zG0wibcHOwJJuyuY1/5Q4K0XfBJDnb/IbczP5/JJWwMYfxSL9O+46ojtdBHvHRSw==} + engines: {node: '>= 18.0.0'} + '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} @@ -7593,6 +7633,10 @@ packages: resolution: {integrity: sha512-CjA3y+Dr3FyFDOAMnxZEGtnW9KBR2M0JvvUtXNW+dYJL5ROWxP9DUHCwgFqpMk0OXCc0ljhaNTr2w/kutYIcHQ==} engines: {node: '>=12'} + dotenv@17.2.1: + resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==} + engines: {node: '>=12'} + dotenv@8.6.0: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} @@ -12951,6 +12995,9 @@ packages: resolution: {integrity: sha512-u9gUDkmR9dFS8b5kAYqIETK4OnzsS4l2ragJ0+soSMHh6VEeNHjTfSjk1tKxCqLyziCrPogadxP680J+v6yGHw==} hasBin: true + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + twoslash-protocol@0.2.5: resolution: {integrity: sha512-oUr5ZAn37CgNa6p1mrCuuR/pINffsnGCee2aS170Uj1IObxCjsHzu6sgdPUdxGLLn6++gd/qjNH1/iR6RrfLeg==} @@ -14213,7 +14260,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@ark-ui/anatomy@0.1.0(@internationalized/date@3.5.2)': + '@ark-ui/anatomy@0.1.0(@internationalized/date@3.5.6)': dependencies: '@zag-js/accordion': 0.20.0 '@zag-js/anatomy': 0.20.0 @@ -14224,7 +14271,7 @@ snapshots: '@zag-js/color-utils': 0.20.0 '@zag-js/combobox': 0.20.0 '@zag-js/date-picker': 0.20.0 - '@zag-js/date-utils': 0.20.0(@internationalized/date@3.5.2) + '@zag-js/date-utils': 0.20.0(@internationalized/date@3.5.6) '@zag-js/dialog': 0.20.0 '@zag-js/editable': 0.20.0 '@zag-js/hover-card': 0.20.0 @@ -14250,7 +14297,7 @@ snapshots: transitivePeerDependencies: - '@internationalized/date' - '@ark-ui/react@0.15.0(@internationalized/date@3.5.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@ark-ui/react@0.15.0(@internationalized/date@3.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@zag-js/accordion': 0.19.1 '@zag-js/anatomy': 0.19.1 @@ -14262,7 +14309,7 @@ snapshots: '@zag-js/combobox': 0.19.1 '@zag-js/core': 0.19.1 '@zag-js/date-picker': 0.19.1 - '@zag-js/date-utils': 0.19.1(@internationalized/date@3.5.2) + '@zag-js/date-utils': 0.19.1(@internationalized/date@3.5.6) '@zag-js/dialog': 0.19.1 '@zag-js/editable': 0.19.1 '@zag-js/hover-card': 0.19.1 @@ -17141,11 +17188,11 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@inkeep/components@0.0.24(@ark-ui/react@0.15.0(@internationalized/date@3.5.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@internationalized/date@3.5.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3)': + '@inkeep/components@0.0.24(@ark-ui/react@0.15.0(@internationalized/date@3.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@internationalized/date@3.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3)': dependencies: - '@ark-ui/react': 0.15.0(@internationalized/date@3.5.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@inkeep/preset': 0.0.24(@internationalized/date@3.5.2)(typescript@5.6.3) - '@inkeep/preset-chakra': 0.0.24(@internationalized/date@3.5.2)(typescript@5.6.3) + '@ark-ui/react': 0.15.0(@internationalized/date@3.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@inkeep/preset': 0.0.24(@internationalized/date@3.5.6)(typescript@5.6.3) + '@inkeep/preset-chakra': 0.0.24(@internationalized/date@3.5.6)(typescript@5.6.3) '@inkeep/shared': 0.0.25 '@inkeep/styled-system': 0.0.44 '@pandacss/dev': 0.22.1(typescript@5.6.3) @@ -17157,9 +17204,9 @@ snapshots: - jsdom - typescript - '@inkeep/preset-chakra@0.0.24(@internationalized/date@3.5.2)(typescript@5.6.3)': + '@inkeep/preset-chakra@0.0.24(@internationalized/date@3.5.6)(typescript@5.6.3)': dependencies: - '@ark-ui/anatomy': 0.1.0(@internationalized/date@3.5.2) + '@ark-ui/anatomy': 0.1.0(@internationalized/date@3.5.6) '@inkeep/shared': 0.0.25 '@pandacss/dev': 0.22.1(typescript@5.6.3) transitivePeerDependencies: @@ -17167,10 +17214,10 @@ snapshots: - jsdom - typescript - '@inkeep/preset@0.0.24(@internationalized/date@3.5.2)(typescript@5.6.3)': + '@inkeep/preset@0.0.24(@internationalized/date@3.5.6)(typescript@5.6.3)': dependencies: - '@ark-ui/anatomy': 0.1.0(@internationalized/date@3.5.2) - '@inkeep/preset-chakra': 0.0.24(@internationalized/date@3.5.2)(typescript@5.6.3) + '@ark-ui/anatomy': 0.1.0(@internationalized/date@3.5.6) + '@inkeep/preset-chakra': 0.0.24(@internationalized/date@3.5.6)(typescript@5.6.3) '@inkeep/shared': 0.0.25 '@pandacss/dev': 0.22.1(typescript@5.6.3) colorjs.io: 0.4.5 @@ -17185,14 +17232,14 @@ snapshots: '@inkeep/styled-system@0.0.46': {} - '@inkeep/widgets@0.2.289(@internationalized/date@3.5.2)(@types/react@18.2.78)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3)': + '@inkeep/widgets@0.2.289(@internationalized/date@3.5.6)(@types/react@18.2.78)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3)': dependencies: '@apollo/client': 3.9.5(@types/react@18.2.78)(graphql-ws@5.14.3(graphql@16.8.1))(graphql@16.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@ark-ui/react': 0.15.0(@internationalized/date@3.5.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ark-ui/react': 0.15.0(@internationalized/date@3.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@inkeep/color-mode': 0.0.24(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@inkeep/components': 0.0.24(@ark-ui/react@0.15.0(@internationalized/date@3.5.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@internationalized/date@3.5.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) - '@inkeep/preset': 0.0.24(@internationalized/date@3.5.2)(typescript@5.6.3) - '@inkeep/preset-chakra': 0.0.24(@internationalized/date@3.5.2)(typescript@5.6.3) + '@inkeep/components': 0.0.24(@ark-ui/react@0.15.0(@internationalized/date@3.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@internationalized/date@3.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) + '@inkeep/preset': 0.0.24(@internationalized/date@3.5.6)(typescript@5.6.3) + '@inkeep/preset-chakra': 0.0.24(@internationalized/date@3.5.6)(typescript@5.6.3) '@inkeep/shared': 0.0.25 '@inkeep/styled-system': 0.0.46 '@types/lodash.isequal': 4.5.8 @@ -17710,6 +17757,32 @@ snapshots: '@napi-rs/simple-git-win32-arm64-msvc': 0.1.16 '@napi-rs/simple-git-win32-x64-msvc': 0.1.16 + '@nats-io/jetstream@3.1.0': + dependencies: + '@nats-io/nats-core': 3.1.0 + + '@nats-io/kv@3.1.0': + dependencies: + '@nats-io/jetstream': 3.1.0 + '@nats-io/nats-core': 3.1.0 + + '@nats-io/nats-core@3.1.0': + dependencies: + '@nats-io/nkeys': 2.0.3 + '@nats-io/nuid': 2.0.3 + + '@nats-io/nkeys@2.0.3': + dependencies: + tweetnacl: 1.0.3 + + '@nats-io/nuid@2.0.3': {} + + '@nats-io/transport-node@3.1.0': + dependencies: + '@nats-io/nats-core': 3.1.0 + '@nats-io/nkeys': 2.0.3 + '@nats-io/nuid': 2.0.3 + '@neon-rs/load@0.0.4': {} '@neondatabase/serverless@0.10.4': @@ -20539,9 +20612,9 @@ snapshots: dependencies: '@internationalized/date': 3.5.2 - '@zag-js/date-utils@0.20.0(@internationalized/date@3.5.2)': + '@zag-js/date-utils@0.19.1(@internationalized/date@3.5.6)': dependencies: - '@internationalized/date': 3.5.2 + '@internationalized/date': 3.5.6 '@zag-js/date-utils@0.20.0(@internationalized/date@3.5.6)': dependencies: @@ -22715,6 +22788,8 @@ snapshots: dotenv@16.4.1: {} + dotenv@17.2.1: {} + dotenv@8.6.0: {} dottie@2.0.6: {} @@ -29575,6 +29650,8 @@ snapshots: turbo-windows-64: 2.1.1 turbo-windows-arm64: 2.1.1 + tweetnacl@1.0.3: {} + twoslash-protocol@0.2.5: {} twoslash@0.2.5(typescript@5.6.3): diff --git a/turbo.json b/turbo.json index 240427e652..105507fb29 100644 --- a/turbo.json +++ b/turbo.json @@ -105,6 +105,7 @@ "@auth/kysely-adapter#build", "@auth/mikro-orm-adapter#build", "@auth/mongodb-adapter#build", + "@auth/nats-kv-adapter#build", "@auth/neo4j-adapter#build", "@auth/pg-adapter#build", "@auth/pouchdb-adapter#build",