diff --git a/bun.lock b/bun.lock index cfb35c89..373a7d58 100644 --- a/bun.lock +++ b/bun.lock @@ -235,7 +235,7 @@ }, "packages/auth": { "name": "@wrnexus/auth", - "version": "0.8.19", + "version": "0.8.20", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", diff --git a/packages/auth/package.json b/packages/auth/package.json index 2f7877db..43490e76 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/auth", - "version": "0.8.19", + "version": "0.8.20", "description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.", "type": "module", "sideEffects": false, diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 4db3961a..cfcf0597 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -8,6 +8,7 @@ export { export type { AuthStore } from "./store.ts"; export { MemoryAuthStore } from "./stores/memory.ts"; export { SqlAuthStore } from "./stores/sql.ts"; +export type { SqlAuthStoreOptions } from "./stores/sql.ts"; export { authSession, requireAuth, diff --git a/packages/auth/src/stores/sql.ts b/packages/auth/src/stores/sql.ts index 0978cc17..39829d6c 100644 --- a/packages/auth/src/stores/sql.ts +++ b/packages/auth/src/stores/sql.ts @@ -34,10 +34,31 @@ function placeholders(sql: string, dialect: string): string { return sql.replace(/\?/g, () => `$${++index}`); } +export interface SqlAuthStoreOptions { + /** PostgreSQL schema owning authentication tables. */ + schema?: string; + /** Defaults to wrn_auth_; use an empty prefix for e.g. sso.users. */ + tablePrefix?: string; +} + export class SqlAuthStore implements AuthStore { - constructor(private readonly db: Db) {} + constructor( + private readonly db: Db, + private readonly options: SqlAuthStoreOptions = {}, + ) { + if (options.schema && !/^[a-z_][a-z0-9_]*$/i.test(options.schema)) + throw new Error("Invalid auth schema identifier"); + if (options.tablePrefix && !/^[a-z_][a-z0-9_]*$/i.test(options.tablePrefix)) + throw new Error("Invalid auth table prefix"); + if (options.schema && db.driver.dialect !== "postgres") + throw new Error("Auth schema requires PostgreSQL"); + } private sql(value: string): string { - return placeholders(value, this.db.driver.dialect); + const qualified = value.replace(/\bwrn_auth_([a-z_]+)\b/g, (_match, table: string) => { + const name = (this.options.tablePrefix ?? "wrn_auth_") + table; + return this.options.schema ? '"' + this.options.schema + '"."' + name + '"' : name; + }); + return placeholders(qualified, this.db.driver.dialect); } private async one(sql: string, params: unknown[] = []): Promise { return (await this.db.one(this.sql(sql), params)) ?? undefined; diff --git a/packages/auth/test/sql-schema.test.ts b/packages/auth/test/sql-schema.test.ts new file mode 100644 index 00000000..e8ab6e89 --- /dev/null +++ b/packages/auth/test/sql-schema.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import type { Db } from "@wrnexus/db"; +import { SqlAuthStore } from "../src/stores/sql.ts"; + +test("auth store qualifies tables without changing parameter values", async () => { + let query = ""; + let values: unknown[] = []; + const db = { + driver: { dialect: "postgres" }, + one: async (sql: string, params: unknown[]) => { + query = sql; + values = params; + return undefined; + }, + } as unknown as Db; + const store = new SqlAuthStore(db, { schema: "sso", tablePrefix: "" }); + await store.findUserById("wrn_auth_users"); + expect(query).toContain('FROM "sso"."users"'); + expect(query).toContain("$1"); + expect(values).toEqual(["wrn_auth_users"]); + expect(() => new SqlAuthStore(db, { schema: 'sso";DROP TABLE users' })).toThrow(); +});