@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 lines • 98.4 kB
Source Map (JSON)
{"version":3,"sources":["../src/index.ts","../src/postgres.ts"],"sourcesContent":["/**\n * PostgreSQL Storage Adapter for Kitium Auth\n * Production-ready PostgreSQL database adapter\n */\n\nexport { PostgresStorageAdapter } from './postgres';\nexport type { PostgresStorageAdapter as PostgresAdapter } from './postgres';\n","import { Pool, PoolClient, PoolConfig } from 'pg';\r\nimport { getLogger } from '@kitiumai/logger';\r\nimport { InternalError } from '@kitiumai/error';\r\nimport { generateId, generateApiKey, hashApiKey } from '@kitiumai/auth/utils';\r\nimport { setTimeout as delay } from 'timers/promises';\r\nimport type {\r\n StorageAdapter,\r\n ApiKeyRecord,\r\n SessionRecord,\r\n OrganizationRecord,\r\n AuthEvent,\r\n UserRecord,\r\n CreateUserInput,\r\n UpdateUserInput,\r\n OAuthLink,\r\n EmailVerificationToken,\r\n OrganizationMember,\r\n RoleRecord,\r\n TwoFactorDevice,\r\n BackupCode,\r\n TwoFactorSession,\r\n SSOLink,\r\n SSOSession,\r\n} from '@kitiumai/auth';\r\n\r\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\r\ntype DbRecord = Record<string, any>;\r\n\r\ntype QueryOptions = {\r\n operation?: string;\r\n timeoutMs?: number;\r\n retries?: number;\r\n};\r\n\r\ntype PostgresAdapterOptions = PoolConfig & {\r\n statementTimeoutMs?: number;\r\n maxRetries?: number;\r\n};\r\n\r\nexport class PostgresStorageAdapter implements StorageAdapter {\r\n private pool: Pool;\r\n private readonly logger = getLogger();\r\n private readonly defaultQueryTimeoutMs: number;\r\n private readonly defaultRetries: number;\r\n\r\n constructor(connectionString: string, options?: PostgresAdapterOptions) {\r\n const { statementTimeoutMs, maxRetries, ...poolOptions } = options ?? {};\r\n this.pool = new Pool({\r\n connectionString,\r\n ...(poolOptions ?? {}),\r\n });\r\n this.defaultQueryTimeoutMs = statementTimeoutMs ?? 5_000;\r\n this.defaultRetries = maxRetries ?? 2;\r\n }\r\n\r\n async connect(): Promise<void> {\r\n try {\r\n await this.runMigrations();\r\n this.logger.info('PostgreSQL adapter connected successfully');\r\n } catch (error) {\r\n this.logger.error('Failed to connect to PostgreSQL', { error });\r\n throw new InternalError({\r\n code: 'auth-postgres/connection_failed',\r\n message: 'Failed to connect to PostgreSQL',\r\n severity: 'error',\r\n retryable: true,\r\n cause: error,\r\n context: { connectionString: this.maskConnectionString() },\r\n });\r\n }\r\n }\r\n\r\n private maskConnectionString(): string {\r\n // Mask sensitive connection string for logging\r\n return '***';\r\n }\r\n\r\n async disconnect(): Promise<void> {\r\n try {\r\n await this.pool.end();\r\n this.logger.info('PostgreSQL adapter disconnected');\r\n } catch (error) {\r\n this.logger.error('Error disconnecting from PostgreSQL', { error });\r\n throw new InternalError({\r\n code: 'auth-postgres/disconnect_failed',\r\n message: 'Failed to disconnect from PostgreSQL',\r\n severity: 'error',\r\n retryable: false,\r\n cause: error,\r\n });\r\n }\r\n }\r\n\r\n private async withClient<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {\r\n const client = await this.pool.connect();\r\n try {\r\n return await fn(client);\r\n } finally {\r\n client.release();\r\n }\r\n }\r\n\r\n private async query<T extends DbRecord = DbRecord>(\r\n text: string,\r\n values: unknown[] = [],\r\n options?: QueryOptions\r\n ) {\r\n const retries = options?.retries ?? this.defaultRetries;\r\n const timeoutMs = options?.timeoutMs ?? this.defaultQueryTimeoutMs;\r\n let lastError: unknown;\r\n\r\n for (let attempt = 0; attempt <= retries; attempt += 1) {\r\n try {\r\n return await this.withClient(async client => {\r\n const start = Date.now();\r\n await client.query('BEGIN');\r\n\r\n try {\r\n if (timeoutMs) {\r\n await client.query('SET LOCAL statement_timeout = $1', [timeoutMs]);\r\n }\r\n\r\n const result = await client.query<T>(text, values);\r\n await client.query('COMMIT');\r\n\r\n const durationMs = Date.now() - start;\r\n\r\n this.logger.debug('postgres.query', {\r\n operation: options?.operation,\r\n durationMs,\r\n });\r\n\r\n return result;\r\n } catch (error) {\r\n await client.query('ROLLBACK');\r\n throw error;\r\n }\r\n });\r\n } catch (error) {\r\n lastError = error;\r\n const retryable = attempt < retries;\r\n this.logger.warn('PostgreSQL query failed', {\r\n operation: options?.operation,\r\n attempt,\r\n retryable,\r\n error,\r\n });\r\n\r\n if (!retryable) {\r\n throw new InternalError({\r\n code: 'auth-postgres/query_failed',\r\n message: 'Failed to execute PostgreSQL query',\r\n severity: 'error',\r\n retryable: false,\r\n cause: error,\r\n });\r\n }\r\n\r\n await delay(50 * 2 ** attempt);\r\n }\r\n }\r\n\r\n throw lastError;\r\n }\r\n\r\n async healthCheck(): Promise<{ status: 'ok' | 'error'; latencyMs: number }> {\r\n const start = Date.now();\r\n try {\r\n await this.query('SELECT 1', [], { operation: 'health_check', retries: 0 });\r\n return { status: 'ok', latencyMs: Date.now() - start };\r\n } catch (error) {\r\n this.logger.error('PostgreSQL health check failed', { error });\r\n return { status: 'error', latencyMs: Date.now() - start };\r\n }\r\n }\r\n\r\n private async runMigrations(): Promise<void> {\r\n await this.query(\r\n `CREATE TABLE IF NOT EXISTS auth_migrations (\r\n id VARCHAR(255) PRIMARY KEY,\r\n applied_at TIMESTAMP NOT NULL DEFAULT NOW()\r\n )`\r\n );\r\n\r\n const migrationId = '0001_initial_schema_v2';\r\n const existing = await this.query('SELECT id FROM auth_migrations WHERE id = $1', [migrationId]);\r\n\r\n if (existing.rows.length > 0) {\r\n return;\r\n }\r\n\r\n const createTables = `\r\n -- Users table\r\n CREATE TABLE IF NOT EXISTS users (\r\n id VARCHAR(255) PRIMARY KEY,\r\n email VARCHAR(255) UNIQUE,\r\n name VARCHAR(255),\r\n picture VARCHAR(1024),\r\n plan VARCHAR(50) DEFAULT 'free',\r\n entitlements TEXT[] NOT NULL DEFAULT '{}',\r\n oauth JSONB DEFAULT '{}',\r\n metadata JSONB DEFAULT '{}',\r\n created_at TIMESTAMP DEFAULT NOW(),\r\n updated_at TIMESTAMP DEFAULT NOW()\r\n );\r\n\r\n -- Organizations table\r\n CREATE TABLE IF NOT EXISTS organizations (\r\n id VARCHAR(255) PRIMARY KEY,\r\n name VARCHAR(255) NOT NULL,\r\n plan VARCHAR(50) NOT NULL,\r\n seats INTEGER NOT NULL DEFAULT 1,\r\n members JSONB NOT NULL DEFAULT '[]',\r\n metadata JSONB DEFAULT '{}',\r\n created_at TIMESTAMP DEFAULT NOW(),\r\n updated_at TIMESTAMP DEFAULT NOW()\r\n );\r\n\r\n -- API Keys table\r\n CREATE TABLE IF NOT EXISTS api_keys (\r\n id VARCHAR(255) PRIMARY KEY,\r\n principal_id VARCHAR(255) NOT NULL,\r\n hash VARCHAR(255) NOT NULL UNIQUE,\r\n prefix VARCHAR(50) NOT NULL,\r\n last_four VARCHAR(4) NOT NULL,\r\n scopes TEXT[] NOT NULL DEFAULT '{}',\r\n metadata JSONB DEFAULT '{}',\r\n expires_at TIMESTAMP,\r\n created_at TIMESTAMP DEFAULT NOW(),\r\n updated_at TIMESTAMP DEFAULT NOW(),\r\n CONSTRAINT fk_api_keys_principal_user FOREIGN KEY (principal_id) REFERENCES users(id) ON DELETE CASCADE\r\n );\r\n\r\n -- Sessions table\r\n CREATE TABLE IF NOT EXISTS sessions (\r\n id VARCHAR(255) PRIMARY KEY,\r\n user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE,\r\n org_id VARCHAR(255),\r\n plan VARCHAR(50),\r\n entitlements TEXT[] NOT NULL DEFAULT '{}',\r\n expires_at TIMESTAMP NOT NULL,\r\n metadata JSONB DEFAULT '{}',\r\n created_at TIMESTAMP DEFAULT NOW(),\r\n updated_at TIMESTAMP DEFAULT NOW(),\r\n CONSTRAINT fk_sessions_org FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE SET NULL\r\n );\r\n\r\n -- Email Verification Tokens table\r\n CREATE TABLE IF NOT EXISTS email_verification_tokens (\r\n id VARCHAR(255) PRIMARY KEY,\r\n email VARCHAR(255) NOT NULL,\r\n code VARCHAR(255) NOT NULL,\r\n code_hash VARCHAR(255) NOT NULL,\r\n type VARCHAR(50) NOT NULL,\r\n user_id VARCHAR(255),\r\n metadata JSONB DEFAULT '{}',\r\n expires_at TIMESTAMP NOT NULL,\r\n used_at TIMESTAMP,\r\n created_at TIMESTAMP DEFAULT NOW(),\r\n CONSTRAINT fk_email_verification_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL\r\n );\r\n\r\n -- Email Verification Token Attempts table\r\n CREATE TABLE IF NOT EXISTS email_verification_token_attempts (\r\n token_id VARCHAR(255) PRIMARY KEY REFERENCES email_verification_tokens(id) ON DELETE CASCADE,\r\n attempts INTEGER DEFAULT 0\r\n );\r\n\r\n -- Events table\r\n CREATE TABLE IF NOT EXISTS auth_events (\r\n id SERIAL PRIMARY KEY,\r\n type VARCHAR(100) NOT NULL,\r\n principal_id VARCHAR(255) NOT NULL,\r\n org_id VARCHAR(255),\r\n data JSONB NOT NULL DEFAULT '{}',\r\n timestamp TIMESTAMP DEFAULT NOW()\r\n );\r\n\r\n -- Roles table\r\n CREATE TABLE IF NOT EXISTS roles (\r\n id VARCHAR(255) PRIMARY KEY,\r\n org_id VARCHAR(255) NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,\r\n name VARCHAR(255) NOT NULL,\r\n description TEXT,\r\n is_system BOOLEAN DEFAULT FALSE,\r\n permissions JSONB NOT NULL DEFAULT '[]',\r\n metadata JSONB DEFAULT '{}',\r\n created_at TIMESTAMP DEFAULT NOW(),\r\n updated_at TIMESTAMP DEFAULT NOW()\r\n );\r\n\r\n -- User Roles table\r\n CREATE TABLE IF NOT EXISTS user_roles (\r\n id VARCHAR(255) PRIMARY KEY,\r\n user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE,\r\n role_id VARCHAR(255) NOT NULL REFERENCES roles(id) ON DELETE CASCADE,\r\n org_id VARCHAR(255) NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,\r\n assigned_at TIMESTAMP DEFAULT NOW()\r\n );\r\n\r\n -- SSO Providers table\r\n CREATE TABLE IF NOT EXISTS sso_providers (\r\n id VARCHAR(255) PRIMARY KEY,\r\n type VARCHAR(50) NOT NULL,\r\n name VARCHAR(255) NOT NULL,\r\n org_id VARCHAR(255),\r\n metadata_url TEXT,\r\n client_id VARCHAR(255),\r\n client_secret TEXT,\r\n token_endpoint_auth_method VARCHAR(50),\r\n idp_entity_id TEXT,\r\n idp_sso_url TEXT,\r\n idp_slo_url TEXT,\r\n idp_certificate TEXT,\r\n sp_entity_id TEXT,\r\n sp_acs_url TEXT,\r\n sp_slo_url TEXT,\r\n signing_cert TEXT,\r\n signing_key TEXT,\r\n encryption_enabled BOOLEAN DEFAULT FALSE,\r\n force_authn BOOLEAN DEFAULT FALSE,\r\n scopes TEXT[] DEFAULT '{}',\r\n redirect_uris TEXT[] DEFAULT '{}',\r\n claim_mapping JSONB DEFAULT '{}',\r\n attribute_mapping JSONB DEFAULT '{}',\r\n metadata JSONB DEFAULT '{}',\r\n created_at TIMESTAMP DEFAULT NOW(),\r\n updated_at TIMESTAMP DEFAULT NOW(),\r\n CONSTRAINT fk_sso_providers_org FOREIGN KEY (org_id) REFERENCES organizations(id) ON DELETE SET NULL\r\n );\r\n\r\n -- SSO Links table\r\n CREATE TABLE IF NOT EXISTS sso_links (\r\n id VARCHAR(255) PRIMARY KEY,\r\n user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE,\r\n provider_id VARCHAR(255) NOT NULL REFERENCES sso_providers(id) ON DELETE CASCADE,\r\n provider_type VARCHAR(50) NOT NULL,\r\n provider_subject VARCHAR(255) NOT NULL,\r\n provider_email VARCHAR(255),\r\n auto_provisioned BOOLEAN DEFAULT FALSE,\r\n metadata JSONB DEFAULT '{}',\r\n linked_at TIMESTAMP DEFAULT NOW(),\r\n last_auth_at TIMESTAMP\r\n );\r\n\r\n -- SSO Sessions table\r\n CREATE TABLE IF NOT EXISTS sso_sessions (\r\n id VARCHAR(255) PRIMARY KEY,\r\n user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE,\r\n provider_id VARCHAR(255) NOT NULL REFERENCES sso_providers(id) ON DELETE CASCADE,\r\n provider_type VARCHAR(50) NOT NULL,\r\n provider_subject VARCHAR(255) NOT NULL,\r\n session_token TEXT,\r\n expires_at TIMESTAMP NOT NULL,\r\n linked_at TIMESTAMP DEFAULT NOW(),\r\n last_auth_at TIMESTAMP\r\n );\r\n\r\n -- 2FA Devices table\r\n CREATE TABLE IF NOT EXISTS twofa_devices (\r\n id VARCHAR(255) PRIMARY KEY,\r\n user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE,\r\n method VARCHAR(50) NOT NULL,\r\n name VARCHAR(255),\r\n verified BOOLEAN DEFAULT FALSE,\r\n phone_number VARCHAR(50),\r\n secret TEXT,\r\n last_used_at TIMESTAMP,\r\n metadata JSONB DEFAULT '{}',\r\n created_at TIMESTAMP DEFAULT NOW(),\r\n updated_at TIMESTAMP DEFAULT NOW()\r\n );\r\n\r\n -- 2FA Backup Codes table\r\n CREATE TABLE IF NOT EXISTS twofa_backup_codes (\r\n id VARCHAR(255) PRIMARY KEY,\r\n user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE,\r\n code VARCHAR(255) NOT NULL,\r\n used BOOLEAN DEFAULT FALSE,\r\n used_at TIMESTAMP,\r\n created_at TIMESTAMP DEFAULT NOW()\r\n );\r\n\r\n -- 2FA Sessions table\r\n CREATE TABLE IF NOT EXISTS twofa_sessions (\r\n id VARCHAR(255) PRIMARY KEY,\r\n user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE,\r\n session_id VARCHAR(255) NOT NULL,\r\n device_id VARCHAR(255) NOT NULL REFERENCES twofa_devices(id) ON DELETE CASCADE,\r\n method VARCHAR(50) NOT NULL,\r\n verification_code VARCHAR(10),\r\n attempt_count INTEGER DEFAULT 0,\r\n max_attempts INTEGER DEFAULT 5,\r\n expires_at TIMESTAMP NOT NULL,\r\n completed_at TIMESTAMP,\r\n created_at TIMESTAMP DEFAULT NOW()\r\n );\r\n\r\n -- Indexes\r\n CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);\r\n CREATE INDEX IF NOT EXISTS idx_api_keys_principal_id ON api_keys(principal_id);\r\n CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(hash);\r\n CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);\r\n CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);\r\n CREATE INDEX IF NOT EXISTS idx_organizations_plan ON organizations(plan);\r\n CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_email ON email_verification_tokens(email);\r\n CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_type ON email_verification_tokens(type);\r\n CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens(expires_at);\r\n CREATE INDEX IF NOT EXISTS idx_auth_events_principal_id ON auth_events(principal_id);\r\n CREATE INDEX IF NOT EXISTS idx_auth_events_type ON auth_events(type);\r\n CREATE INDEX IF NOT EXISTS idx_auth_events_timestamp ON auth_events(timestamp);\r\n CREATE INDEX IF NOT EXISTS idx_roles_org_id ON roles(org_id);\r\n CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);\r\n CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id);\r\n CREATE INDEX IF NOT EXISTS idx_user_roles_org_id ON user_roles(org_id);\r\n CREATE INDEX IF NOT EXISTS idx_sso_providers_org_id ON sso_providers(org_id);\r\n CREATE INDEX IF NOT EXISTS idx_sso_links_user_id ON sso_links(user_id);\r\n CREATE INDEX IF NOT EXISTS idx_sso_links_provider_id ON sso_links(provider_id);\r\n CREATE INDEX IF NOT EXISTS idx_sso_sessions_user_id ON sso_sessions(user_id);\r\n CREATE INDEX IF NOT EXISTS idx_twofa_devices_user_id ON twofa_devices(user_id);\r\n CREATE INDEX IF NOT EXISTS idx_twofa_backup_codes_user_id ON twofa_backup_codes(user_id);\r\n CREATE INDEX IF NOT EXISTS idx_twofa_sessions_user_id ON twofa_sessions(user_id);\r\n CREATE INDEX IF NOT EXISTS idx_twofa_sessions_session_id ON twofa_sessions(session_id);\r\n\r\n -- Updated at trigger\r\n CREATE OR REPLACE FUNCTION set_updated_at()\r\n RETURNS TRIGGER AS $$\r\n BEGIN\r\n NEW.updated_at = NOW();\r\n RETURN NEW;\r\n END;\r\n $$ LANGUAGE plpgsql;\r\n\r\n DO $$\r\n DECLARE\r\n tbl TEXT;\r\n BEGIN\r\n FOR tbl IN SELECT UNNEST(ARRAY['users','api_keys','sessions','organizations','roles','twofa_devices']) LOOP\r\n EXECUTE format('CREATE TRIGGER %I_set_updated_at BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION set_updated_at();', tbl, tbl);\r\n END LOOP;\r\n END;\r\n $$;\r\n `;\r\n\r\n await this.query(createTables, [], { operation: 'migration:initial', timeoutMs: 30_000, retries: 0 });\r\n await this.query('INSERT INTO auth_migrations (id) VALUES ($1)', [migrationId]);\r\n }\r\n\r\n private mapEmailVerificationToken(row: Record<string, unknown>): EmailVerificationToken {\r\n return {\r\n id: String(row.id),\r\n email: String(row.email),\r\n code: String(row.code),\r\n codeHash: String(row.code_hash),\r\n type: String(row.type) as EmailVerificationToken['type'],\r\n userId: (row.user_id as string | null) ?? undefined,\r\n metadata: (row.metadata as Record<string, unknown>) || {},\r\n expiresAt: new Date(row.expires_at as string),\r\n createdAt: new Date(row.created_at as string),\r\n usedAt: row.used_at ? new Date(row.used_at as string) : undefined,\r\n };\r\n }\r\n\r\n /**\r\n * Create an API key with plaintext secret (convenience method)\r\n * @param principalId - Principal ID for the key\r\n * @param scopes - Scopes for the key\r\n * @param prefix - Optional prefix (default: 'api')\r\n * @returns Object with the record and plaintext key\r\n */\r\n async createApiKeyWithSecret(\r\n principalId: string,\r\n scopes: string[],\r\n prefix: string = 'api'\r\n ): Promise<{ record: ApiKeyRecord; key: string }> {\r\n const key = generateApiKey(prefix);\r\n const hash = hashApiKey(key);\r\n const parts = key.split('_');\r\n const lastFour = parts[parts.length - 1]!.slice(-4);\r\n\r\n const record = await this.createApiKey({\r\n principalId,\r\n hash,\r\n prefix,\r\n lastFour,\r\n scopes,\r\n });\r\n\r\n return { record, key };\r\n }\r\n\r\n // API Key methods\r\n async createApiKey(\r\n data: Omit<ApiKeyRecord, 'id' | 'createdAt' | 'updatedAt'>\r\n ): Promise<ApiKeyRecord> {\r\n const query = `\r\n INSERT INTO api_keys (id, principal_id, hash, prefix, last_four, scopes, metadata, expires_at, updated_at)\r\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\r\n RETURNING *\r\n `;\r\n\r\n const values = [\r\n generateId(),\r\n data['principalId'],\r\n data['hash'],\r\n data['prefix'],\r\n data['lastFour'],\r\n data['scopes'],\r\n JSON.stringify(data['metadata'] || {}),\r\n data['expiresAt'] || null,\r\n new Date(),\r\n ];\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/create_failed',\r\n message: 'Failed to create API key',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapApiKeyRecord(result.rows[0]!);\r\n }\r\n\r\n async getApiKey(id: string): Promise<ApiKeyRecord | null> {\r\n const query = 'SELECT * FROM api_keys WHERE id = $1';\r\n const result = await this.query(query, [id]);\r\n\r\n if (result.rows.length === 0) {\r\n return null;\r\n }\r\n\r\n return this.mapApiKeyRecord(result.rows[0]!);\r\n }\r\n\r\n async getApiKeyByHash(hash: string): Promise<ApiKeyRecord | null> {\r\n const query = 'SELECT * FROM api_keys WHERE hash = $1';\r\n const result = await this.query(query, [hash]);\r\n\r\n if (result.rows.length === 0) {\r\n return null;\r\n }\r\n\r\n return this.mapApiKeyRecord(result.rows[0]!);\r\n }\r\n\r\n async getApiKeysByPrefixAndLastFour(prefix: string, lastFour: string): Promise<ApiKeyRecord[]> {\r\n const query = 'SELECT * FROM api_keys WHERE prefix = $1 AND last_four = $2';\r\n const result = await this.query(query, [prefix, lastFour]);\r\n\r\n return result.rows.filter((row): row is DbRecord => !!row).map((row) => this.mapApiKeyRecord(row));\r\n }\r\n\r\n async updateApiKey(id: string, data: Partial<ApiKeyRecord>): Promise<ApiKeyRecord> {\r\n const fields = [];\r\n const values = [];\r\n let paramCount = 1;\r\n\r\n for (const [key, value] of Object.entries(data)) {\r\n if (key === 'id' || key === 'createdAt') {\r\n continue;\r\n }\r\n\r\n if (key === 'metadata') {\r\n fields.push(`metadata = $${paramCount}`);\r\n values.push(JSON.stringify(value));\r\n } else if (key === 'principalId') {\r\n fields.push(`principal_id = $${paramCount}`);\r\n values.push(value);\r\n } else if (key === 'lastFour') {\r\n fields.push(`last_four = $${paramCount}`);\r\n values.push(value);\r\n } else if (key === 'expiresAt') {\r\n fields.push(`expires_at = $${paramCount}`);\r\n values.push(value || null);\r\n } else {\r\n const snakeKey = this.camelToSnake(key);\r\n fields.push(`${snakeKey} = $${paramCount}`);\r\n values.push(value);\r\n }\r\n paramCount++;\r\n }\r\n\r\n if (fields.length === 0) {\r\n throw new InternalError({\r\n code: 'auth-postgres/no_fields_to_update',\r\n message: 'No fields to update',\r\n severity: 'error',\r\n retryable: false,\r\n });\r\n }\r\n\r\n fields.push(`updated_at = $${paramCount}`);\r\n values.push(new Date());\r\n values.push(id);\r\n\r\n const query = `\r\n UPDATE api_keys \r\n SET ${fields.join(', ')} \r\n WHERE id = $${paramCount + 1}\r\n RETURNING *\r\n `;\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/update_failed',\r\n message: 'Failed to update API key',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapApiKeyRecord(result.rows[0]!);\r\n }\r\n\r\n async deleteApiKey(id: string): Promise<void> {\r\n const query = 'DELETE FROM api_keys WHERE id = $1';\r\n await this.query(query, [id]);\r\n }\r\n\r\n async listApiKeys(principalId: string): Promise<ApiKeyRecord[]> {\r\n const query = 'SELECT * FROM api_keys WHERE principal_id = $1 ORDER BY created_at DESC';\r\n const result = await this.query(query, [principalId]);\r\n\r\n return result.rows.filter((row): row is DbRecord => !!row).map((row) => this.mapApiKeyRecord(row));\r\n }\r\n\r\n // Session methods\r\n async createSession(data: Omit<SessionRecord, 'id' | 'createdAt'>): Promise<SessionRecord> {\r\n const query = `\r\n INSERT INTO sessions (id, user_id, org_id, plan, entitlements, expires_at, metadata, updated_at)\r\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\r\n RETURNING *\r\n `;\r\n\r\n const values = [\r\n generateId(),\r\n data.userId,\r\n data.orgId || null,\r\n data.plan || null,\r\n data.entitlements || [],\r\n data.expiresAt,\r\n JSON.stringify(data.metadata || {}),\r\n new Date(),\r\n ];\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/create_failed',\r\n message: 'Failed to create session',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapSessionRecord(result.rows[0]!);\r\n }\r\n\r\n async getSession(id: string): Promise<SessionRecord | null> {\r\n const query = 'SELECT * FROM sessions WHERE id = $1';\r\n const result = await this.query(query, [id]);\r\n\r\n if (result.rows.length === 0) {\r\n return null;\r\n }\r\n\r\n return this.mapSessionRecord(result.rows[0]!);\r\n }\r\n\r\n async updateSession(id: string, data: Partial<SessionRecord>): Promise<SessionRecord> {\r\n const fields = [];\r\n const values = [];\r\n let paramCount = 1;\r\n\r\n for (const [key, value] of Object.entries(data)) {\r\n if (key === 'id' || key === 'createdAt') {\r\n continue;\r\n }\r\n\r\n if (key === 'metadata') {\r\n fields.push(`metadata = $${paramCount}`);\r\n values.push(JSON.stringify(value));\r\n } else if (key === 'userId') {\r\n fields.push(`user_id = $${paramCount}`);\r\n values.push(value);\r\n } else if (key === 'orgId') {\r\n fields.push(`org_id = $${paramCount}`);\r\n values.push(value || null);\r\n } else if (key === 'expiresAt') {\r\n fields.push(`expires_at = $${paramCount}`);\r\n values.push(value);\r\n } else {\r\n const snakeKey = this.camelToSnake(key);\r\n fields.push(`${snakeKey} = $${paramCount}`);\r\n values.push(value);\r\n }\r\n paramCount++;\r\n }\r\n\r\n if (fields.length === 0) {\r\n throw new InternalError({\r\n code: 'auth-postgres/no_fields_to_update',\r\n message: 'No fields to update',\r\n severity: 'error',\r\n retryable: false,\r\n });\r\n }\r\n\r\n fields.push(`updated_at = $${paramCount}`);\r\n values.push(new Date());\r\n values.push(id);\r\n\r\n const query = `\r\n UPDATE sessions \r\n SET ${fields.join(', ')} \r\n WHERE id = $${paramCount + 1}\r\n RETURNING *\r\n `;\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/update_failed',\r\n message: 'Failed to update session',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapSessionRecord(result.rows[0]!);\r\n }\r\n\r\n async deleteSession(id: string): Promise<void> {\r\n const query = 'DELETE FROM sessions WHERE id = $1';\r\n await this.query(query, [id]);\r\n }\r\n\r\n // Organization methods\r\n async createOrganization(\r\n data: Omit<OrganizationRecord, 'id' | 'createdAt' | 'updatedAt'>\r\n ): Promise<OrganizationRecord> {\r\n const query = `\r\n INSERT INTO organizations (id, name, plan, seats, members, metadata)\r\n VALUES ($1, $2, $3, $4, $5, $6)\r\n RETURNING *\r\n `;\r\n\r\n const values = [\r\n generateId(),\r\n data.name,\r\n data.plan,\r\n data.seats,\r\n JSON.stringify(data.members),\r\n JSON.stringify(data.metadata || {}),\r\n ];\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/create_failed',\r\n message: 'Failed to create organization',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapOrganizationRecord(result.rows[0]!);\r\n }\r\n\r\n async getOrganization(id: string): Promise<OrganizationRecord | null> {\r\n const query = 'SELECT * FROM organizations WHERE id = $1';\r\n const result = await this.query(query, [id]);\r\n\r\n if (result.rows.length === 0) {\r\n return null;\r\n }\r\n\r\n return this.mapOrganizationRecord(result.rows[0]!);\r\n }\r\n\r\n async updateOrganization(\r\n id: string,\r\n data: Partial<OrganizationRecord>\r\n ): Promise<OrganizationRecord> {\r\n const fields = [];\r\n const values = [];\r\n let paramCount = 1;\r\n\r\n for (const [key, value] of Object.entries(data)) {\r\n if (key === 'id' || key === 'createdAt') {\r\n continue;\r\n }\r\n\r\n if (key === 'members' || key === 'metadata') {\r\n fields.push(`${key} = $${paramCount}`);\r\n values.push(JSON.stringify(value));\r\n } else {\r\n fields.push(`${key} = $${paramCount}`);\r\n values.push(value);\r\n }\r\n paramCount++;\r\n }\r\n\r\n if (fields.length === 0) {\r\n throw new InternalError({\r\n code: 'auth-postgres/no_fields_to_update',\r\n message: 'No fields to update',\r\n severity: 'error',\r\n retryable: false,\r\n });\r\n }\r\n\r\n fields.push(`updated_at = $${paramCount}`);\r\n values.push(new Date());\r\n values.push(id);\r\n\r\n const query = `\r\n UPDATE organizations \r\n SET ${fields.join(', ')} \r\n WHERE id = $${paramCount + 1}\r\n RETURNING *\r\n `;\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/update_failed',\r\n message: 'Failed to update organization',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapOrganizationRecord(result.rows[0]!);\r\n }\r\n\r\n async deleteOrganization(id: string): Promise<void> {\r\n const query = 'DELETE FROM organizations WHERE id = $1';\r\n await this.query(query, [id]);\r\n }\r\n\r\n // User methods\r\n async createUser(data: CreateUserInput): Promise<UserRecord> {\r\n const id = generateId();\r\n\r\n const query = `\r\n INSERT INTO users (id, email, name, picture, plan, entitlements, oauth, metadata)\r\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\r\n RETURNING *\r\n `;\r\n\r\n const values = [\r\n id,\r\n data.email || null,\r\n data.name || null,\r\n data.picture || null,\r\n data.plan || 'free',\r\n data.entitlements || [],\r\n JSON.stringify({}),\r\n JSON.stringify(data.metadata || {}),\r\n ];\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/create_failed',\r\n message: 'Failed to create user',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapUserRecord(result.rows[0]!);\r\n }\r\n\r\n async getUser(id: string): Promise<UserRecord | null> {\r\n const query = 'SELECT * FROM users WHERE id = $1';\r\n const result = await this.query(query, [id]);\r\n\r\n if (result.rows.length === 0) {\r\n return null;\r\n }\r\n\r\n return this.mapUserRecord(result.rows[0]!);\r\n }\r\n\r\n async getUserByEmail(email: string): Promise<UserRecord | null> {\r\n const query = 'SELECT * FROM users WHERE email = $1';\r\n const result = await this.query(query, [email]);\r\n\r\n if (result.rows.length === 0) {\r\n return null;\r\n }\r\n\r\n return this.mapUserRecord(result.rows[0]!);\r\n }\r\n\r\n async getUserByOAuth(provider: string, sub: string): Promise<UserRecord | null> {\r\n const query = `\r\n SELECT * FROM users\r\n WHERE oauth->>$1 IS NOT NULL\r\n AND oauth->$1->>'sub' = $2\r\n `;\r\n const result = await this.query(query, [provider, sub]);\r\n\r\n if (result.rows.length === 0) {\r\n return null;\r\n }\r\n\r\n return this.mapUserRecord(result.rows[0]!);\r\n }\r\n\r\n async updateUser(id: string, data: UpdateUserInput): Promise<UserRecord> {\r\n const fields = [];\r\n const values = [];\r\n let paramCount = 1;\r\n\r\n for (const [key, value] of Object.entries(data)) {\r\n if (key === 'metadata') {\r\n fields.push(`${this.camelToSnake(key)} = $${paramCount}`);\r\n values.push(JSON.stringify(value));\r\n } else if (key === 'entitlements') {\r\n fields.push(`${this.camelToSnake(key)} = $${paramCount}`);\r\n values.push(value);\r\n } else {\r\n fields.push(`${this.camelToSnake(key)} = $${paramCount}`);\r\n values.push(value);\r\n }\r\n paramCount++;\r\n }\r\n\r\n if (fields.length === 0) {\r\n return this.getUser(id) as Promise<UserRecord>;\r\n }\r\n\r\n fields.push(`updated_at = $${paramCount}`);\r\n values.push(new Date());\r\n values.push(id);\r\n\r\n const query = `\r\n UPDATE users\r\n SET ${fields.join(', ')}\r\n WHERE id = $${paramCount + 1}\r\n RETURNING *\r\n `;\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/update_failed',\r\n message: 'Failed to update user',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapUserRecord(result.rows[0]!);\r\n }\r\n\r\n async deleteUser(id: string): Promise<void> {\r\n const query = 'DELETE FROM users WHERE id = $1';\r\n await this.query(query, [id]);\r\n }\r\n\r\n async linkOAuthAccount(\r\n userId: string,\r\n provider: string,\r\n oauthLink: OAuthLink\r\n ): Promise<UserRecord> {\r\n const query = `\r\n UPDATE users\r\n SET oauth = jsonb_set(oauth, $2, $3),\r\n updated_at = NOW()\r\n WHERE id = $1\r\n RETURNING *\r\n `;\r\n\r\n const oauthData = {\r\n provider: oauthLink.provider,\r\n sub: oauthLink.sub,\r\n email: oauthLink.email,\r\n name: oauthLink.name,\r\n linkedAt: oauthLink.linkedAt.toISOString(),\r\n };\r\n\r\n const values = [userId, `{${provider}}`, JSON.stringify(oauthData)];\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/link_failed',\r\n message: 'Failed to link OAuth account',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapUserRecord(result.rows[0]!);\r\n }\r\n\r\n // Email Verification Token methods\r\n async createEmailVerificationToken(\r\n data: Omit<EmailVerificationToken, 'id'>\r\n ): Promise<EmailVerificationToken> {\r\n const id = generateId();\r\n\r\n const query = `\r\n INSERT INTO email_verification_tokens (id, email, code, code_hash, type, user_id, metadata, expires_at)\r\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\r\n RETURNING *\r\n `;\r\n\r\n const values = [\r\n id,\r\n data.email,\r\n data.code,\r\n data.codeHash,\r\n data.type,\r\n data.userId || null,\r\n JSON.stringify(data.metadata || {}),\r\n data.expiresAt,\r\n ];\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/create_failed',\r\n message: 'Failed to create email verification token',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapEmailVerificationToken(result.rows[0]!);\r\n }\r\n\r\n async getEmailVerificationTokens(\r\n email: string,\r\n type?: string\r\n ): Promise<EmailVerificationToken[]> {\r\n let query = 'SELECT * FROM email_verification_tokens WHERE email = $1';\r\n const values: (string | undefined)[] = [email];\r\n\r\n if (type) {\r\n query += ' AND type = $2';\r\n values.push(type);\r\n }\r\n\r\n query += ' ORDER BY created_at DESC';\r\n\r\n const result = await this.query(query, values);\r\n return result.rows.filter((row): row is DbRecord => !!row).map((row) => this.mapEmailVerificationToken(row));\r\n }\r\n\r\n async getEmailVerificationTokenById(id: string): Promise<EmailVerificationToken | null> {\r\n const query = 'SELECT * FROM email_verification_tokens WHERE id = $1';\r\n const result = await this.query(query, [id]);\r\n\r\n if (result.rows.length === 0) {\r\n return null;\r\n }\r\n\r\n return this.mapEmailVerificationToken(result.rows[0]!);\r\n }\r\n\r\n async markEmailVerificationTokenAsUsed(id: string): Promise<EmailVerificationToken> {\r\n const query = `\r\n UPDATE email_verification_tokens\r\n SET used_at = NOW()\r\n WHERE id = $1\r\n RETURNING *\r\n `;\r\n\r\n const result = await this.query(query, [id]);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/update_failed',\r\n message: 'Failed to mark email verification token as used',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapEmailVerificationToken(result.rows[0]!);\r\n }\r\n\r\n async deleteExpiredEmailVerificationTokens(): Promise<number> {\r\n const query = 'DELETE FROM email_verification_tokens WHERE expires_at < NOW()';\r\n const result = await this.query(query);\r\n return result.rowCount || 0;\r\n }\r\n\r\n async getEmailVerificationTokenAttempts(tokenId: string): Promise<number> {\r\n const query = 'SELECT attempts FROM email_verification_token_attempts WHERE token_id = $1';\r\n const result = await this.query(query, [tokenId]);\r\n\r\n if (result.rows.length === 0) {\r\n return 0;\r\n }\r\n\r\n return result.rows[0]!.attempts;\r\n }\r\n\r\n async incrementEmailVerificationTokenAttempts(tokenId: string): Promise<number> {\r\n const query = `\r\n INSERT INTO email_verification_token_attempts (token_id, attempts)\r\n VALUES ($1, 1)\r\n ON CONFLICT (token_id)\r\n DO UPDATE SET attempts = attempts + 1\r\n RETURNING attempts\r\n `;\r\n\r\n const result = await this.query(query, [tokenId]);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/update_failed',\r\n message: 'Failed to increment token attempts',\r\n severity: 'error',\r\n retryable: false,\r\n });\r\n }\r\n return result.rows[0]!.attempts;\r\n }\r\n\r\n // Event methods\r\n async emitEvent(event: AuthEvent): Promise<void> {\r\n const query = `\r\n INSERT INTO auth_events (type, principal_id, org_id, data, timestamp)\r\n VALUES ($1, $2, $3, $4, $5)\r\n `;\r\n\r\n const values = [\r\n event.type,\r\n event.principalId,\r\n event.orgId,\r\n JSON.stringify(event.data),\r\n event.timestamp,\r\n ];\r\n\r\n await this.query(query, values);\r\n }\r\n\r\n // RBAC methods\r\n async createRole(data: Omit<RoleRecord, 'id' | 'createdAt' | 'updatedAt'>): Promise<RoleRecord> {\r\n const id = generateId();\r\n\r\n const query = `\r\n INSERT INTO roles (id, org_id, name, description, is_system, permissions, metadata)\r\n VALUES ($1, $2, $3, $4, $5, $6, $7)\r\n RETURNING *\r\n `;\r\n\r\n const values = [\r\n id,\r\n data.orgId,\r\n data.name,\r\n data.description || null,\r\n data.isSystem || false,\r\n JSON.stringify(data.permissions || []),\r\n JSON.stringify(data.metadata || {}),\r\n ];\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/create_failed',\r\n message: 'Failed to create role',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapRoleRecord(result.rows[0]!);\r\n }\r\n\r\n async getRole(roleId: string): Promise<RoleRecord | null> {\r\n const query = 'SELECT * FROM roles WHERE id = $1';\r\n const result = await this.query(query, [roleId]);\r\n\r\n if (result.rows.length === 0) {\r\n return null;\r\n }\r\n\r\n return this.mapRoleRecord(result.rows[0]!);\r\n }\r\n\r\n async updateRole(roleId: string, data: Partial<RoleRecord>): Promise<RoleRecord> {\r\n const fields = [];\r\n const values = [];\r\n let paramCount = 1;\r\n\r\n for (const [key, value] of Object.entries(data)) {\r\n if (key === 'id' || key === 'createdAt') {\r\n continue;\r\n }\r\n\r\n const snakeKey = this.camelToSnake(key);\r\n if (key === 'permissions' || key === 'metadata') {\r\n fields.push(`${snakeKey} = $${paramCount}`);\r\n values.push(JSON.stringify(value));\r\n } else {\r\n fields.push(`${snakeKey} = $${paramCount}`);\r\n values.push(value);\r\n }\r\n paramCount++;\r\n }\r\n\r\n if (fields.length === 0) {\r\n const existing = await this.getRole(roleId);\r\n if (!existing) {\r\n throw new InternalError({\r\n code: 'auth-postgres/role_not_found',\r\n message: 'Role not found',\r\n severity: 'error',\r\n retryable: false,\r\n context: { roleId },\r\n });\r\n }\r\n return existing;\r\n }\r\n\r\n fields.push(`updated_at = $${paramCount}`);\r\n values.push(new Date());\r\n values.push(roleId);\r\n\r\n const query = `\r\n UPDATE roles\r\n SET ${fields.join(', ')}\r\n WHERE id = $${paramCount + 1}\r\n RETURNING *\r\n `;\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/update_failed',\r\n message: 'Failed to update role',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapRoleRecord(result.rows[0]!);\r\n }\r\n\r\n async deleteRole(roleId: string): Promise<void> {\r\n const query = 'DELETE FROM roles WHERE id = $1';\r\n await this.query(query, [roleId]);\r\n }\r\n\r\n async listRoles(orgId: string): Promise<RoleRecord[]> {\r\n const query = 'SELECT * FROM roles WHERE org_id = $1 ORDER BY created_at DESC';\r\n const result = await this.query(query, [orgId]);\r\n\r\n return result.rows.map((row) => this.mapRoleRecord(row));\r\n }\r\n\r\n async assignRoleToUser(userId: string, roleId: string, orgId: string): Promise<RoleRecord> {\r\n const id = generateId();\r\n\r\n const query = `\r\n INSERT INTO user_roles (id, user_id, role_id, org_id)\r\n VALUES ($1, $2, $3, $4)\r\n RETURNING *\r\n `;\r\n\r\n const values = [id, userId, roleId, orgId];\r\n await this.query(query, values);\r\n\r\n const role = await this.getRole(roleId);\r\n if (!role) {\r\n throw new InternalError({\r\n code: 'auth-postgres/role_not_found',\r\n message: 'Role not found',\r\n severity: 'error',\r\n retryable: false,\r\n context: { roleId },\r\n });\r\n }\r\n return role;\r\n }\r\n\r\n async revokeRoleFromUser(userId: string, roleId: string, orgId: string): Promise<void> {\r\n const query = 'DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2 AND org_id = $3';\r\n await this.query(query, [userId, roleId, orgId]);\r\n }\r\n\r\n async getUserRoles(userId: string, orgId: string): Promise<RoleRecord[]> {\r\n const query = `\r\n SELECT r.*\r\n FROM roles r\r\n INNER JOIN user_roles ur ON r.id = ur.role_id\r\n WHERE ur.user_id = $1 AND ur.org_id = $2\r\n ORDER BY r.created_at DESC\r\n `;\r\n const result = await this.query(query, [userId, orgId]);\r\n\r\n return result.rows.filter((row): row is DbRecord => !!row).map((row) => this.mapRoleRecord(row));\r\n }\r\n\r\n // SSO methods\r\n async createSSOProvider(data: DbRecord): Promise<unknown> {\r\n const id = generateId();\r\n\r\n const query = `\r\n INSERT INTO sso_providers (\r\n id, type, name, org_id, metadata_url, client_id, client_secret,\r\n token_endpoint_auth_method, idp_entity_id, idp_sso_url, idp_slo_url,\r\n idp_certificate, sp_entity_id, sp_acs_url, sp_slo_url, signing_cert,\r\n signing_key, encryption_enabled, force_authn, scopes, redirect_uris,\r\n claim_mapping, attribute_mapping, metadata\r\n )\r\n 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)\r\n RETURNING *\r\n `;\r\n\r\n const values = [\r\n id,\r\n data.type,\r\n data.name,\r\n data.orgId || null,\r\n data.metadata_url || data.metadataUrl || null,\r\n data.client_id || data.clientId || null,\r\n data.client_secret || data.clientSecret || null,\r\n data.token_endpoint_auth_method || data.tokenEndpointAuthMethod || null,\r\n data.idp_entity_id || data.idpEntityId || null,\r\n data.idp_sso_url || data.idpSsoUrl || null,\r\n data.idp_slo_url || data.idpSloUrl || null,\r\n data.idp_certificate || data.idpCertificate || null,\r\n data.sp_entity_id || data.spEntityId || null,\r\n data.sp_acs_url || data.spAcsUrl || null,\r\n data.sp_slo_url || data.spSloUrl || null,\r\n data.signing_cert || data.signingCert || null,\r\n data.signing_key || data.signingKey || null,\r\n data.encryption_enabled || data.encryptionEnabled || false,\r\n data.force_authn || data.forceAuthn || false,\r\n data.scopes || [],\r\n data.redirect_uris || data.redirectUris || [],\r\n JSON.stringify(data.claim_mapping || data.claimMapping || {}),\r\n JSON.stringify(data.attribute_mapping || data.attributeMapping || {}),\r\n JSON.stringify(data.metadata || {}),\r\n ];\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/create_failed',\r\n message: 'Failed to create SSO provider',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapSSOProviderRecord(result.rows[0]!);\r\n }\r\n\r\n async getSSOProvider(providerId: string): Promise<unknown | null> {\r\n const query = 'SELECT * FROM sso_providers WHERE id = $1';\r\n const result = await this.query(query, [providerId]);\r\n\r\n if (result.rows.length === 0) {\r\n return null;\r\n }\r\n\r\n return this.mapSSOProviderRecord(result.rows[0]!);\r\n }\r\n\r\n async updateSSOProvider(providerId: string, data: Partial<unknown>): Promise<unknown> {\r\n const fields = [];\r\n const values = [];\r\n let paramCount = 1;\r\n\r\n for (const [key, value] of Object.entries(data)) {\r\n if (key === 'id' || key === 'createdAt') {\r\n continue;\r\n }\r\n\r\n const snakeKey = this.camelToSnake(key);\r\n if (key === 'claimMapping' || key === 'attributeMapping' || key === 'metadata') {\r\n fields.push(`${snakeKey} = $${paramCount}`);\r\n values.push(JSON.stringify(value));\r\n } else {\r\n fields.push(`${snakeKey} = $${paramCount}`);\r\n values.push(value);\r\n }\r\n paramCount++;\r\n }\r\n\r\n if (fields.length === 0) {\r\n return this.getSSOProvider(providerId) as Promise<unknown>;\r\n }\r\n\r\n fields.push(`updated_at = $${paramCount}`);\r\n values.push(new Date());\r\n values.push(providerId);\r\n\r\n const query = `\r\n UPDATE sso_providers\r\n SET ${fields.join(', ')}\r\n WHERE id = $${paramCount + 1}\r\n RETURNING *\r\n `;\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/update_failed',\r\n message: 'Failed to update SSO provider',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapSSOProviderRecord(result.rows[0]!);\r\n }\r\n\r\n async deleteSSOProvider(providerId: string): Promise<void> {\r\n const query = 'DELETE FROM sso_providers WHERE id = $1';\r\n await this.query(query, [providerId]);\r\n }\r\n\r\n async listSSOProviders(orgId?: string): Promise<unknown[]> {\r\n let query = 'SELECT * FROM sso_providers';\r\n const values: unknown[] = [];\r\n\r\n if (orgId) {\r\n query += ' WHERE org_id = $1 OR org_id IS NULL';\r\n values.push(orgId);\r\n }\r\n\r\n query += ' ORDER BY created_at DESC';\r\n\r\n const result = await this.query(query, values);\r\n return result.rows.filter((row): row is DbRecord => !!row).map((row) => this.mapSSOProviderRecord(row));\r\n }\r\n\r\n async createSSOLink(data: Omit<SSOLink, 'id' | 'linkedAt'>): Promise<SSOLink> {\r\n const id = generateId();\r\n\r\n const query = `\r\n INSERT INTO sso_links (\r\n id, user_id, provider_id, provider_type, provider_subject,\r\n provider_email, auto_provisioned, metadata, last_auth_at\r\n )\r\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\r\n RETURNING *\r\n `;\r\n\r\n const values = [\r\n id,\r\n data.userId,\r\n data.providerId,\r\n data.providerType,\r\n data.providerSubject,\r\n data.providerEmail || null,\r\n data.autoProvisioned || false,\r\n JSON.stringify(data.metadata || {}),\r\n data.lastAuthAt || new Date(),\r\n ];\r\n\r\n const result = await this.query(query, values);\r\n if (!result.rows[0]) {\r\n throw new InternalError({\r\n code: 'auth-postgres/create_failed',\r\n message: 'Failed to create SSO link',\r\n severity: 'error',\n retryable: false,\n });\r\n }\r\n return this.mapSSOLinkRecord(result.rows[0]!);\r\n }\r\n\r\n async getSSOLink(linkId: string): Promise<SSOLink | null> {\r\n const query = 'SELECT * FROM sso_links WHERE id = $1';\r\n const result = await this.quer