@remit/auth-service 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ const url =
2
+ process.env.DATABASE_URL ??
3
+ process.env.PG_CONNECTION_URL ??
4
+ "postgresql://remit:remit@localhost:5432/remit_dev";
5
+
6
+ /**
7
+ * The better-auth identity tables share the pg-parity database with the entity
8
+ * schema (remit-drizzle-service). `tablesFilter` scopes drizzle-kit to only the
9
+ * auth tables so a push here never proposes dropping the entity tables it does
10
+ * not know about.
11
+ */
12
+ export default {
13
+ dialect: "postgresql",
14
+ schema: "./src/schema/auth-schema.ts",
15
+ out: "./.drizzle",
16
+ dbCredentials: { url },
17
+ tablesFilter: [
18
+ "auth_user",
19
+ "auth_session",
20
+ "auth_account",
21
+ "auth_verification",
22
+ "auth_jwks",
23
+ ],
24
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@remit/auth-service",
3
+ "version": "0.0.1",
4
+ "description": "better-auth identity service for the Postgres-parity stack (pg-parity)",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "default": "./src/index.ts"
12
+ },
13
+ "./edge-tls": {
14
+ "types": "./src/edge-tls.ts",
15
+ "default": "./src/edge-tls.ts"
16
+ },
17
+ "./verifier": {
18
+ "types": "./src/verifier.ts",
19
+ "default": "./src/verifier.ts"
20
+ }
21
+ },
22
+ "scripts": {
23
+ "test:typecheck": "tsgo --noEmit -p tsconfig.json",
24
+ "test:run": "node --import tsx --test 'src/**/*.test.ts'",
25
+ "test": "npm run test:typecheck && npm run test:run",
26
+ "auth:schema:generate": "npx @better-auth/cli generate --config src/auth.gen.ts --output src/schema/auth-schema.ts -y",
27
+ "auth:schema:push": "npx drizzle-kit push",
28
+ "fix": "biome check --write src"
29
+ },
30
+ "devDependencies": {
31
+ "@better-auth/cli": "^1.4.21",
32
+ "@types/better-sqlite3": "^7.6.13",
33
+ "drizzle-kit": "^0.31.1",
34
+ "tsx": "*"
35
+ },
36
+ "dependencies": {
37
+ "better-auth": "^1.6.23",
38
+ "better-sqlite3": "^12.11.1",
39
+ "drizzle-orm": "^0.45.2",
40
+ "jose": "^6.2.3",
41
+ "pg": "^8.0.0"
42
+ },
43
+ "license": "MIT",
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/remit-mail/remit.git",
50
+ "directory": "packages/auth-service"
51
+ }
52
+ }
@@ -0,0 +1,35 @@
1
+ import type { Auth as BetterAuthInstance } from "better-auth";
2
+ import { betterAuth } from "better-auth";
3
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
4
+ import { jwt } from "better-auth/plugins";
5
+ import { drizzle } from "drizzle-orm/node-postgres";
6
+ import pg from "pg";
7
+
8
+ /**
9
+ * Schema-generation-only config for `@better-auth/cli generate`. It mirrors the
10
+ * plugin set of the real `createAuth` so the emitted Drizzle schema matches what
11
+ * runtime expects, but omits the schema import to break the generate-time
12
+ * chicken-and-egg (the schema file is this command's output).
13
+ */
14
+ const pool = new pg.Pool({
15
+ connectionString:
16
+ process.env.PG_CONNECTION_URL ??
17
+ "postgresql://remit:remit@localhost:5432/remit_dev",
18
+ });
19
+
20
+ export const auth = betterAuth({
21
+ secret: "schema-generation-only",
22
+ baseURL: "http://localhost",
23
+ database: drizzleAdapter(drizzle(pool), { provider: "pg" }),
24
+ emailAndPassword: { enabled: true },
25
+ user: { modelName: "auth_user" },
26
+ session: { modelName: "auth_session" },
27
+ account: { modelName: "auth_account" },
28
+ verification: { modelName: "auth_verification" },
29
+ plugins: [
30
+ jwt({
31
+ schema: { jwks: { modelName: "auth_jwks" } },
32
+ jwks: { keyPairConfig: { alg: "RS256", modulusLength: 2048 } },
33
+ }),
34
+ ],
35
+ }) as unknown as BetterAuthInstance;
package/src/auth.ts ADDED
@@ -0,0 +1,129 @@
1
+ import type { Auth as BetterAuthInstance } from "better-auth";
2
+ import { betterAuth } from "better-auth";
3
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
4
+ import { jwt } from "better-auth/plugins";
5
+ import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
6
+ import pg from "pg";
7
+ import * as authSchema from "./schema/auth-schema.js";
8
+ import * as authSchemaSqlite from "./schema/auth-schema-sqlite.js";
9
+
10
+ export interface AuthConfig {
11
+ /**
12
+ * The relational backend better-auth runs on, off `DATA_BACKEND` (RFC 036).
13
+ * `pg` (the default) reads `connectionString` as a Postgres URL; `sqlite`
14
+ * reads it as the path to the shared database file (D3), and drives the
15
+ * SQLite identity schema through the drizzle adapter's `sqlite` provider.
16
+ */
17
+ provider?: "pg" | "sqlite";
18
+ connectionString: string;
19
+ secret: string;
20
+ baseURL: string;
21
+ trustedOrigins?: string[];
22
+ /**
23
+ * Self-service signup switch, mirroring the Cognito user pool's
24
+ * `selfSignUpEnabled`. `false` closes signup via better-auth's
25
+ * `disableSignUp`; accounts are then provisioned out-of-band.
26
+ */
27
+ selfSignUpEnabled: boolean;
28
+ }
29
+
30
+ // The drizzle adapter better-auth runs against, chosen by backend. better-sqlite3
31
+ // and its drizzle driver are imported dynamically so the Postgres path never
32
+ // loads the native binding, and the module stays out of any bundle that never
33
+ // runs on SQLite. On SQLite the auth connection sets the same WAL/busy_timeout
34
+ // coordination every writer uses (RFC 036 D3) — it shares the file with the app
35
+ // tables through its own connection.
36
+ const buildAuthAdapter = async (config: AuthConfig) => {
37
+ if (config.provider === "sqlite") {
38
+ const { default: Database } = await import("better-sqlite3");
39
+ const { drizzle } = await import("drizzle-orm/better-sqlite3");
40
+ const sqlite = new Database(config.connectionString);
41
+ sqlite.pragma("journal_mode = WAL");
42
+ sqlite.pragma("busy_timeout = 5000");
43
+ sqlite.pragma("synchronous = NORMAL");
44
+ sqlite.pragma("foreign_keys = ON");
45
+ return drizzleAdapter(drizzle(sqlite, { schema: authSchemaSqlite }), {
46
+ provider: "sqlite",
47
+ schema: authSchemaSqlite,
48
+ });
49
+ }
50
+
51
+ const pool = new pg.Pool({ connectionString: config.connectionString });
52
+ return drizzleAdapter(drizzlePg(pool, { schema: authSchema }), {
53
+ provider: "pg",
54
+ schema: authSchema,
55
+ });
56
+ };
57
+
58
+ const intEnv = (name: string, fallback: number): number => {
59
+ const raw = process.env[name];
60
+ if (!raw) return fallback;
61
+ const value = Number(raw);
62
+ return Number.isFinite(value) && value > 0 ? value : fallback;
63
+ };
64
+
65
+ /**
66
+ * Build a better-auth instance bound to the pg-parity Postgres.
67
+ *
68
+ * RS256 is deliberate: the edge tier (APISIX, or the dev-server verifier)
69
+ * follows JWKS key rotation offline, so signing must be asymmetric. The JWKS is
70
+ * published at `${baseURL}/api/auth/jwks` and tokens are minted at
71
+ * `${baseURL}/api/auth/token`.
72
+ */
73
+ // The return is widened to better-auth's own `Auth` type so the exported
74
+ // surface never names better-auth's bundled zod internals. When a consumer
75
+ // resolves a different zod major than better-auth (open-core reader: web-client
76
+ // pins zod 3, better-auth needs 4, so better-auth's copy nests and is
77
+ // unnameable), the inferred instance type is not portable; the widening keeps
78
+ // the public type stable across any consumer's dependency graph.
79
+ export const createAuth = async (
80
+ config: AuthConfig,
81
+ ): Promise<BetterAuthInstance> => {
82
+ const database = await buildAuthAdapter(config);
83
+
84
+ return betterAuth({
85
+ secret: config.secret,
86
+ baseURL: config.baseURL,
87
+ trustedOrigins: config.trustedOrigins,
88
+ database,
89
+ emailAndPassword: {
90
+ enabled: true,
91
+ autoSignIn: true,
92
+ disableSignUp: !config.selfSignUpEnabled,
93
+ },
94
+ rateLimit: {
95
+ enabled: true,
96
+ window: intEnv("BETTER_AUTH_RATE_LIMIT_WINDOW", 60),
97
+ max: intEnv("BETTER_AUTH_RATE_LIMIT_MAX", 100),
98
+ customRules: {
99
+ "/sign-in/email": {
100
+ window: intEnv("BETTER_AUTH_RATE_LIMIT_SIGN_IN_WINDOW", 60),
101
+ max: intEnv("BETTER_AUTH_RATE_LIMIT_SIGN_IN_MAX", 5),
102
+ },
103
+ "/sign-up/email": {
104
+ window: intEnv("BETTER_AUTH_RATE_LIMIT_SIGN_UP_WINDOW", 60),
105
+ max: intEnv("BETTER_AUTH_RATE_LIMIT_SIGN_UP_MAX", 5),
106
+ },
107
+ },
108
+ },
109
+ user: { modelName: "auth_user" },
110
+ session: { modelName: "auth_session" },
111
+ account: { modelName: "auth_account" },
112
+ verification: { modelName: "auth_verification" },
113
+ plugins: [
114
+ jwt({
115
+ schema: { jwks: { modelName: "auth_jwks" } },
116
+ jwt: {
117
+ issuer: config.baseURL,
118
+ audience: config.baseURL,
119
+ expirationTime: "15m",
120
+ },
121
+ jwks: {
122
+ keyPairConfig: { alg: "RS256", modulusLength: 2048 },
123
+ },
124
+ }),
125
+ ],
126
+ }) as unknown as BetterAuthInstance;
127
+ };
128
+
129
+ export type Auth = Awaited<ReturnType<typeof createAuth>>;
package/src/config.ts ADDED
@@ -0,0 +1,80 @@
1
+ import type { AuthConfig } from "./auth.js";
2
+
3
+ export const AUTH_BASE_PATH = "/api/auth";
4
+ export const AUTH_JWKS_PATH = `${AUTH_BASE_PATH}/jwks`;
5
+ export const AUTH_TOKEN_PATH = `${AUTH_BASE_PATH}/token`;
6
+
7
+ const required = (name: string, value: string | undefined): string => {
8
+ if (!value || value.length === 0) {
9
+ throw new Error(`Missing required auth env var: ${name}`);
10
+ }
11
+ return value;
12
+ };
13
+
14
+ /**
15
+ * Whether self-service signup is open on the better-auth tier. Mirrors the
16
+ * Cognito user pool's `selfSignUpEnabled` prop: enabled means users can
17
+ * self-register, disabled means signup is closed and accounts are provisioned
18
+ * out-of-band. `SELF_SIGN_UP_ENABLED` is an operator flag set in deploy config
19
+ * (per stage). Unset defaults to enabled, preserving the current open-signup
20
+ * behaviour; only an explicit `false`/`0`/`no` closes it. When closed,
21
+ * `createAuth` sets better-auth's `disableSignUp`, which rejects `/sign-up/email`
22
+ * server-side — the UI state is presentation only, not the gate.
23
+ */
24
+ export const resolveSelfSignUpEnabled = (
25
+ raw: string | undefined = process.env.SELF_SIGN_UP_ENABLED,
26
+ ): boolean => {
27
+ const value = (raw ?? "").trim().toLowerCase();
28
+ return value !== "false" && value !== "0" && value !== "no";
29
+ };
30
+
31
+ /**
32
+ * Resolve the better-auth config from the environment. Callers reach this only
33
+ * on the self-host relational backends (Postgres or SQLite); the Cognito/AWS
34
+ * path never touches better-auth. On `DATA_BACKEND=sqlite` the identity tables
35
+ * share the app database file (RFC 036 D3), so the locator is `SQLITE_DB_PATH`;
36
+ * otherwise it is the Postgres URL.
37
+ */
38
+ export const resolveAuthConfig = (): AuthConfig => {
39
+ const baseURL = required("BETTER_AUTH_URL", process.env.BETTER_AUTH_URL);
40
+ const provider = process.env.DATA_BACKEND === "sqlite" ? "sqlite" : "pg";
41
+ const connectionString =
42
+ provider === "sqlite"
43
+ ? required("SQLITE_DB_PATH", process.env.SQLITE_DB_PATH)
44
+ : required("PG_CONNECTION_URL", process.env.PG_CONNECTION_URL);
45
+ return {
46
+ provider,
47
+ connectionString,
48
+ secret: required("BETTER_AUTH_SECRET", process.env.BETTER_AUTH_SECRET),
49
+ baseURL,
50
+ trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",")
51
+ .map((origin) => origin.trim())
52
+ .filter((origin) => origin.length > 0),
53
+ selfSignUpEnabled: resolveSelfSignUpEnabled(),
54
+ };
55
+ };
56
+
57
+ export interface VerifierConfig {
58
+ jwksUrl: string;
59
+ issuer: string;
60
+ audience: string;
61
+ }
62
+
63
+ /**
64
+ * Derive the JWT verifier config from the same base URL the tokens are minted
65
+ * against, so issuer/audience/JWKS all agree.
66
+ */
67
+ export const resolveVerifierConfig = (baseURL?: string): VerifierConfig => {
68
+ const base = required(
69
+ "BETTER_AUTH_URL",
70
+ baseURL ?? process.env.BETTER_AUTH_URL,
71
+ );
72
+ return {
73
+ // The JWKS fetch URL can be overridden so the backend can reach its own
74
+ // loopback mount instead of routing back through the browser-facing proxy
75
+ // origin, while issuer/audience still match the minted token's base URL.
76
+ jwksUrl: process.env.BETTER_AUTH_JWKS_URL ?? `${base}${AUTH_JWKS_PATH}`,
77
+ issuer: base,
78
+ audience: base,
79
+ };
80
+ };
@@ -0,0 +1,39 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { shouldVerifyDiscoveryTls } from "./edge-tls.js";
4
+
5
+ describe("shouldVerifyDiscoveryTls", () => {
6
+ it("verifies TLS for a deployed https host", () => {
7
+ assert.equal(
8
+ shouldVerifyDiscoveryTls(
9
+ "https://api.dev.remit.example/api/auth/.well-known/openid-configuration",
10
+ ),
11
+ true,
12
+ );
13
+ });
14
+
15
+ it("skips verification for plaintext http hops", () => {
16
+ assert.equal(
17
+ shouldVerifyDiscoveryTls("http://host.docker.internal:5436/api/auth"),
18
+ false,
19
+ );
20
+ assert.equal(
21
+ shouldVerifyDiscoveryTls("http://api.dev.remit.example/api/auth"),
22
+ false,
23
+ );
24
+ });
25
+
26
+ it("skips verification for loopback / docker-host https", () => {
27
+ for (const host of [
28
+ "localhost",
29
+ "127.0.0.1",
30
+ "[::1]",
31
+ "host.docker.internal",
32
+ ]) {
33
+ assert.equal(
34
+ shouldVerifyDiscoveryTls(`https://${host}:5436/api/auth`),
35
+ false,
36
+ );
37
+ }
38
+ });
39
+ });
@@ -0,0 +1,21 @@
1
+ const LOOPBACK_HOSTS = new Set([
2
+ "localhost",
3
+ "127.0.0.1",
4
+ "[::1]",
5
+ "host.docker.internal",
6
+ ]);
7
+
8
+ /**
9
+ * Whether APISIX should verify TLS when fetching the OIDC discovery / JWKS
10
+ * document from `discoveryUrl`.
11
+ *
12
+ * A plaintext (`http:`) hop has no certificate to verify, and loopback /
13
+ * docker-host addresses are the dev-only path where the IdP shares the host. For
14
+ * every other (deployed) host verification must be on: without it a MITM on the
15
+ * edge → IdP path could serve a forged JWKS and have minted tokens accepted.
16
+ */
17
+ export const shouldVerifyDiscoveryTls = (discoveryUrl: string): boolean => {
18
+ const { protocol, hostname } = new URL(discoveryUrl);
19
+ if (protocol !== "https:") return false;
20
+ return !LOOPBACK_HOSTS.has(hostname);
21
+ };
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export { toNodeHandler } from "better-auth/node";
2
+ export { type Auth, type AuthConfig, createAuth } from "./auth.js";
3
+ export {
4
+ AUTH_BASE_PATH,
5
+ AUTH_JWKS_PATH,
6
+ AUTH_TOKEN_PATH,
7
+ resolveAuthConfig,
8
+ resolveSelfSignUpEnabled,
9
+ resolveVerifierConfig,
10
+ type VerifierConfig,
11
+ } from "./config.js";
12
+ export {
13
+ createJwtVerifier,
14
+ extractBearerToken,
15
+ type JwtClaims,
16
+ type JwtVerifier,
17
+ } from "./verify.js";
@@ -0,0 +1,121 @@
1
+ // SQLite twin of auth-schema.ts (RFC 036 D5). Same five better-auth identity
2
+ // tables in the SQLite dialect — the shape better-auth's drizzle adapter emits
3
+ // with `provider: "sqlite"`: `sqliteTable`, `text` unchanged, `boolean` and
4
+ // `timestamp` mapped to `integer` (mode boolean / timestamp). Used only to
5
+ // generate the committed sqlite migrations (deploy/vps/migrations-sqlite/auth);
6
+ // the Postgres schema stays the source for the pg migrations.
7
+ import { relations } from "drizzle-orm";
8
+ import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
9
+
10
+ export const auth_user = sqliteTable("auth_user", {
11
+ id: text("id").primaryKey(),
12
+ name: text("name").notNull(),
13
+ email: text("email").notNull().unique(),
14
+ emailVerified: integer("email_verified", { mode: "boolean" })
15
+ .default(false)
16
+ .notNull(),
17
+ image: text("image"),
18
+ createdAt: integer("created_at", { mode: "timestamp" })
19
+ .$defaultFn(() => /* @__PURE__ */ new Date())
20
+ .notNull(),
21
+ updatedAt: integer("updated_at", { mode: "timestamp" })
22
+ .$defaultFn(() => /* @__PURE__ */ new Date())
23
+ .$onUpdate(() => /* @__PURE__ */ new Date())
24
+ .notNull(),
25
+ });
26
+
27
+ export const auth_session = sqliteTable(
28
+ "auth_session",
29
+ {
30
+ id: text("id").primaryKey(),
31
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
32
+ token: text("token").notNull().unique(),
33
+ createdAt: integer("created_at", { mode: "timestamp" })
34
+ .$defaultFn(() => /* @__PURE__ */ new Date())
35
+ .notNull(),
36
+ updatedAt: integer("updated_at", { mode: "timestamp" })
37
+ .$onUpdate(() => /* @__PURE__ */ new Date())
38
+ .notNull(),
39
+ ipAddress: text("ip_address"),
40
+ userAgent: text("user_agent"),
41
+ userId: text("user_id")
42
+ .notNull()
43
+ .references(() => auth_user.id, { onDelete: "cascade" }),
44
+ },
45
+ (table) => [index("auth_session_userId_idx").on(table.userId)],
46
+ );
47
+
48
+ export const auth_account = sqliteTable(
49
+ "auth_account",
50
+ {
51
+ id: text("id").primaryKey(),
52
+ accountId: text("account_id").notNull(),
53
+ providerId: text("provider_id").notNull(),
54
+ userId: text("user_id")
55
+ .notNull()
56
+ .references(() => auth_user.id, { onDelete: "cascade" }),
57
+ accessToken: text("access_token"),
58
+ refreshToken: text("refresh_token"),
59
+ idToken: text("id_token"),
60
+ accessTokenExpiresAt: integer("access_token_expires_at", {
61
+ mode: "timestamp",
62
+ }),
63
+ refreshTokenExpiresAt: integer("refresh_token_expires_at", {
64
+ mode: "timestamp",
65
+ }),
66
+ scope: text("scope"),
67
+ password: text("password"),
68
+ createdAt: integer("created_at", { mode: "timestamp" })
69
+ .$defaultFn(() => /* @__PURE__ */ new Date())
70
+ .notNull(),
71
+ updatedAt: integer("updated_at", { mode: "timestamp" })
72
+ .$onUpdate(() => /* @__PURE__ */ new Date())
73
+ .notNull(),
74
+ },
75
+ (table) => [index("auth_account_userId_idx").on(table.userId)],
76
+ );
77
+
78
+ export const auth_verification = sqliteTable(
79
+ "auth_verification",
80
+ {
81
+ id: text("id").primaryKey(),
82
+ identifier: text("identifier").notNull(),
83
+ value: text("value").notNull(),
84
+ expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
85
+ createdAt: integer("created_at", { mode: "timestamp" })
86
+ .$defaultFn(() => /* @__PURE__ */ new Date())
87
+ .notNull(),
88
+ updatedAt: integer("updated_at", { mode: "timestamp" })
89
+ .$defaultFn(() => /* @__PURE__ */ new Date())
90
+ .$onUpdate(() => /* @__PURE__ */ new Date())
91
+ .notNull(),
92
+ },
93
+ (table) => [index("auth_verification_identifier_idx").on(table.identifier)],
94
+ );
95
+
96
+ export const auth_jwks = sqliteTable("auth_jwks", {
97
+ id: text("id").primaryKey(),
98
+ publicKey: text("public_key").notNull(),
99
+ privateKey: text("private_key").notNull(),
100
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
101
+ expiresAt: integer("expires_at", { mode: "timestamp" }),
102
+ });
103
+
104
+ export const auth_userRelations = relations(auth_user, ({ many }) => ({
105
+ auth_sessions: many(auth_session),
106
+ auth_accounts: many(auth_account),
107
+ }));
108
+
109
+ export const auth_sessionRelations = relations(auth_session, ({ one }) => ({
110
+ auth_user: one(auth_user, {
111
+ fields: [auth_session.userId],
112
+ references: [auth_user.id],
113
+ }),
114
+ }));
115
+
116
+ export const auth_accountRelations = relations(auth_account, ({ one }) => ({
117
+ auth_user: one(auth_user, {
118
+ fields: [auth_account.userId],
119
+ references: [auth_user.id],
120
+ }),
121
+ }));
@@ -0,0 +1,101 @@
1
+ import { relations } from "drizzle-orm";
2
+ import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
3
+
4
+ export const auth_user = pgTable("auth_user", {
5
+ id: text("id").primaryKey(),
6
+ name: text("name").notNull(),
7
+ email: text("email").notNull().unique(),
8
+ emailVerified: boolean("email_verified").default(false).notNull(),
9
+ image: text("image"),
10
+ createdAt: timestamp("created_at").defaultNow().notNull(),
11
+ updatedAt: timestamp("updated_at")
12
+ .defaultNow()
13
+ .$onUpdate(() => /* @__PURE__ */ new Date())
14
+ .notNull(),
15
+ });
16
+
17
+ export const auth_session = pgTable(
18
+ "auth_session",
19
+ {
20
+ id: text("id").primaryKey(),
21
+ expiresAt: timestamp("expires_at").notNull(),
22
+ token: text("token").notNull().unique(),
23
+ createdAt: timestamp("created_at").defaultNow().notNull(),
24
+ updatedAt: timestamp("updated_at")
25
+ .$onUpdate(() => /* @__PURE__ */ new Date())
26
+ .notNull(),
27
+ ipAddress: text("ip_address"),
28
+ userAgent: text("user_agent"),
29
+ userId: text("user_id")
30
+ .notNull()
31
+ .references(() => auth_user.id, { onDelete: "cascade" }),
32
+ },
33
+ (table) => [index("auth_session_userId_idx").on(table.userId)],
34
+ );
35
+
36
+ export const auth_account = pgTable(
37
+ "auth_account",
38
+ {
39
+ id: text("id").primaryKey(),
40
+ accountId: text("account_id").notNull(),
41
+ providerId: text("provider_id").notNull(),
42
+ userId: text("user_id")
43
+ .notNull()
44
+ .references(() => auth_user.id, { onDelete: "cascade" }),
45
+ accessToken: text("access_token"),
46
+ refreshToken: text("refresh_token"),
47
+ idToken: text("id_token"),
48
+ accessTokenExpiresAt: timestamp("access_token_expires_at"),
49
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
50
+ scope: text("scope"),
51
+ password: text("password"),
52
+ createdAt: timestamp("created_at").defaultNow().notNull(),
53
+ updatedAt: timestamp("updated_at")
54
+ .$onUpdate(() => /* @__PURE__ */ new Date())
55
+ .notNull(),
56
+ },
57
+ (table) => [index("auth_account_userId_idx").on(table.userId)],
58
+ );
59
+
60
+ export const auth_verification = pgTable(
61
+ "auth_verification",
62
+ {
63
+ id: text("id").primaryKey(),
64
+ identifier: text("identifier").notNull(),
65
+ value: text("value").notNull(),
66
+ expiresAt: timestamp("expires_at").notNull(),
67
+ createdAt: timestamp("created_at").defaultNow().notNull(),
68
+ updatedAt: timestamp("updated_at")
69
+ .defaultNow()
70
+ .$onUpdate(() => /* @__PURE__ */ new Date())
71
+ .notNull(),
72
+ },
73
+ (table) => [index("auth_verification_identifier_idx").on(table.identifier)],
74
+ );
75
+
76
+ export const auth_jwks = pgTable("auth_jwks", {
77
+ id: text("id").primaryKey(),
78
+ publicKey: text("public_key").notNull(),
79
+ privateKey: text("private_key").notNull(),
80
+ createdAt: timestamp("created_at").notNull(),
81
+ expiresAt: timestamp("expires_at"),
82
+ });
83
+
84
+ export const auth_userRelations = relations(auth_user, ({ many }) => ({
85
+ auth_sessions: many(auth_session),
86
+ auth_accounts: many(auth_account),
87
+ }));
88
+
89
+ export const auth_sessionRelations = relations(auth_session, ({ one }) => ({
90
+ auth_user: one(auth_user, {
91
+ fields: [auth_session.userId],
92
+ references: [auth_user.id],
93
+ }),
94
+ }));
95
+
96
+ export const auth_accountRelations = relations(auth_account, ({ one }) => ({
97
+ auth_user: one(auth_user, {
98
+ fields: [auth_account.userId],
99
+ references: [auth_user.id],
100
+ }),
101
+ }));
@@ -0,0 +1,80 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createAuth } from "./auth.js";
4
+ import { resolveSelfSignUpEnabled } from "./config.js";
5
+
6
+ describe("resolveSelfSignUpEnabled", () => {
7
+ it("defaults to enabled when unset or blank", () => {
8
+ assert.equal(resolveSelfSignUpEnabled(undefined), true);
9
+ assert.equal(resolveSelfSignUpEnabled(""), true);
10
+ assert.equal(resolveSelfSignUpEnabled(" "), true);
11
+ });
12
+
13
+ it("stays enabled for truthy or unrecognised values", () => {
14
+ assert.equal(resolveSelfSignUpEnabled("true"), true);
15
+ assert.equal(resolveSelfSignUpEnabled("1"), true);
16
+ assert.equal(resolveSelfSignUpEnabled("yes"), true);
17
+ });
18
+
19
+ it("closes signup only for an explicit off value", () => {
20
+ assert.equal(resolveSelfSignUpEnabled("false"), false);
21
+ assert.equal(resolveSelfSignUpEnabled("0"), false);
22
+ assert.equal(resolveSelfSignUpEnabled("no"), false);
23
+ });
24
+
25
+ it("matches off values case- and whitespace-insensitively", () => {
26
+ assert.equal(resolveSelfSignUpEnabled(" FALSE "), false);
27
+ assert.equal(resolveSelfSignUpEnabled("No"), false);
28
+ });
29
+ });
30
+
31
+ const baseConfig = {
32
+ connectionString: "postgresql://remit:remit@localhost:5432/remit_test",
33
+ secret: "signup-test-secret-value-32chars-minimum",
34
+ baseURL: "http://localhost:3000",
35
+ };
36
+
37
+ describe("createAuth self-signup gate", () => {
38
+ it("flips better-auth disableSignUp from the selfSignUpEnabled flag", async () => {
39
+ const open = await createAuth({ ...baseConfig, selfSignUpEnabled: true });
40
+ const closed = await createAuth({
41
+ ...baseConfig,
42
+ selfSignUpEnabled: false,
43
+ });
44
+ assert.equal(open.options.emailAndPassword?.disableSignUp, false);
45
+ assert.equal(closed.options.emailAndPassword?.disableSignUp, true);
46
+ });
47
+
48
+ it("selects the sqlite drizzle provider when configured", async () => {
49
+ const auth = await createAuth({
50
+ provider: "sqlite",
51
+ connectionString: ":memory:",
52
+ secret: baseConfig.secret,
53
+ baseURL: baseConfig.baseURL,
54
+ selfSignUpEnabled: true,
55
+ });
56
+ assert.equal(auth.options.emailAndPassword?.disableSignUp, false);
57
+ });
58
+
59
+ it("rejects a signup request server-side when disabled, before any DB access", async () => {
60
+ const auth = await createAuth({ ...baseConfig, selfSignUpEnabled: false });
61
+ const request = new Request(
62
+ "http://localhost:3000/api/auth/sign-up/email",
63
+ {
64
+ method: "POST",
65
+ headers: { "content-type": "application/json" },
66
+ body: JSON.stringify({
67
+ email: "nobody@example.com",
68
+ password: "a-sufficiently-long-password",
69
+ name: "Nobody",
70
+ }),
71
+ },
72
+ );
73
+
74
+ const response = await auth.handler(request);
75
+
76
+ assert.equal(response.status, 400);
77
+ const body = (await response.json()) as { code?: string };
78
+ assert.equal(body.code, "EMAIL_PASSWORD_SIGN_UP_DISABLED");
79
+ });
80
+ });
@@ -0,0 +1,21 @@
1
+ // JWT-verification-only slice of this package's barrel. The full barrel
2
+ // (`index.ts`) re-exports `createAuth` from `./auth.js`, which constructs a
3
+ // `pg.Pool` + `drizzle-orm/node-postgres` at module scope, and `toNodeHandler`
4
+ // from `better-auth/node` — neither needed to verify a token, both dragging a
5
+ // Postgres client into any Lambda bundle that only checks a bearer token (see
6
+ // #1242's FAQ, #1244/#1247). `config.ts`'s `AuthConfig` import from `./auth.js`
7
+ // is type-only and erases at build, so this stays free of that chain.
8
+
9
+ export {
10
+ AUTH_BASE_PATH,
11
+ AUTH_JWKS_PATH,
12
+ AUTH_TOKEN_PATH,
13
+ resolveVerifierConfig,
14
+ type VerifierConfig,
15
+ } from "./config.js";
16
+ export {
17
+ createJwtVerifier,
18
+ extractBearerToken,
19
+ type JwtClaims,
20
+ type JwtVerifier,
21
+ } from "./verify.js";
@@ -0,0 +1,108 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { generateKeyPair, type JWTVerifyGetKey, SignJWT } from "jose";
4
+ import type { VerifierConfig } from "./config.js";
5
+ import { createJwtVerifier, extractBearerToken } from "./verify.js";
6
+
7
+ const ISSUER = "http://localhost:5599";
8
+ const CONFIG: VerifierConfig = {
9
+ jwksUrl: `${ISSUER}/api/auth/jwks`,
10
+ issuer: ISSUER,
11
+ audience: ISSUER,
12
+ };
13
+
14
+ const keyPair = await generateKeyPair("RS256");
15
+ const getKey: JWTVerifyGetKey = async () => keyPair.publicKey;
16
+
17
+ const sign = (
18
+ claims: Record<string, unknown>,
19
+ overrides: { issuer?: string; audience?: string; expOffset?: number } = {},
20
+ ): Promise<string> => {
21
+ const jwt = new SignJWT(claims)
22
+ .setProtectedHeader({ alg: "RS256" })
23
+ .setIssuedAt()
24
+ .setIssuer(overrides.issuer ?? ISSUER)
25
+ .setAudience(overrides.audience ?? ISSUER)
26
+ .setExpirationTime(`${overrides.expOffset ?? 900}s`);
27
+ return jwt.sign(keyPair.privateKey);
28
+ };
29
+
30
+ test("verifies a valid RS256 token and returns sub + email", async () => {
31
+ const verify = createJwtVerifier(CONFIG, getKey);
32
+ const token = await sign({ sub: "user-123", email: "a@b.com" });
33
+ const claims = await verify(token);
34
+ assert.equal(claims.sub, "user-123");
35
+ assert.equal(claims.email, "a@b.com");
36
+ });
37
+
38
+ test("rejects a token signed by a different key", async () => {
39
+ const other = await generateKeyPair("RS256");
40
+ const verify = createJwtVerifier(CONFIG, getKey);
41
+ const token = await new SignJWT({ sub: "user-123" })
42
+ .setProtectedHeader({ alg: "RS256" })
43
+ .setIssuedAt()
44
+ .setIssuer(ISSUER)
45
+ .setAudience(ISSUER)
46
+ .setExpirationTime("900s")
47
+ .sign(other.privateKey);
48
+ await assert.rejects(() => verify(token));
49
+ });
50
+
51
+ test("rejects a token with the wrong issuer", async () => {
52
+ const verify = createJwtVerifier(CONFIG, getKey);
53
+ const token = await sign({ sub: "user-123" }, { issuer: "http://evil" });
54
+ await assert.rejects(() => verify(token));
55
+ });
56
+
57
+ test("rejects a token with the wrong audience", async () => {
58
+ const verify = createJwtVerifier(CONFIG, getKey);
59
+ const token = await sign({ sub: "user-123" }, { audience: "http://evil" });
60
+ await assert.rejects(() => verify(token));
61
+ });
62
+
63
+ test("rejects an expired token", async () => {
64
+ const verify = createJwtVerifier(CONFIG, getKey);
65
+ const token = await sign({ sub: "user-123" }, { expOffset: -10 });
66
+ await assert.rejects(() => verify(token));
67
+ });
68
+
69
+ test("rejects an HS256 token (no algorithm downgrade)", async () => {
70
+ const verify = createJwtVerifier(CONFIG, getKey);
71
+ const token = await new SignJWT({ sub: "user-123" })
72
+ .setProtectedHeader({ alg: "HS256" })
73
+ .setIssuedAt()
74
+ .setIssuer(ISSUER)
75
+ .setAudience(ISSUER)
76
+ .setExpirationTime("900s")
77
+ .sign(new TextEncoder().encode("attacker-chosen-shared-secret"));
78
+ await assert.rejects(() => verify(token));
79
+ });
80
+
81
+ test("rejects an unsigned alg:none token", async () => {
82
+ const verify = createJwtVerifier(CONFIG, getKey);
83
+ const b64 = (obj: unknown): string =>
84
+ Buffer.from(JSON.stringify(obj)).toString("base64url");
85
+ const header = b64({ alg: "none", typ: "JWT" });
86
+ const payload = b64({
87
+ sub: "user-123",
88
+ iss: ISSUER,
89
+ aud: ISSUER,
90
+ exp: Math.floor(Date.now() / 1000) + 900,
91
+ });
92
+ const token = `${header}.${payload}.`;
93
+ await assert.rejects(() => verify(token));
94
+ });
95
+
96
+ test("rejects a token without a sub claim", async () => {
97
+ const verify = createJwtVerifier(CONFIG, getKey);
98
+ const token = await sign({ email: "a@b.com" });
99
+ await assert.rejects(() => verify(token), /sub/);
100
+ });
101
+
102
+ test("extractBearerToken parses a Bearer header case-insensitively", () => {
103
+ assert.equal(extractBearerToken("Bearer abc.def.ghi"), "abc.def.ghi");
104
+ assert.equal(extractBearerToken("bearer abc"), "abc");
105
+ assert.equal(extractBearerToken(undefined), undefined);
106
+ assert.equal(extractBearerToken("Basic xyz"), undefined);
107
+ assert.equal(extractBearerToken("Bearer "), undefined);
108
+ });
package/src/verify.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { createRemoteJWKSet, type JWTVerifyGetKey, jwtVerify } from "jose";
2
+ import type { VerifierConfig } from "./config.js";
3
+
4
+ export interface JwtClaims {
5
+ sub: string;
6
+ email?: string;
7
+ }
8
+
9
+ export type JwtVerifier = (token: string) => Promise<JwtClaims>;
10
+
11
+ /**
12
+ * Verify a better-auth RS256 JWT against its JWKS, offline (no session lookup).
13
+ *
14
+ * `getKey` is injectable so the verification path can be unit-tested against a
15
+ * local key pair without a running JWKS endpoint; in production it defaults to a
16
+ * cached remote JWKS that follows key rotation.
17
+ */
18
+ export const createJwtVerifier = (
19
+ config: VerifierConfig,
20
+ getKey?: JWTVerifyGetKey,
21
+ ): JwtVerifier => {
22
+ const jwks = getKey ?? createRemoteJWKSet(new URL(config.jwksUrl));
23
+
24
+ return async (token: string): Promise<JwtClaims> => {
25
+ const { payload } = await jwtVerify(token, jwks, {
26
+ issuer: config.issuer,
27
+ audience: config.audience,
28
+ algorithms: ["RS256"],
29
+ });
30
+
31
+ const sub = payload.sub;
32
+ if (typeof sub !== "string" || sub.length === 0) {
33
+ throw new Error("JWT is missing a usable `sub` claim");
34
+ }
35
+
36
+ const email = typeof payload.email === "string" ? payload.email : undefined;
37
+ return { sub, email };
38
+ };
39
+ };
40
+
41
+ const BEARER_PREFIX = /^Bearer\s+/i;
42
+
43
+ export const extractBearerToken = (
44
+ authorization: string | undefined,
45
+ ): string | undefined => {
46
+ if (!authorization) return undefined;
47
+ if (!BEARER_PREFIX.test(authorization)) return undefined;
48
+ const token = authorization.replace(BEARER_PREFIX, "").trim();
49
+ return token.length > 0 ? token : undefined;
50
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*.ts", "drizzle.config.ts"]
8
+ }