UNPKG

@kitiumai/auth-postgres

Version:

Enterprise-grade PostgreSQL storage adapter for @kitiumai/auth with full support for users, sessions, OAuth links, API keys, 2FA, RBAC, and SSO

1,460 lines (1,441 loc) 57.6 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); var __publicField = (obj, key, value) => { __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); return value; }; // src/index.ts var src_exports = {}; __export(src_exports, { PostgresStorageAdapter: () => PostgresStorageAdapter }); module.exports = __toCommonJS(src_exports); // src/postgres.ts var import_pg = require("pg"); var import_logger = require("@kitiumai/logger"); var import_error = require("@kitiumai/error"); var import_utils = require("@kitiumai/auth/utils"); var import_promises = require("timers/promises"); var PostgresStorageAdapter = class { constructor(connectionString, options) { __publicField(this, "pool"); __publicField(this, "logger", (0, import_logger.getLogger)()); __publicField(this, "defaultQueryTimeoutMs"); __publicField(this, "defaultRetries"); const { statementTimeoutMs, maxRetries, ...poolOptions } = options ?? {}; this.pool = new import_pg.Pool({ connectionString, ...poolOptions ?? {} }); this.defaultQueryTimeoutMs = statementTimeoutMs ?? 5e3; this.defaultRetries = maxRetries ?? 2; } async connect() { try { await this.runMigrations(); this.logger.info("PostgreSQL adapter connected successfully"); } catch (error) { this.logger.error("Failed to connect to PostgreSQL", { error }); throw new import_error.InternalError({ code: "auth-postgres/connection_failed", message: "Failed to connect to PostgreSQL", severity: "error", retryable: true, cause: error, context: { connectionString: this.maskConnectionString() } }); } } maskConnectionString() { return "***"; } async disconnect() { try { await this.pool.end(); this.logger.info("PostgreSQL adapter disconnected"); } catch (error) { this.logger.error("Error disconnecting from PostgreSQL", { error }); throw new import_error.InternalError({ code: "auth-postgres/disconnect_failed", message: "Failed to disconnect from PostgreSQL", severity: "error", retryable: false, cause: error }); } } async withClient(fn) { const client = await this.pool.connect(); try { return await fn(client); } finally { client.release(); } } async query(text, values = [], options) { const retries = options?.retries ?? this.defaultRetries; const timeoutMs = options?.timeoutMs ?? this.defaultQueryTimeoutMs; let lastError; for (let attempt = 0; attempt <= retries; attempt += 1) { try { return await this.withClient(async (client) => { const start = Date.now(); await client.query("BEGIN"); try { if (timeoutMs) { await client.query("SET LOCAL statement_timeout = $1", [timeoutMs]); } const result = await client.query(text, values); await client.query("COMMIT"); const durationMs = Date.now() - start; this.logger.debug("postgres.query", { operation: options?.operation, durationMs }); return result; } catch (error) { await client.query("ROLLBACK"); throw error; } }); } catch (error) { lastError = error; const retryable = attempt < retries; this.logger.warn("PostgreSQL query failed", { operation: options?.operation, attempt, retryable, error }); if (!retryable) { throw new import_error.InternalError({ code: "auth-postgres/query_failed", message: "Failed to execute PostgreSQL query", severity: "error", retryable: false, cause: error }); } await (0, import_promises.setTimeout)(50 * 2 ** attempt); } } throw lastError; } async healthCheck() { const start = Date.now(); try { await this.query("SELECT 1", [], { operation: "health_check", retries: 0 }); return { status: "ok", latencyMs: Date.now() - start }; } catch (error) { this.logger.error("PostgreSQL health check failed", { error }); return { status: "error", latencyMs: Date.now() - start }; } } async runMigrations() { await this.query( `CREATE TABLE IF NOT EXISTS auth_migrations ( id VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMP NOT NULL DEFAULT NOW() )` ); const migrationId = "0001_initial_schema_v2"; const existing = await this.query("SELECT id FROM auth_migrations WHERE id = $1", [migrationId]); if (existing.rows.length > 0) { return; } const createTables = ` -- Users table CREATE TABLE IF NOT EXISTS users ( id VARCHAR(255) PRIMARY KEY, email VARCHAR(255) UNIQUE, name VARCHAR(255), picture VARCHAR(1024), plan VARCHAR(50) DEFAULT 'free', entitlements TEXT[] NOT NULL DEFAULT '{}', oauth JSONB DEFAULT '{}', metadata JSONB DEFAULT '{}', created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); -- Organizations table CREATE TABLE IF NOT EXISTS organizations ( id VARCHAR(255) PRIMARY KEY, name VARCHAR(255) NOT NULL, plan VARCHAR(50) NOT NULL, seats INTEGER NOT NULL DEFAULT 1, members JSONB NOT NULL DEFAULT '[]', metadata JSONB DEFAULT '{}', created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); -- API Keys table CREATE TABLE IF NOT EXISTS api_keys ( id VARCHAR(255) PRIMARY KEY, principal_id VARCHAR(255) NOT NULL, hash VARCHAR(255) NOT NULL UNIQUE, prefix VARCHAR(50) NOT NULL, last_four VARCHAR(4) NOT NULL, scopes TEXT[] NOT NULL DEFAULT '{}', metadata JSONB DEFAULT '{}', expires_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), CONSTRAINT fk_api_keys_principal_user FOREIGN KEY (principal_id) REFERENCES users(id) ON DELETE CASCADE ); -- Sessions table CREATE TABLE IF NOT EXISTS sessions ( id VARCHAR(255) PRIMARY KEY, user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE, org_id VARCHAR(255), plan VARCHAR(50), entitlements TEXT[] NOT NULL DEFAULT '{}', expires_at TIMESTAMP NOT NULL, metadata JSONB DEFAULT '{}', created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), CONSTRAINT fk_sessions_org FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE SET NULL ); -- Email Verification Tokens table CREATE TABLE IF NOT EXISTS email_verification_tokens ( id VARCHAR(255) PRIMARY KEY, email VARCHAR(255) NOT NULL, code VARCHAR(255) NOT NULL, code_hash VARCHAR(255) NOT NULL, type VARCHAR(50) NOT NULL, user_id VARCHAR(255), metadata JSONB DEFAULT '{}', expires_at TIMESTAMP NOT NULL, used_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW(), CONSTRAINT fk_email_verification_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ); -- Email Verification Token Attempts table CREATE TABLE IF NOT EXISTS email_verification_token_attempts ( token_id VARCHAR(255) PRIMARY KEY REFERENCES email_verification_tokens(id) ON DELETE CASCADE, attempts INTEGER DEFAULT 0 ); -- Events table CREATE TABLE IF NOT EXISTS auth_events ( id SERIAL PRIMARY KEY, type VARCHAR(100) NOT NULL, principal_id VARCHAR(255) NOT NULL, org_id VARCHAR(255), data JSONB NOT NULL DEFAULT '{}', timestamp TIMESTAMP DEFAULT NOW() ); -- Roles table CREATE TABLE IF NOT EXISTS roles ( id VARCHAR(255) PRIMARY KEY, org_id VARCHAR(255) NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, description TEXT, is_system BOOLEAN DEFAULT FALSE, permissions JSONB NOT NULL DEFAULT '[]', metadata JSONB DEFAULT '{}', created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); -- User Roles table CREATE TABLE IF NOT EXISTS user_roles ( id VARCHAR(255) PRIMARY KEY, user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id VARCHAR(255) NOT NULL REFERENCES roles(id) ON DELETE CASCADE, org_id VARCHAR(255) NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, assigned_at TIMESTAMP DEFAULT NOW() ); -- SSO Providers table CREATE TABLE IF NOT EXISTS sso_providers ( id VARCHAR(255) PRIMARY KEY, type VARCHAR(50) NOT NULL, name VARCHAR(255) NOT NULL, org_id VARCHAR(255), metadata_url TEXT, client_id VARCHAR(255), client_secret TEXT, token_endpoint_auth_method VARCHAR(50), idp_entity_id TEXT, idp_sso_url TEXT, idp_slo_url TEXT, idp_certificate TEXT, sp_entity_id TEXT, sp_acs_url TEXT, sp_slo_url TEXT, signing_cert TEXT, signing_key TEXT, encryption_enabled BOOLEAN DEFAULT FALSE, force_authn BOOLEAN DEFAULT FALSE, scopes TEXT[] DEFAULT '{}', redirect_uris TEXT[] DEFAULT '{}', claim_mapping JSONB DEFAULT '{}', attribute_mapping JSONB DEFAULT '{}', metadata JSONB DEFAULT '{}', created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), CONSTRAINT fk_sso_providers_org FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE SET NULL ); -- SSO Links table CREATE TABLE IF NOT EXISTS sso_links ( id VARCHAR(255) PRIMARY KEY, user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE, provider_id VARCHAR(255) NOT NULL REFERENCES sso_providers(id) ON DELETE CASCADE, provider_type VARCHAR(50) NOT NULL, provider_subject VARCHAR(255) NOT NULL, provider_email VARCHAR(255), auto_provisioned BOOLEAN DEFAULT FALSE, metadata JSONB DEFAULT '{}', linked_at TIMESTAMP DEFAULT NOW(), last_auth_at TIMESTAMP ); -- SSO Sessions table CREATE TABLE IF NOT EXISTS sso_sessions ( id VARCHAR(255) PRIMARY KEY, user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE, provider_id VARCHAR(255) NOT NULL REFERENCES sso_providers(id) ON DELETE CASCADE, provider_type VARCHAR(50) NOT NULL, provider_subject VARCHAR(255) NOT NULL, session_token TEXT, expires_at TIMESTAMP NOT NULL, linked_at TIMESTAMP DEFAULT NOW(), last_auth_at TIMESTAMP ); -- 2FA Devices table CREATE TABLE IF NOT EXISTS twofa_devices ( id VARCHAR(255) PRIMARY KEY, user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE, method VARCHAR(50) NOT NULL, name VARCHAR(255), verified BOOLEAN DEFAULT FALSE, phone_number VARCHAR(50), secret TEXT, last_used_at TIMESTAMP, metadata JSONB DEFAULT '{}', created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); -- 2FA Backup Codes table CREATE TABLE IF NOT EXISTS twofa_backup_codes ( id VARCHAR(255) PRIMARY KEY, user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE, code VARCHAR(255) NOT NULL, used BOOLEAN DEFAULT FALSE, used_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW() ); -- 2FA Sessions table CREATE TABLE IF NOT EXISTS twofa_sessions ( id VARCHAR(255) PRIMARY KEY, user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE, session_id VARCHAR(255) NOT NULL, device_id VARCHAR(255) NOT NULL REFERENCES twofa_devices(id) ON DELETE CASCADE, method VARCHAR(50) NOT NULL, verification_code VARCHAR(10), attempt_count INTEGER DEFAULT 0, max_attempts INTEGER DEFAULT 5, expires_at TIMESTAMP NOT NULL, completed_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW() ); -- Indexes CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_api_keys_principal_id ON api_keys(principal_id); CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(hash); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_organizations_plan ON organizations(plan); CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_email ON email_verification_tokens(email); CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_type ON email_verification_tokens(type); CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens(expires_at); CREATE INDEX IF NOT EXISTS idx_auth_events_principal_id ON auth_events(principal_id); CREATE INDEX IF NOT EXISTS idx_auth_events_type ON auth_events(type); CREATE INDEX IF NOT EXISTS idx_auth_events_timestamp ON auth_events(timestamp); CREATE INDEX IF NOT EXISTS idx_roles_org_id ON roles(org_id); CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id); CREATE INDEX IF NOT EXISTS idx_user_roles_org_id ON user_roles(org_id); CREATE INDEX IF NOT EXISTS idx_sso_providers_org_id ON sso_providers(org_id); CREATE INDEX IF NOT EXISTS idx_sso_links_user_id ON sso_links(user_id); CREATE INDEX IF NOT EXISTS idx_sso_links_provider_id ON sso_links(provider_id); CREATE INDEX IF NOT EXISTS idx_sso_sessions_user_id ON sso_sessions(user_id); CREATE INDEX IF NOT EXISTS idx_twofa_devices_user_id ON twofa_devices(user_id); CREATE INDEX IF NOT EXISTS idx_twofa_backup_codes_user_id ON twofa_backup_codes(user_id); CREATE INDEX IF NOT EXISTS idx_twofa_sessions_user_id ON twofa_sessions(user_id); CREATE INDEX IF NOT EXISTS idx_twofa_sessions_session_id ON twofa_sessions(session_id); -- Updated at trigger CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; DO $$ DECLARE tbl TEXT; BEGIN FOR tbl IN SELECT UNNEST(ARRAY['users','api_keys','sessions','organizations','roles','twofa_devices']) LOOP EXECUTE format('CREATE TRIGGER %I_set_updated_at BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION set_updated_at();', tbl, tbl); END LOOP; END; $$; `; await this.query(createTables, [], { operation: "migration:initial", timeoutMs: 3e4, retries: 0 }); await this.query("INSERT INTO auth_migrations (id) VALUES ($1)", [migrationId]); } mapEmailVerificationToken(row) { return { id: String(row.id), email: String(row.email), code: String(row.code), codeHash: String(row.code_hash), type: String(row.type), userId: row.user_id ?? void 0, metadata: row.metadata || {}, expiresAt: new Date(row.expires_at), createdAt: new Date(row.created_at), usedAt: row.used_at ? new Date(row.used_at) : void 0 }; } /** * Create an API key with plaintext secret (convenience method) * @param principalId - Principal ID for the key * @param scopes - Scopes for the key * @param prefix - Optional prefix (default: 'api') * @returns Object with the record and plaintext key */ async createApiKeyWithSecret(principalId, scopes, prefix = "api") { const key = (0, import_utils.generateApiKey)(prefix); const hash = (0, import_utils.hashApiKey)(key); const parts = key.split("_"); const lastFour = parts[parts.length - 1].slice(-4); const record = await this.createApiKey({ principalId, hash, prefix, lastFour, scopes }); return { record, key }; } // API Key methods async createApiKey(data) { const query = ` INSERT INTO api_keys (id, principal_id, hash, prefix, last_four, scopes, metadata, expires_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING * `; const values = [ (0, import_utils.generateId)(), data["principalId"], data["hash"], data["prefix"], data["lastFour"], data["scopes"], JSON.stringify(data["metadata"] || {}), data["expiresAt"] || null, /* @__PURE__ */ new Date() ]; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/create_failed", message: "Failed to create API key", severity: "error", retryable: false }); } return this.mapApiKeyRecord(result.rows[0]); } async getApiKey(id) { const query = "SELECT * FROM api_keys WHERE id = $1"; const result = await this.query(query, [id]); if (result.rows.length === 0) { return null; } return this.mapApiKeyRecord(result.rows[0]); } async getApiKeyByHash(hash) { const query = "SELECT * FROM api_keys WHERE hash = $1"; const result = await this.query(query, [hash]); if (result.rows.length === 0) { return null; } return this.mapApiKeyRecord(result.rows[0]); } async getApiKeysByPrefixAndLastFour(prefix, lastFour) { const query = "SELECT * FROM api_keys WHERE prefix = $1 AND last_four = $2"; const result = await this.query(query, [prefix, lastFour]); return result.rows.filter((row) => !!row).map((row) => this.mapApiKeyRecord(row)); } async updateApiKey(id, data) { const fields = []; const values = []; let paramCount = 1; for (const [key, value] of Object.entries(data)) { if (key === "id" || key === "createdAt") { continue; } if (key === "metadata") { fields.push(`metadata = $${paramCount}`); values.push(JSON.stringify(value)); } else if (key === "principalId") { fields.push(`principal_id = $${paramCount}`); values.push(value); } else if (key === "lastFour") { fields.push(`last_four = $${paramCount}`); values.push(value); } else if (key === "expiresAt") { fields.push(`expires_at = $${paramCount}`); values.push(value || null); } else { const snakeKey = this.camelToSnake(key); fields.push(`${snakeKey} = $${paramCount}`); values.push(value); } paramCount++; } if (fields.length === 0) { throw new import_error.InternalError({ code: "auth-postgres/no_fields_to_update", message: "No fields to update", severity: "error", retryable: false }); } fields.push(`updated_at = $${paramCount}`); values.push(/* @__PURE__ */ new Date()); values.push(id); const query = ` UPDATE api_keys SET ${fields.join(", ")} WHERE id = $${paramCount + 1} RETURNING * `; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/update_failed", message: "Failed to update API key", severity: "error", retryable: false }); } return this.mapApiKeyRecord(result.rows[0]); } async deleteApiKey(id) { const query = "DELETE FROM api_keys WHERE id = $1"; await this.query(query, [id]); } async listApiKeys(principalId) { const query = "SELECT * FROM api_keys WHERE principal_id = $1 ORDER BY created_at DESC"; const result = await this.query(query, [principalId]); return result.rows.filter((row) => !!row).map((row) => this.mapApiKeyRecord(row)); } // Session methods async createSession(data) { const query = ` INSERT INTO sessions (id, user_id, org_id, plan, entitlements, expires_at, metadata, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING * `; const values = [ (0, import_utils.generateId)(), data.userId, data.orgId || null, data.plan || null, data.entitlements || [], data.expiresAt, JSON.stringify(data.metadata || {}), /* @__PURE__ */ new Date() ]; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/create_failed", message: "Failed to create session", severity: "error", retryable: false }); } return this.mapSessionRecord(result.rows[0]); } async getSession(id) { const query = "SELECT * FROM sessions WHERE id = $1"; const result = await this.query(query, [id]); if (result.rows.length === 0) { return null; } return this.mapSessionRecord(result.rows[0]); } async updateSession(id, data) { const fields = []; const values = []; let paramCount = 1; for (const [key, value] of Object.entries(data)) { if (key === "id" || key === "createdAt") { continue; } if (key === "metadata") { fields.push(`metadata = $${paramCount}`); values.push(JSON.stringify(value)); } else if (key === "userId") { fields.push(`user_id = $${paramCount}`); values.push(value); } else if (key === "orgId") { fields.push(`org_id = $${paramCount}`); values.push(value || null); } else if (key === "expiresAt") { fields.push(`expires_at = $${paramCount}`); values.push(value); } else { const snakeKey = this.camelToSnake(key); fields.push(`${snakeKey} = $${paramCount}`); values.push(value); } paramCount++; } if (fields.length === 0) { throw new import_error.InternalError({ code: "auth-postgres/no_fields_to_update", message: "No fields to update", severity: "error", retryable: false }); } fields.push(`updated_at = $${paramCount}`); values.push(/* @__PURE__ */ new Date()); values.push(id); const query = ` UPDATE sessions SET ${fields.join(", ")} WHERE id = $${paramCount + 1} RETURNING * `; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/update_failed", message: "Failed to update session", severity: "error", retryable: false }); } return this.mapSessionRecord(result.rows[0]); } async deleteSession(id) { const query = "DELETE FROM sessions WHERE id = $1"; await this.query(query, [id]); } // Organization methods async createOrganization(data) { const query = ` INSERT INTO organizations (id, name, plan, seats, members, metadata) VALUES ($1, $2, $3, $4, $5, $6) RETURNING * `; const values = [ (0, import_utils.generateId)(), data.name, data.plan, data.seats, JSON.stringify(data.members), JSON.stringify(data.metadata || {}) ]; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/create_failed", message: "Failed to create organization", severity: "error", retryable: false }); } return this.mapOrganizationRecord(result.rows[0]); } async getOrganization(id) { const query = "SELECT * FROM organizations WHERE id = $1"; const result = await this.query(query, [id]); if (result.rows.length === 0) { return null; } return this.mapOrganizationRecord(result.rows[0]); } async updateOrganization(id, data) { const fields = []; const values = []; let paramCount = 1; for (const [key, value] of Object.entries(data)) { if (key === "id" || key === "createdAt") { continue; } if (key === "members" || key === "metadata") { fields.push(`${key} = $${paramCount}`); values.push(JSON.stringify(value)); } else { fields.push(`${key} = $${paramCount}`); values.push(value); } paramCount++; } if (fields.length === 0) { throw new import_error.InternalError({ code: "auth-postgres/no_fields_to_update", message: "No fields to update", severity: "error", retryable: false }); } fields.push(`updated_at = $${paramCount}`); values.push(/* @__PURE__ */ new Date()); values.push(id); const query = ` UPDATE organizations SET ${fields.join(", ")} WHERE id = $${paramCount + 1} RETURNING * `; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/update_failed", message: "Failed to update organization", severity: "error", retryable: false }); } return this.mapOrganizationRecord(result.rows[0]); } async deleteOrganization(id) { const query = "DELETE FROM organizations WHERE id = $1"; await this.query(query, [id]); } // User methods async createUser(data) { const id = (0, import_utils.generateId)(); const query = ` INSERT INTO users (id, email, name, picture, plan, entitlements, oauth, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING * `; const values = [ id, data.email || null, data.name || null, data.picture || null, data.plan || "free", data.entitlements || [], JSON.stringify({}), JSON.stringify(data.metadata || {}) ]; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/create_failed", message: "Failed to create user", severity: "error", retryable: false }); } return this.mapUserRecord(result.rows[0]); } async getUser(id) { const query = "SELECT * FROM users WHERE id = $1"; const result = await this.query(query, [id]); if (result.rows.length === 0) { return null; } return this.mapUserRecord(result.rows[0]); } async getUserByEmail(email) { const query = "SELECT * FROM users WHERE email = $1"; const result = await this.query(query, [email]); if (result.rows.length === 0) { return null; } return this.mapUserRecord(result.rows[0]); } async getUserByOAuth(provider, sub) { const query = ` SELECT * FROM users WHERE oauth->>$1 IS NOT NULL AND oauth->$1->>'sub' = $2 `; const result = await this.query(query, [provider, sub]); if (result.rows.length === 0) { return null; } return this.mapUserRecord(result.rows[0]); } async updateUser(id, data) { const fields = []; const values = []; let paramCount = 1; for (const [key, value] of Object.entries(data)) { if (key === "metadata") { fields.push(`${this.camelToSnake(key)} = $${paramCount}`); values.push(JSON.stringify(value)); } else if (key === "entitlements") { fields.push(`${this.camelToSnake(key)} = $${paramCount}`); values.push(value); } else { fields.push(`${this.camelToSnake(key)} = $${paramCount}`); values.push(value); } paramCount++; } if (fields.length === 0) { return this.getUser(id); } fields.push(`updated_at = $${paramCount}`); values.push(/* @__PURE__ */ new Date()); values.push(id); const query = ` UPDATE users SET ${fields.join(", ")} WHERE id = $${paramCount + 1} RETURNING * `; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/update_failed", message: "Failed to update user", severity: "error", retryable: false }); } return this.mapUserRecord(result.rows[0]); } async deleteUser(id) { const query = "DELETE FROM users WHERE id = $1"; await this.query(query, [id]); } async linkOAuthAccount(userId, provider, oauthLink) { const query = ` UPDATE users SET oauth = jsonb_set(oauth, $2, $3), updated_at = NOW() WHERE id = $1 RETURNING * `; const oauthData = { provider: oauthLink.provider, sub: oauthLink.sub, email: oauthLink.email, name: oauthLink.name, linkedAt: oauthLink.linkedAt.toISOString() }; const values = [userId, `{${provider}}`, JSON.stringify(oauthData)]; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/link_failed", message: "Failed to link OAuth account", severity: "error", retryable: false }); } return this.mapUserRecord(result.rows[0]); } // Email Verification Token methods async createEmailVerificationToken(data) { const id = (0, import_utils.generateId)(); const query = ` INSERT INTO email_verification_tokens (id, email, code, code_hash, type, user_id, metadata, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING * `; const values = [ id, data.email, data.code, data.codeHash, data.type, data.userId || null, JSON.stringify(data.metadata || {}), data.expiresAt ]; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/create_failed", message: "Failed to create email verification token", severity: "error", retryable: false }); } return this.mapEmailVerificationToken(result.rows[0]); } async getEmailVerificationTokens(email, type) { let query = "SELECT * FROM email_verification_tokens WHERE email = $1"; const values = [email]; if (type) { query += " AND type = $2"; values.push(type); } query += " ORDER BY created_at DESC"; const result = await this.query(query, values); return result.rows.filter((row) => !!row).map((row) => this.mapEmailVerificationToken(row)); } async getEmailVerificationTokenById(id) { const query = "SELECT * FROM email_verification_tokens WHERE id = $1"; const result = await this.query(query, [id]); if (result.rows.length === 0) { return null; } return this.mapEmailVerificationToken(result.rows[0]); } async markEmailVerificationTokenAsUsed(id) { const query = ` UPDATE email_verification_tokens SET used_at = NOW() WHERE id = $1 RETURNING * `; const result = await this.query(query, [id]); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/update_failed", message: "Failed to mark email verification token as used", severity: "error", retryable: false }); } return this.mapEmailVerificationToken(result.rows[0]); } async deleteExpiredEmailVerificationTokens() { const query = "DELETE FROM email_verification_tokens WHERE expires_at < NOW()"; const result = await this.query(query); return result.rowCount || 0; } async getEmailVerificationTokenAttempts(tokenId) { const query = "SELECT attempts FROM email_verification_token_attempts WHERE token_id = $1"; const result = await this.query(query, [tokenId]); if (result.rows.length === 0) { return 0; } return result.rows[0].attempts; } async incrementEmailVerificationTokenAttempts(tokenId) { const query = ` INSERT INTO email_verification_token_attempts (token_id, attempts) VALUES ($1, 1) ON CONFLICT (token_id) DO UPDATE SET attempts = attempts + 1 RETURNING attempts `; const result = await this.query(query, [tokenId]); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/update_failed", message: "Failed to increment token attempts", severity: "error", retryable: false }); } return result.rows[0].attempts; } // Event methods async emitEvent(event) { const query = ` INSERT INTO auth_events (type, principal_id, org_id, data, timestamp) VALUES ($1, $2, $3, $4, $5) `; const values = [ event.type, event.principalId, event.orgId, JSON.stringify(event.data), event.timestamp ]; await this.query(query, values); } // RBAC methods async createRole(data) { const id = (0, import_utils.generateId)(); const query = ` INSERT INTO roles (id, org_id, name, description, is_system, permissions, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `; const values = [ id, data.orgId, data.name, data.description || null, data.isSystem || false, JSON.stringify(data.permissions || []), JSON.stringify(data.metadata || {}) ]; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/create_failed", message: "Failed to create role", severity: "error", retryable: false }); } return this.mapRoleRecord(result.rows[0]); } async getRole(roleId) { const query = "SELECT * FROM roles WHERE id = $1"; const result = await this.query(query, [roleId]); if (result.rows.length === 0) { return null; } return this.mapRoleRecord(result.rows[0]); } async updateRole(roleId, data) { const fields = []; const values = []; let paramCount = 1; for (const [key, value] of Object.entries(data)) { if (key === "id" || key === "createdAt") { continue; } const snakeKey = this.camelToSnake(key); if (key === "permissions" || key === "metadata") { fields.push(`${snakeKey} = $${paramCount}`); values.push(JSON.stringify(value)); } else { fields.push(`${snakeKey} = $${paramCount}`); values.push(value); } paramCount++; } if (fields.length === 0) { const existing = await this.getRole(roleId); if (!existing) { throw new import_error.InternalError({ code: "auth-postgres/role_not_found", message: "Role not found", severity: "error", retryable: false, context: { roleId } }); } return existing; } fields.push(`updated_at = $${paramCount}`); values.push(/* @__PURE__ */ new Date()); values.push(roleId); const query = ` UPDATE roles SET ${fields.join(", ")} WHERE id = $${paramCount + 1} RETURNING * `; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/update_failed", message: "Failed to update role", severity: "error", retryable: false }); } return this.mapRoleRecord(result.rows[0]); } async deleteRole(roleId) { const query = "DELETE FROM roles WHERE id = $1"; await this.query(query, [roleId]); } async listRoles(orgId) { const query = "SELECT * FROM roles WHERE org_id = $1 ORDER BY created_at DESC"; const result = await this.query(query, [orgId]); return result.rows.map((row) => this.mapRoleRecord(row)); } async assignRoleToUser(userId, roleId, orgId) { const id = (0, import_utils.generateId)(); const query = ` INSERT INTO user_roles (id, user_id, role_id, org_id) VALUES ($1, $2, $3, $4) RETURNING * `; const values = [id, userId, roleId, orgId]; await this.query(query, values); const role = await this.getRole(roleId); if (!role) { throw new import_error.InternalError({ code: "auth-postgres/role_not_found", message: "Role not found", severity: "error", retryable: false, context: { roleId } }); } return role; } async revokeRoleFromUser(userId, roleId, orgId) { const query = "DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2 AND org_id = $3"; await this.query(query, [userId, roleId, orgId]); } async getUserRoles(userId, orgId) { const query = ` SELECT r.* FROM roles r INNER JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = $1 AND ur.org_id = $2 ORDER BY r.created_at DESC `; const result = await this.query(query, [userId, orgId]); return result.rows.filter((row) => !!row).map((row) => this.mapRoleRecord(row)); } // SSO methods async createSSOProvider(data) { const id = (0, import_utils.generateId)(); const query = ` INSERT INTO sso_providers ( id, type, name, org_id, metadata_url, client_id, client_secret, token_endpoint_auth_method, idp_entity_id, idp_sso_url, idp_slo_url, idp_certificate, sp_entity_id, sp_acs_url, sp_slo_url, signing_cert, signing_key, encryption_enabled, force_authn, scopes, redirect_uris, claim_mapping, attribute_mapping, metadata ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) RETURNING * `; const values = [ id, data.type, data.name, data.orgId || null, data.metadata_url || data.metadataUrl || null, data.client_id || data.clientId || null, data.client_secret || data.clientSecret || null, data.token_endpoint_auth_method || data.tokenEndpointAuthMethod || null, data.idp_entity_id || data.idpEntityId || null, data.idp_sso_url || data.idpSsoUrl || null, data.idp_slo_url || data.idpSloUrl || null, data.idp_certificate || data.idpCertificate || null, data.sp_entity_id || data.spEntityId || null, data.sp_acs_url || data.spAcsUrl || null, data.sp_slo_url || data.spSloUrl || null, data.signing_cert || data.signingCert || null, data.signing_key || data.signingKey || null, data.encryption_enabled || data.encryptionEnabled || false, data.force_authn || data.forceAuthn || false, data.scopes || [], data.redirect_uris || data.redirectUris || [], JSON.stringify(data.claim_mapping || data.claimMapping || {}), JSON.stringify(data.attribute_mapping || data.attributeMapping || {}), JSON.stringify(data.metadata || {}) ]; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/create_failed", message: "Failed to create SSO provider", severity: "error", retryable: false }); } return this.mapSSOProviderRecord(result.rows[0]); } async getSSOProvider(providerId) { const query = "SELECT * FROM sso_providers WHERE id = $1"; const result = await this.query(query, [providerId]); if (result.rows.length === 0) { return null; } return this.mapSSOProviderRecord(result.rows[0]); } async updateSSOProvider(providerId, data) { const fields = []; const values = []; let paramCount = 1; for (const [key, value] of Object.entries(data)) { if (key === "id" || key === "createdAt") { continue; } const snakeKey = this.camelToSnake(key); if (key === "claimMapping" || key === "attributeMapping" || key === "metadata") { fields.push(`${snakeKey} = $${paramCount}`); values.push(JSON.stringify(value)); } else { fields.push(`${snakeKey} = $${paramCount}`); values.push(value); } paramCount++; } if (fields.length === 0) { return this.getSSOProvider(providerId); } fields.push(`updated_at = $${paramCount}`); values.push(/* @__PURE__ */ new Date()); values.push(providerId); const query = ` UPDATE sso_providers SET ${fields.join(", ")} WHERE id = $${paramCount + 1} RETURNING * `; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/update_failed", message: "Failed to update SSO provider", severity: "error", retryable: false }); } return this.mapSSOProviderRecord(result.rows[0]); } async deleteSSOProvider(providerId) { const query = "DELETE FROM sso_providers WHERE id = $1"; await this.query(query, [providerId]); } async listSSOProviders(orgId) { let query = "SELECT * FROM sso_providers"; const values = []; if (orgId) { query += " WHERE org_id = $1 OR org_id IS NULL"; values.push(orgId); } query += " ORDER BY created_at DESC"; const result = await this.query(query, values); return result.rows.filter((row) => !!row).map((row) => this.mapSSOProviderRecord(row)); } async createSSOLink(data) { const id = (0, import_utils.generateId)(); const query = ` INSERT INTO sso_links ( id, user_id, provider_id, provider_type, provider_subject, provider_email, auto_provisioned, metadata, last_auth_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING * `; const values = [ id, data.userId, data.providerId, data.providerType, data.providerSubject, data.providerEmail || null, data.autoProvisioned || false, JSON.stringify(data.metadata || {}), data.lastAuthAt || /* @__PURE__ */ new Date() ]; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/create_failed", message: "Failed to create SSO link", severity: "error", retryable: false }); } return this.mapSSOLinkRecord(result.rows[0]); } async getSSOLink(linkId) { const query = "SELECT * FROM sso_links WHERE id = $1"; const result = await this.query(query, [linkId]); if (result.rows.length === 0) { return null; } return this.mapSSOLinkRecord(result.rows[0]); } async getUserSSOLinks(userId) { const query = "SELECT * FROM sso_links WHERE user_id = $1 ORDER BY linked_at DESC"; const result = await this.query(query, [userId]); return result.rows.filter((row) => !!row).map((row) => this.mapSSOLinkRecord(row)); } async deleteSSOLink(linkId) { const query = "DELETE FROM sso_links WHERE id = $1"; await this.query(query, [linkId]); } async createSSOSession(data) { const id = (0, import_utils.generateId)(); const query = ` INSERT INTO sso_sessions ( id, user_id, provider_id, provider_type, provider_subject, session_token, expires_at, last_auth_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING * `; const values = [ id, data.userId, data.providerId, data.providerType, data.providerSubject, data.sessionToken || null, data.expiresAt, data.lastAuthAt || /* @__PURE__ */ new Date() ]; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/create_failed", message: "Failed to create SSO session", severity: "error", retryable: false }); } return this.mapSSOSessionRecord(result.rows[0]); } async getSSOSession(sessionId) { const query = "SELECT * FROM sso_sessions WHERE id = $1"; const result = await this.query(query, [sessionId]); if (result.rows.length === 0) { return null; } return this.mapSSOSessionRecord(result.rows[0]); } // 2FA methods async createTwoFactorDevice(data) { const id = (0, import_utils.generateId)(); const query = ` INSERT INTO twofa_devices ( id, user_id, method, name, verified, phone_number, secret, metadata ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING * `; const values = [ id, data.userId, data.method, data.name || null, data.verified || false, data.phoneNumber || null, data.secret || null, JSON.stringify(data.metadata || {}) ]; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/create_failed", message: "Failed to create two-factor device", severity: "error", retryable: false }); } return this.mapTwoFactorDeviceRecord(result.rows[0]); } async getTwoFactorDevice(deviceId) { const query = "SELECT * FROM twofa_devices WHERE id = $1"; const result = await this.query(query, [deviceId]); if (result.rows.length === 0) { return null; } return this.mapTwoFactorDeviceRecord(result.rows[0]); } async listTwoFactorDevices(userId) { const query = "SELECT * FROM twofa_devices WHERE user_id = $1 ORDER BY created_at DESC"; const result = await this.query(query, [userId]); return result.rows.filter((row) => !!row).map((row) => this.mapTwoFactorDeviceRecord(row)); } async updateTwoFactorDevice(deviceId, data) { const fields = []; const values = []; let paramCount = 1; for (const [key, value] of Object.entries(data)) { if (key === "id" || key === "createdAt") { continue; } const snakeKey = this.camelToSnake(key); if (key === "metadata") { fields.push(`${snakeKey} = $${paramCount}`); values.push(JSON.stringify(value)); } else { fields.push(`${snakeKey} = $${paramCount}`); values.push(value); } paramCount++; } if (fields.length === 0) { const existing = await this.getTwoFactorDevice(deviceId); if (!existing) { throw new import_error.InternalError({ code: "auth-postgres/twofa_device_not_found", message: "Two-factor device not found", severity: "error", retryable: false, context: { deviceId } }); } return existing; } fields.push(`updated_at = $${paramCount}`); values.push(/* @__PURE__ */ new Date()); values.push(deviceId); const query = ` UPDATE twofa_devices SET ${fields.join(", ")} WHERE id = $${paramCount + 1} RETURNING * `; const result = await this.query(query, values); if (!result.rows[0]) { throw new import_error.InternalError({ code: "auth-postgres/update_failed", message: "Failed to update two-factor device", severity: "error", retryable: false }); } return this.mapTwoFactorDeviceRecord(result.rows[0]); } async deleteTwoFactorDevice(deviceId) { const query = "DELETE FROM twofa_devices WHERE id = $1"; await this.query(query, [deviceId]); } async createBackupCodes(userId, codes) { const createdCodes = []; for (const codeData of codes) { const id = (0, import_utils.generateId)(); const query = ` INSERT INTO twofa_backup_codes (id, user_id, code, used) VALUES ($1, $2, $3, $4) RETURNING * `; const codeValue = typeof codeData === "string" ? codeData : codeData.code || ""; const values = [id, userId, codeValue, false]; const result = await this.query(query, values); if (result.rows[0]) { createdCodes.push(this.mapBackupCodeRecord(result.rows[0])); } } return createdCodes; } async getBackupCodes(userId) { const query = "SELECT * FROM twofa_backup_codes WHERE user_id = $1 ORDER BY created_at DESC"; const result = await this.query(query, [userId]); return result.rows.filter((row) => !!row).map((row) => this.mapBackupCodeRecord(row)); } async markBackupCodeUsed(codeId) { const query = ` UPDATE twofa_backup_codes SET used = TRUE, used_at = NOW() WHERE id = $1 `; await this.query(query, [codeId]); } async createTwoFactorSession(data) { const id = (0, import_utils.generateId)(); const query = ` INSERT INTO twofa_sessions ( id, user_id, session_id, device_id, method, verification_code, attempt_count, max_attempts, expires_at ) VA