From 5a701c71c700443867ef7b1600e539c190e994f0 Mon Sep 17 00:00:00 2001 From: kaposke Date: Mon, 25 Aug 2025 00:30:07 -0300 Subject: [PATCH] feat(adapters): add `postgresjs` adapter --- .github/ISSUE_TEMPLATE/3_bug_adapter.yml | 1 + .github/workflows/release.yml | 1 + docs/pages/data/manifest.json | 1 + docs/pages/getting-started/adapters/_meta.js | 3 +- docs/public/img/adapters/postgresjs.svg | 17 ++ packages/adapter-postgresjs/README.md | 28 ++ packages/adapter-postgresjs/package.json | 51 ++++ packages/adapter-postgresjs/schema.sql | 49 ++++ packages/adapter-postgresjs/src/index.ts | 272 ++++++++++++++++++ packages/adapter-postgresjs/src/types.ts | 99 +++++++ .../adapter-postgresjs/test/index.test.ts | 62 ++++ packages/adapter-postgresjs/test/test.sh | 22 ++ packages/adapter-postgresjs/tsconfig.json | 9 + .../adapter-postgresjs/typedoc.config.cjs | 14 + pnpm-lock.yaml | 10 + turbo.json | 1 + 16 files changed, 639 insertions(+), 1 deletion(-) create mode 100644 docs/public/img/adapters/postgresjs.svg create mode 100644 packages/adapter-postgresjs/README.md create mode 100644 packages/adapter-postgresjs/package.json create mode 100644 packages/adapter-postgresjs/schema.sql create mode 100644 packages/adapter-postgresjs/src/index.ts create mode 100644 packages/adapter-postgresjs/src/types.ts create mode 100644 packages/adapter-postgresjs/test/index.test.ts create mode 100755 packages/adapter-postgresjs/test/test.sh create mode 100644 packages/adapter-postgresjs/tsconfig.json create mode 100644 packages/adapter-postgresjs/typedoc.config.cjs diff --git a/.github/ISSUE_TEMPLATE/3_bug_adapter.yml b/.github/ISSUE_TEMPLATE/3_bug_adapter.yml index 46696d17c2..fb18ee22b1 100644 --- a/.github/ISSUE_TEMPLATE/3_bug_adapter.yml +++ b/.github/ISSUE_TEMPLATE/3_bug_adapter.yml @@ -35,6 +35,7 @@ body: - "@auth/mongodb-adapter" - "@auth/neo4j-adapter" - "@auth/pg-adapter" + - "@auth/postgresjs-adapter" - "@auth/pouchdb-adapter" - "@auth/prisma-adapter" - "@auth/sequelize-adapter" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ee78afa19..34728aea7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,7 @@ on: - "@auth/mongodb-adapter" - "@auth/neo4j-adapter" - "@auth/pg-adapter" + - "@auth/postgresjs-adapter" - "@auth/pouchdb-adapter" - "@auth/prisma-adapter" - "@auth/sequelize-adapter" diff --git a/docs/pages/data/manifest.json b/docs/pages/data/manifest.json index 7fef03e029..48ff1ea209 100644 --- a/docs/pages/data/manifest.json +++ b/docs/pages/data/manifest.json @@ -36,6 +36,7 @@ "mongodb": "MongoDB", "neo4j": "Neo4j", "pg": "pg", + "postgresjs": "postgresjs", "pouchdb": "PouchDB", "sequelize": "Sequelize", "surrealdb": "SurrealDB", diff --git a/docs/pages/getting-started/adapters/_meta.js b/docs/pages/getting-started/adapters/_meta.js index 153672f783..6dd6a56604 100644 --- a/docs/pages/getting-started/adapters/_meta.js +++ b/docs/pages/getting-started/adapters/_meta.js @@ -13,7 +13,8 @@ export default { mongodb: "MongoDB", neo4j: "Neo4j", neon: "Neon", - pg: "PostgreSQL", + pg: "PostgreSQL (pg)", + postgresjs: "Postgres.js", pouchdb: "PouchDB", prisma: "Prisma", sequelize: "Sequelize", diff --git a/docs/public/img/adapters/postgresjs.svg b/docs/public/img/adapters/postgresjs.svg new file mode 100644 index 0000000000..5e1fd0210d --- /dev/null +++ b/docs/public/img/adapters/postgresjs.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/adapter-postgresjs/README.md b/packages/adapter-postgresjs/README.md new file mode 100644 index 0000000000..abf543d3ad --- /dev/null +++ b/packages/adapter-postgresjs/README.md @@ -0,0 +1,28 @@ +

+
+ + + + + + +

Postgres.js Adapter - NextAuth.js / Auth.js

+

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

+

+ +--- + +Check out the documentation at [authjs.dev](https://authjs.dev/reference/adapter/postgresjs). diff --git a/packages/adapter-postgresjs/package.json b/packages/adapter-postgresjs/package.json new file mode 100644 index 0000000000..5ede85a6fc --- /dev/null +++ b/packages/adapter-postgresjs/package.json @@ -0,0 +1,51 @@ +{ + "name": "@auth/postgresjs-adapter", + "version": "1.10.0", + "description": "Postgres.js adapter for next-auth.", + "homepage": "https://authjs.dev", + "repository": "https://github.com/nextauthjs/next-auth", + "bugs": { + "url": "https://github.com/nextauthjs/next-auth/issues" + }, + "author": "Guilherme Bassa", + "license": "ISC", + "keywords": [ + "next-auth", + "@auth", + "Auth.js", + "next.js", + "oauth", + "postgres", + "postgresjs" + ], + "type": "module", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./index.js" + } + }, + "files": [ + "*.d.ts*", + "*.js", + "src" + ], + "private": false, + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "./test/test.sh", + "build": "tsc", + "clean": "rm -rf *.js *.d.ts*" + }, + "dependencies": { + "@auth/core": "workspace:*" + }, + "devDependencies": { + "postgres": "^3.4.3" + }, + "peerDependencies": { + "postgres": "^3" + } +} diff --git a/packages/adapter-postgresjs/schema.sql b/packages/adapter-postgresjs/schema.sql new file mode 100644 index 0000000000..3c3544c03e --- /dev/null +++ b/packages/adapter-postgresjs/schema.sql @@ -0,0 +1,49 @@ +\set ON_ERROR_STOP true + +CREATE TABLE verification_tokens +( + identifier TEXT NOT NULL, + expires TIMESTAMPTZ NOT NULL, + token TEXT NOT NULL, + + PRIMARY KEY (identifier, token) +); + +CREATE TABLE accounts +( + id UUID DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + type VARCHAR(255) NOT NULL, + provider VARCHAR(255) NOT NULL, + provider_account_id VARCHAR(255) NOT NULL, + refresh_token TEXT, + access_token TEXT, + expires_at BIGINT, + id_token TEXT, + scope TEXT, + session_state TEXT, + token_type TEXT, + + PRIMARY KEY (id) +); + +CREATE TABLE sessions +( + id UUID DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + expires TIMESTAMPTZ NOT NULL, + session_token VARCHAR(255) NOT NULL, + + PRIMARY KEY (id) +); + +CREATE TABLE users +( + id UUID DEFAULT gen_random_uuid(), + name VARCHAR(255), + email VARCHAR(255) NOT NULL, + email_verified TIMESTAMPTZ, + image TEXT, + + PRIMARY KEY (id) +); diff --git a/packages/adapter-postgresjs/src/index.ts b/packages/adapter-postgresjs/src/index.ts new file mode 100644 index 0000000000..17fed94fd1 --- /dev/null +++ b/packages/adapter-postgresjs/src/index.ts @@ -0,0 +1,272 @@ +/** + *
+ *

An official Postgres.js adapter for Auth.js / NextAuth.js.

+ * + * + * + *
+ * + * ## Installation + * + * ```bash npm2yarn + * npm install next-auth @auth/postgresjs-adapter postgres + * ``` + * + * @module @auth/postgresjs-adapter + */ + +import type { + Adapter, + AdapterUser, + VerificationToken, + AdapterSession, + AdapterAccount, +} from "@auth/core/adapters" +import postgresjs from "postgres" +import { + DBAccount, + DBSession, + DBUser, + DBVerificationToken, + fromPartialAdapterUser, + toAdapterAccount, + toAdapterSession, + toAdapterUser, + toAdapterVerificationToken, +} from "./types.js" + +function buildPartialSetStatement(partial: object, sql: postgresjs.Sql) { + const fieldsToSet = Object.entries(partial).filter( + ([key, value]) => value !== undefined && key !== "id" + ) + + return sql(Object.fromEntries(fieldsToSet)) +} + +export default function PostgresjsAdapter(sql: postgresjs.Sql): Adapter { + return { + async createVerificationToken( + verificationToken: VerificationToken + ): Promise { + const { identifier, expires, token } = verificationToken + + await sql` + INSERT INTO verification_tokens ( identifier, expires, token ) + VALUES (${identifier}, ${expires}, ${token}) + RETURNING identifier, expires, token; + ` + + return verificationToken + }, + + async useVerificationToken({ + identifier, + token, + }: { + identifier: string + token: string + }) { + const [verificationToken] = await sql` + DELETE FROM verification_tokens + WHERE identifier = ${identifier} AND token = ${token} + RETURNING identifier, expires, token; + ` + + return verificationToken + ? toAdapterVerificationToken(verificationToken) + : null + }, + + async createUser(user: Omit) { + const { name, email, emailVerified, image } = user + + const [newUser] = await sql` + INSERT INTO users (name, email, email_verified, image) + VALUES (${name ?? null}, ${email}, ${emailVerified}, ${image ?? null}) + RETURNING id, name, email, email_verified, image` + + return toAdapterUser(newUser) + }, + + async getUser(id) { + const [dbUser] = await sql< + DBUser[] + >`SELECT * FROM users WHERE id = ${id} LIMIT 1` + + return dbUser ? toAdapterUser(dbUser) : null + }, + + async getUserByEmail(email) { + const [dbUser] = await sql` + SELECT * FROM users WHERE email = ${email} LIMIT 1 + ` + + return dbUser ? toAdapterUser(dbUser) : null + }, + + async getUserByAccount({ providerAccountId, provider }) { + const [dbUser] = await sql` + SELECT u.* + FROM users u + JOIN accounts a ON u.id = a.user_id + WHERE a.provider = ${provider} + AND a.provider_account_id = ${providerAccountId} + LIMIT 1` + + return dbUser ? toAdapterUser(dbUser) : null + }, + + async updateUser(user: Partial & Pick) { + const { id } = user + + if (id === undefined) { + throw new Error("No user id.") + } + + const setStatement = buildPartialSetStatement( + fromPartialAdapterUser(user), + sql + ) + + const [updatedUser] = await sql` + UPDATE users + SET ${setStatement} + WHERE id = ${id} + RETURNING name, id, email, email_verified, image + ` + + return toAdapterUser(updatedUser) + }, + + async linkAccount(account: AdapterAccount) { + const [newAccount] = await sql` + INSERT INTO accounts + ( + user_id, + provider, + type, + provider_account_id, + access_token, + expires_at, + refresh_token, + id_token, + scope, + session_state, + token_type + ) + VALUES ( + ${account.userId}, + ${account.provider}, + ${account.type}, + ${account.providerAccountId}, + ${account.access_token ?? null}, + ${account.expires_at ?? null}, + ${account.refresh_token ?? null}, + ${account.id_token ?? null}, + ${account.scope ?? null}, + ${account.session_state?.toString() ?? null}, + ${account.token_type ?? null} + ) + RETURNING + * + ` + + return toAdapterAccount(newAccount) + }, + + async createSession({ sessionToken, userId, expires }) { + const [session] = await sql< + DBSession[] + >`INSERT INTO sessions (user_id, expires, session_token) + VALUES (${userId}, ${expires}, ${sessionToken}) + RETURNING id, session_token, user_id, expires` + + return toAdapterSession(session) + }, + + async getSessionAndUser(sessionToken: string | undefined): Promise<{ + session: AdapterSession + user: AdapterUser + } | null> { + if (sessionToken === undefined) { + return null + } + const [session] = await sql< + DBSession[] + >`SELECT * FROM sessions WHERE session_token = ${sessionToken} LIMIT 1` + + if (!session) { + return null + } + + const [user] = await sql< + DBUser[] + >`SELECT * FROM users WHERE id = ${session.user_id} LIMIT 1` + + if (!user) { + return null + } + + return { + session: toAdapterSession(session), + user: toAdapterUser(user), + } + }, + + async updateSession( + session: Partial & Pick + ): Promise { + const { sessionToken, expires } = session + + if (!expires) { + const [session] = await sql` + SELECT * FROM sessions WHERE session_token = ${sessionToken} LIMIT 1 + ` + return session ? toAdapterSession(session) : null + } + + const [updatedSession] = await sql` + UPDATE sessions + SET expires = ${expires} + WHERE session_token = ${sessionToken} + ` + return updatedSession ? toAdapterSession(updatedSession) : null + }, + + async deleteSession(sessionToken) { + await sql` + DELETE FROM sessions + WHERE session_token = ${sessionToken} + RETURNING * + ` + }, + + async unlinkAccount(partialAccount) { + const { provider, providerAccountId } = partialAccount + await sql` + DELETE FROM accounts + WHERE provider_account_id = ${providerAccountId} + AND provider = ${provider} + RETURNING * + ` + }, + + async deleteUser(userId: string) { + await sql` + DELETE FROM users + WHERE id = ${userId} + RETURNING * + ` + await sql` + DELETE FROM sessions + WHERE user_id = ${userId} + RETURNING * + ` + await sql` + DELETE FROM accounts + WHERE user_id = ${userId} + RETURNING * + ` + }, + } +} diff --git a/packages/adapter-postgresjs/src/types.ts b/packages/adapter-postgresjs/src/types.ts new file mode 100644 index 0000000000..ff0b8e857f --- /dev/null +++ b/packages/adapter-postgresjs/src/types.ts @@ -0,0 +1,99 @@ +import { + AdapterAccount, + AdapterAccountType, + AdapterSession, + AdapterUser, + VerificationToken, +} from "@auth/core/adapters" + +export type DBUser = { + id: string + name: string | null + email: string + email_verified: Date | null + image: string | null +} + +export function fromPartialAdapterUser( + user: Partial +): Partial { + return { + id: user.id, + name: user.name, + email: user.email, + email_verified: user.emailVerified, + image: user.image, + } +} + +export function toAdapterUser(user: DBUser): AdapterUser { + return { + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.email_verified, + image: user.image, + } +} + +export type DBAccount = { + id: string + user_id: string + type: string + provider: string + provider_account_id: string + refresh_token: string | null + access_token: string | null + expires_at: string | null + id_token: string | null + scope: string | null + session_state: string | null + token_type: string | null +} + +export function toAdapterAccount(account: DBAccount): AdapterAccount { + return { + id: account.id, + userId: account.user_id, + type: account.type as AdapterAccountType, + provider: account.provider, + providerAccountId: account.provider_account_id, + access_token: account.access_token ?? undefined, + expires_at: account.expires_at ? parseInt(account.expires_at) : undefined, + refresh_token: account.refresh_token ?? undefined, + id_token: account.id_token ?? undefined, + scope: account.scope ?? undefined, + session_state: account.session_state ?? undefined, + token_type: + (account.token_type?.toLowerCase() as Lowercase) ?? undefined, + } +} + +export type DBSession = { + id: string + user_id: string + expires: Date + session_token: string +} + +export function toAdapterSession(session: DBSession): AdapterSession { + return { + userId: session.user_id, + expires: session.expires, + sessionToken: session.session_token, + } +} + +export type DBVerificationToken = { + id: string + identifier: string + expires: Date + token: string +} + +export function toAdapterVerificationToken( + verificationToken: DBVerificationToken +): VerificationToken { + const { id, ...rest } = verificationToken + return rest +} diff --git a/packages/adapter-postgresjs/test/index.test.ts b/packages/adapter-postgresjs/test/index.test.ts new file mode 100644 index 0000000000..3964b43272 --- /dev/null +++ b/packages/adapter-postgresjs/test/index.test.ts @@ -0,0 +1,62 @@ +import { runBasicTests } from "utils/adapter" +import postgresjs from "postgres" +import PostgresjsAdapter from "../src" +import { + DBAccount, + DBUser, + toAdapterAccount, + toAdapterUser, + toAdapterVerificationToken, +} from "../src/types" +import { DBSession } from "../src/types" +import { toAdapterSession } from "../src/types" +import { DBVerificationToken } from "../src/types" + +const POOL_SIZE = 20 + +const sql = postgresjs({ + host: "127.0.0.1", + port: 5432, + database: "adapter-postgresjs-test", + user: "postgresjs", + password: "postgresjs", + max: POOL_SIZE, +}) + +runBasicTests({ + adapter: PostgresjsAdapter(sql), + db: { + disconnect: async () => { + await sql.end() + }, + user: async (id: string) => { + const [user] = await sql< + DBUser[] + >`SELECT * FROM users WHERE id = ${id} LIMIT 1` + return user ? toAdapterUser(user) : null + }, + account: async ({ providerAccountId }) => { + const [account] = await sql< + DBAccount[] + >`SELECT * FROM accounts WHERE provider_account_id = ${providerAccountId} LIMIT 1` + + return account ? toAdapterAccount(account) : null + }, + session: async (sessionToken) => { + const [session] = await sql< + DBSession[] + >`SELECT * FROM sessions WHERE session_token = ${sessionToken} LIMIT 1` + return session ? toAdapterSession(session) : null + }, + async verificationToken(identifier_token) { + const { identifier, token } = identifier_token + const [verificationToken] = await sql< + DBVerificationToken[] + >`SELECT * FROM verification_tokens WHERE identifier = ${identifier} AND token = ${token} LIMIT 1` + + return verificationToken + ? toAdapterVerificationToken(verificationToken) + : null + }, + }, +}) diff --git a/packages/adapter-postgresjs/test/test.sh b/packages/adapter-postgresjs/test/test.sh new file mode 100755 index 0000000000..279fbd7acd --- /dev/null +++ b/packages/adapter-postgresjs/test/test.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +CONTAINER_NAME=authjs-postgresjs-test + +docker run -d --rm \ + --name ${CONTAINER_NAME} \ + -e POSTGRES_DB=adapter-postgresjs-test \ + -e POSTGRES_USER=postgresjs \ + -e POSTGRES_PASSWORD=postgresjs \ + -p 5432:5432 \ + -v "$(pwd)"/schema.sql:/docker-entrypoint-initdb.d/schema.sql \ + postgres:latest + +echo "waiting 5s for db 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 stop ${CONTAINER_NAME} +else + docker stop ${CONTAINER_NAME} && exit 1 +fi diff --git a/packages/adapter-postgresjs/tsconfig.json b/packages/adapter-postgresjs/tsconfig.json new file mode 100644 index 0000000000..6f3b51d36f --- /dev/null +++ b/packages/adapter-postgresjs/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-postgresjs/typedoc.config.cjs b/packages/adapter-postgresjs/typedoc.config.cjs new file mode 100644 index 0000000000..1e259dae28 --- /dev/null +++ b/packages/adapter-postgresjs/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/postgresjs-adapter", + entryFileName: "../postgresjs-adapter.mdx", + includeVersion: true, + readme: "none", +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6fe6e4264..02516c5947 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -540,6 +540,16 @@ importers: specifier: ^8.7.1 version: 8.11.3 + packages/adapter-postgresjs: + dependencies: + '@auth/core': + specifier: workspace:* + version: link:../core + devDependencies: + postgres: + specifier: ^3.4.3 + version: 3.4.3 + packages/adapter-pouchdb: dependencies: '@auth/core': diff --git a/turbo.json b/turbo.json index 240427e652..65d944011b 100644 --- a/turbo.json +++ b/turbo.json @@ -107,6 +107,7 @@ "@auth/mongodb-adapter#build", "@auth/neo4j-adapter#build", "@auth/pg-adapter#build", + "@auth/postgresjs-adapter#build", "@auth/pouchdb-adapter#build", "@auth/prisma-adapter#build", "@auth/sequelize-adapter#build",