diff --git a/graphql/server/src/index.ts b/graphql/server/src/index.ts index 06d1561bd..7a7f424d2 100644 --- a/graphql/server/src/index.ts +++ b/graphql/server/src/index.ts @@ -4,6 +4,8 @@ export * from './server'; export { createApiMiddleware, getSubdomain, getApiConfig } from './middleware/api'; export { createAuthenticateMiddleware } from './middleware/auth'; export { createUploadAuthenticateMiddleware } from './middleware/upload'; +export { createIdentityProvidersRouter } from './middleware/identity-providers'; +export { createAppSettingsAuthRouter } from './middleware/app-settings-auth'; export { cors } from './middleware/cors'; export { graphile } from './middleware/graphile'; export { flush, flushService } from './middleware/flush'; diff --git a/graphql/server/src/middleware/app-settings-auth.ts b/graphql/server/src/middleware/app-settings-auth.ts new file mode 100644 index 000000000..16be19517 --- /dev/null +++ b/graphql/server/src/middleware/app-settings-auth.ts @@ -0,0 +1,248 @@ +/** + * App Settings Auth API + * + * Express router for managing auth settings (cookie config, captcha, OAuth settings). + * Requires administrator role. Reads/writes to app_settings_auth table via + * the authSettings loader discovery. + * + * Routes: + * GET /app-settings-auth → get current settings + * PATCH /app-settings-auth → update settings + */ + +import express, { Router, Request, Response } from 'express'; +import { Logger } from '@pgpmjs/logger'; +import { QuoteUtils } from '@pgsql/quotes'; +import type { ConstructiveContext } from '@constructive-io/express-context'; + +import './types'; + +const log = new Logger('app-settings-auth'); + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const AUTH_SETTINGS_DISCOVERY_SQL = ` + SELECT s.schema_name, sm.auth_settings_table AS table_name + FROM metaschema_modules_public.sessions_module sm + JOIN metaschema_public.schema s ON s.id = sm.schema_id + LIMIT 1 +`; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface AuthSettingsRow { + allow_identity_sign_in: boolean; + allow_identity_sign_up: boolean; + cookie_secure: boolean; + cookie_samesite: string; + cookie_domain: string | null; + cookie_httponly: boolean; + cookie_max_age: string | null; + cookie_path: string; + remember_me_duration: string | null; + enable_captcha: boolean; + captcha_site_key: string | null; + oauth_state_max_age: string | null; + oauth_require_verified_email: boolean; + oauth_error_redirect_path: string | null; +} + +interface UpdateAuthSettingsBody { + allowIdentitySignIn?: boolean; + allowIdentitySignUp?: boolean; + cookieSecure?: boolean; + cookieSamesite?: string; + cookieDomain?: string | null; + cookieHttponly?: boolean; + cookieMaxAge?: string | null; + cookiePath?: string; + rememberMeDuration?: string | null; + enableCaptcha?: boolean; + captchaSiteKey?: string | null; + oauthStateMaxAge?: string | null; + oauthRequireVerifiedEmail?: boolean; + oauthErrorRedirectPath?: string | null; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +async function isAppMember(ctx: ConstructiveContext): Promise { + const userId = ctx.userId; + if (!userId) return false; + + // Check if user is an app member (has a record in app_memberships_sprt) + const sql = ` + SELECT 1 FROM constructive_memberships_private.app_memberships_sprt + WHERE actor_id = $1 + LIMIT 1 + `; + const result = await ctx.pool.query(sql, [userId]); + return result.rows.length > 0; +} + +async function requireAppMember(ctx: ConstructiveContext, res: Response): Promise { + if (!(await isAppMember(ctx))) { + res.status(403).json({ error: 'MEMBERSHIP_REQUIRED' }); + return false; + } + return true; +} + +async function discoverAuthSettingsTable( + ctx: ConstructiveContext, +): Promise<{ schemaName: string; tableName: string } | null> { + const result = await ctx.pool.query<{ schema_name: string; table_name: string }>( + AUTH_SETTINGS_DISCOVERY_SQL, + ); + const row = result.rows[0]; + if (!row) return null; + return { schemaName: row.schema_name, tableName: row.table_name }; +} + +// ─── Router ───────────────────────────────────────────────────────────────── + +export function createAppSettingsAuthRouter(): Router { + const router = Router(); + + // Parse JSON body for PATCH requests + router.use(express.json()); + + /** + * GET /app-settings-auth + * Get current auth settings + */ + router.get('/app-settings-auth', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!(await requireAppMember(ctx, res))) return; + + try { + const table = await discoverAuthSettingsTable(ctx); + if (!table) { + return res.status(404).json({ error: 'Auth settings module not configured' }); + } + + const sql = ` + SELECT + allow_identity_sign_in, + allow_identity_sign_up, + cookie_secure, + cookie_samesite, + cookie_domain, + cookie_httponly, + cookie_max_age::text, + cookie_path, + remember_me_duration::text, + enable_captcha, + captcha_site_key, + oauth_state_max_age::text, + oauth_require_verified_email, + oauth_error_redirect_path + FROM ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} + LIMIT 1 + `; + const result = await ctx.pool.query(sql); + const settings = result.rows[0]; + + if (!settings) { + return res.status(404).json({ error: 'Auth settings not found' }); + } + + res.json({ + allowIdentitySignIn: settings.allow_identity_sign_in, + allowIdentitySignUp: settings.allow_identity_sign_up, + cookieSecure: settings.cookie_secure, + cookieSamesite: settings.cookie_samesite, + cookieDomain: settings.cookie_domain, + cookieHttponly: settings.cookie_httponly, + cookieMaxAge: settings.cookie_max_age, + cookiePath: settings.cookie_path, + rememberMeDuration: settings.remember_me_duration, + enableCaptcha: settings.enable_captcha, + captchaSiteKey: settings.captcha_site_key, + oauthStateMaxAge: settings.oauth_state_max_age, + oauthRequireVerifiedEmail: settings.oauth_require_verified_email, + oauthErrorRedirectPath: settings.oauth_error_redirect_path, + }); + } catch (error) { + log.error('[app-settings-auth] Failed to get settings:', error); + res.status(500).json({ error: 'Failed to get settings' }); + } + }); + + /** + * PATCH /app-settings-auth + * Update auth settings + */ + router.patch('/app-settings-auth', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!(await requireAppMember(ctx, res))) return; + + const body = req.body as UpdateAuthSettingsBody; + + try { + const table = await discoverAuthSettingsTable(ctx); + if (!table) { + return res.status(404).json({ error: 'Auth settings module not configured' }); + } + + const fieldMap: Record = { + allowIdentitySignIn: 'allow_identity_sign_in', + allowIdentitySignUp: 'allow_identity_sign_up', + cookieSecure: 'cookie_secure', + cookieSamesite: 'cookie_samesite', + cookieDomain: 'cookie_domain', + cookieHttponly: 'cookie_httponly', + cookieMaxAge: 'cookie_max_age', + cookiePath: 'cookie_path', + rememberMeDuration: 'remember_me_duration', + enableCaptcha: 'enable_captcha', + captchaSiteKey: 'captcha_site_key', + oauthStateMaxAge: 'oauth_state_max_age', + oauthRequireVerifiedEmail: 'oauth_require_verified_email', + oauthErrorRedirectPath: 'oauth_error_redirect_path', + }; + + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + for (const [camelKey, snakeKey] of Object.entries(fieldMap)) { + if (camelKey in body) { + const value = (body as Record)[camelKey]; + if (snakeKey.includes('_age') || snakeKey.includes('_duration')) { + setClauses.push(`${snakeKey} = $${paramIndex++}::interval`); + } else { + setClauses.push(`${snakeKey} = $${paramIndex++}`); + } + values.push(value); + } + } + + if (setClauses.length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + const sql = ` + UPDATE ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} + SET ${setClauses.join(', ')} + `; + await ctx.pool.query(sql, values); + + log.info('[app-settings-auth] Updated settings'); + res.json({ success: true }); + } catch (error) { + log.error('[app-settings-auth] Failed to update settings:', error); + res.status(500).json({ error: 'Failed to update settings' }); + } + }); + + return router; +} diff --git a/graphql/server/src/middleware/identity-providers.ts b/graphql/server/src/middleware/identity-providers.ts new file mode 100644 index 000000000..a9fafdbb2 --- /dev/null +++ b/graphql/server/src/middleware/identity-providers.ts @@ -0,0 +1,389 @@ +/** + * Admin Identity Providers API + * + * Express router for managing OAuth/OIDC identity provider configurations. + * Requires administrator role. Uses module loaders from @constructive-io/express-context + * to discover schemas and function names at runtime. + * + * Routes: + * GET /identity-providers → list all providers + * GET /identity-providers/:slug → get provider details + * PATCH /identity-providers/:slug → update provider config + * POST /identity-providers/:slug/secret → rotate client secret + */ + +import express, { Router, Request, Response } from 'express'; +import { Logger } from '@pgpmjs/logger'; +import { QuoteUtils } from '@pgsql/quotes'; +import type { ConstructiveContext } from '@constructive-io/express-context'; + +import './types'; + +const log = new Logger('admin-identity-providers'); + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface ProviderRow { + id: string; + slug: string; + kind: 'oauth2' | 'oidc'; + display_name: string; + enabled: boolean; + is_built_in: boolean; + client_id: string | null; + client_secret_id: string | null; + authorization_url: string | null; + token_url: string | null; + userinfo_url: string | null; + scopes: string[] | null; + pkce_enabled: boolean | null; +} + +interface UpdateProviderBody { + clientId?: string; + enabled?: boolean; + scopes?: string[]; + authorizationUrl?: string; + tokenUrl?: string; + userinfoUrl?: string; + pkceEnabled?: boolean; +} + +interface RotateSecretBody { + clientSecret: string; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +async function isAppMember(ctx: ConstructiveContext): Promise { + const userId = ctx.userId; + if (!userId) return false; + + // Check if user is an app member (has a record in app_memberships_sprt) + const sql = ` + SELECT 1 FROM constructive_memberships_private.app_memberships_sprt + WHERE actor_id = $1 + LIMIT 1 + `; + const result = await ctx.pool.query(sql, [userId]); + return result.rows.length > 0; +} + +async function requireAppMember(ctx: ConstructiveContext, res: Response): Promise { + if (!(await isAppMember(ctx))) { + res.status(403).json({ error: 'MEMBERSHIP_REQUIRED' }); + return false; + } + return true; +} + +// ─── Router ───────────────────────────────────────────────────────────────── + +export function createIdentityProvidersRouter(): Router { + const router = Router(); + + // Parse JSON body for PATCH/POST requests + router.use(express.json()); + + /** + * GET /identity-providers + * List all identity providers (including disabled ones) + */ + router.get('/identity-providers', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!(await requireAppMember(ctx, res))) return; + + try { + const identityProviders = await ctx.useModule('identityProviders'); + if (!identityProviders) { + return res.status(404).json({ error: 'Identity providers module not configured' }); + } + + const { privateSchemaName, tableName } = identityProviders; + + const sql = ` + SELECT + id, slug, kind, display_name, enabled, is_built_in, + client_id, client_secret_id, + authorization_url, token_url, userinfo_url, + scopes, pkce_enabled + FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + ORDER BY is_built_in DESC, slug ASC + `; + const result = await ctx.pool.query(sql); + const providers = result.rows; + + res.json({ + providers: providers.map((p) => ({ + id: p.id, + slug: p.slug, + kind: p.kind, + displayName: p.display_name, + enabled: p.enabled, + isBuiltIn: p.is_built_in, + clientId: p.client_id, + hasSecret: !!p.client_secret_id, + authorizationUrl: p.authorization_url, + tokenUrl: p.token_url, + userinfoUrl: p.userinfo_url, + scopes: p.scopes || [], + pkceEnabled: p.pkce_enabled ?? true, + })), + }); + } catch (error) { + log.error('[admin-identity-providers] Failed to list providers:', error); + res.status(500).json({ error: 'Failed to list providers' }); + } + }); + + /** + * GET /identity-providers/:slug + * Get a single provider's details + */ + router.get('/identity-providers/:slug', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!(await requireAppMember(ctx, res))) return; + + const { slug } = req.params; + + try { + const identityProviders = await ctx.useModule('identityProviders'); + if (!identityProviders) { + return res.status(404).json({ error: 'Identity providers module not configured' }); + } + + const { privateSchemaName, tableName } = identityProviders; + + const sql = ` + SELECT + id, slug, kind, display_name, enabled, is_built_in, + client_id, client_secret_id, + authorization_url, token_url, userinfo_url, + scopes, pkce_enabled + FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + WHERE slug = $1 + `; + const result = await ctx.pool.query(sql, [slug]); + const provider = result.rows[0]; + + if (!provider) { + return res.status(404).json({ error: 'Provider not found' }); + } + + res.json({ + id: provider.id, + slug: provider.slug, + kind: provider.kind, + displayName: provider.display_name, + enabled: provider.enabled, + isBuiltIn: provider.is_built_in, + clientId: provider.client_id, + hasSecret: !!provider.client_secret_id, + authorizationUrl: provider.authorization_url, + tokenUrl: provider.token_url, + userinfoUrl: provider.userinfo_url, + scopes: provider.scopes || [], + pkceEnabled: provider.pkce_enabled ?? true, + }); + } catch (error) { + log.error(`[admin-identity-providers] Failed to get provider ${slug}:`, error); + res.status(500).json({ error: 'Failed to get provider' }); + } + }); + + /** + * PATCH /identity-providers/:slug + * Update provider configuration (client_id, enabled, scopes, urls) + */ + router.patch('/identity-providers/:slug', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!(await requireAppMember(ctx, res))) return; + + const { slug } = req.params; + const body = req.body as UpdateProviderBody; + + try { + const identityProviders = await ctx.useModule('identityProviders'); + if (!identityProviders) { + return res.status(404).json({ error: 'Identity providers module not configured' }); + } + + const { privateSchemaName, tableName } = identityProviders; + + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (body.clientId !== undefined) { + setClauses.push(`client_id = $${paramIndex++}`); + values.push(body.clientId); + } + if (body.enabled !== undefined) { + setClauses.push(`enabled = $${paramIndex++}`); + values.push(body.enabled); + } + if (body.scopes !== undefined) { + setClauses.push(`scopes = $${paramIndex++}`); + values.push(body.scopes); + } + if (body.authorizationUrl !== undefined) { + setClauses.push(`authorization_url = $${paramIndex++}`); + values.push(body.authorizationUrl); + } + if (body.tokenUrl !== undefined) { + setClauses.push(`token_url = $${paramIndex++}`); + values.push(body.tokenUrl); + } + if (body.userinfoUrl !== undefined) { + setClauses.push(`userinfo_url = $${paramIndex++}`); + values.push(body.userinfoUrl); + } + if (body.pkceEnabled !== undefined) { + setClauses.push(`pkce_enabled = $${paramIndex++}`); + values.push(body.pkceEnabled); + } + + if (setClauses.length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + values.push(slug); + + const sql = ` + UPDATE ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + SET ${setClauses.join(', ')} + WHERE slug = $${paramIndex} + `; + const result = await ctx.pool.query(sql, values); + if (result.rowCount === 0) { + return res.status(404).json({ error: 'Provider not found' }); + } + + log.info(`[admin-identity-providers] Updated provider ${slug}`); + res.json({ success: true }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message === 'PROVIDER_NOT_FOUND') { + return res.status(404).json({ error: 'Provider not found' }); + } + log.error(`[admin-identity-providers] Failed to update provider ${slug}:`, error); + res.status(500).json({ error: 'Failed to update provider' }); + } + }); + + /** + * POST /identity-providers/:slug/secret + * Set or rotate the client secret for a provider + */ + router.post('/identity-providers/:slug/secret', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!(await requireAppMember(ctx, res))) return; + + const { slug } = req.params; + const body = req.body as RotateSecretBody; + + if (!body.clientSecret) { + return res.status(400).json({ error: 'clientSecret is required' }); + } + + try { + const identityProviders = await ctx.useModule('identityProviders'); + if (!identityProviders) { + return res.status(404).json({ error: 'Identity providers module not configured' }); + } + + const { privateSchemaName, tableName } = identityProviders; + const databaseId = ctx.databaseId; + if (!databaseId) { + return res.status(500).json({ error: 'Database context not available' }); + } + + // Get provider info + const lookupSql = ` + SELECT id, client_secret_id FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + WHERE slug = $1 + `; + const lookupResult = await ctx.pool.query<{ id: string; client_secret_id: string | null }>(lookupSql, [slug]); + if (lookupResult.rows.length === 0) { + return res.status(404).json({ error: 'Provider not found' }); + } + + const provider = lookupResult.rows[0]; + + // Ensure default namespace exists + const namespaceSql = ` + INSERT INTO constructive_infra_public.platform_namespaces (database_id, name) + VALUES ($1, 'default') + ON CONFLICT (database_id, name) DO UPDATE SET name = EXCLUDED.name + RETURNING id + `; + const namespaceResult = await ctx.pool.query<{ id: string }>(namespaceSql, [databaseId]); + const namespaceId = namespaceResult.rows[0].id; + + let secretId = provider.client_secret_id; + + if (secretId) { + // Update existing secret + const updateSecretSql = ` + UPDATE constructive_store_private.platform_secrets + SET value = $1::bytea, algo = 'plain', updated_at = now() + WHERE id = $2 + `; + await ctx.pool.query(updateSecretSql, [body.clientSecret, secretId]); + } else { + // Insert new secret + const insertSecretSql = ` + INSERT INTO constructive_store_private.platform_secrets (database_id, namespace_id, name, value, algo) + VALUES ($1, $2, $3, $4::bytea, 'plain') + RETURNING id + `; + const secretResult = await ctx.pool.query<{ id: string }>(insertSecretSql, [ + databaseId, + namespaceId, + `${slug}/client-secret`, + body.clientSecret, + ]); + secretId = secretResult.rows[0].id; + + // Link secret to provider + const linkSql = ` + UPDATE ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + SET client_secret_id = $1 + WHERE id = $2 + `; + await ctx.pool.query(linkSql, [secretId, provider.id]); + } + + log.info(`[admin-identity-providers] Set secret for provider ${slug}`); + res.json({ success: true }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message === 'PROVIDER_NOT_FOUND') { + return res.status(404).json({ error: 'Provider not found' }); + } + if (message.includes('IDENTITY_PROVIDER_NOT_FOUND')) { + return res.status(404).json({ error: 'Provider not found' }); + } + log.error(`[admin-identity-providers] Failed to rotate secret for ${slug}:`, error); + res.status(500).json({ error: 'Failed to rotate secret' }); + } + }); + + return router; +} diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 8607c9871..6eca7ec6b 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -40,6 +40,8 @@ import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie'; import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload'; import { createLlmApiRouter } from './middleware/llm-api'; import { createOAuthRoutes } from './middleware/oauth'; +import { createIdentityProvidersRouter } from './middleware/identity-providers'; +import { createAppSettingsAuthRouter } from './middleware/app-settings-auth'; import { createContextMiddleware, createDefaultRegistry, requestIdMiddleware } from '@constructive-io/express-context'; import { startDebugSampler } from './diagnostics/debug-sampler'; @@ -204,6 +206,12 @@ class Server { // are handled without going through PostGraphile app.use('/auth', createOAuthRoutes(effectiveOpts)); + // Identity Providers API — mounted before graphile + app.use(createIdentityProvidersRouter()); + + // App Settings Auth API — mounted before graphile + app.use(createAppSettingsAuthRouter()); + // LLM Agent REST API — mounted before graphile so SSE streaming // routes are handled without going through PostGraphile app.use(createLlmApiRouter()); diff --git a/packages/express-context/src/loaders/create-loader.ts b/packages/express-context/src/loaders/create-loader.ts index db2b55a82..28d477fae 100644 --- a/packages/express-context/src/loaders/create-loader.ts +++ b/packages/express-context/src/loaders/create-loader.ts @@ -30,7 +30,7 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade const cache = new LRUCache({ max: opts.max ?? DEFAULT_MAX, ttl: opts.ttlMs ?? DEFAULT_TTL_MS, - updateAgeOnGet: true, + updateAgeOnGet: false, // TTL from first set, not refreshed on read allowStale: false, }); diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts index 99a301b3c..65171c8c5 100644 --- a/packages/express-context/src/loaders/identity-providers.ts +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -30,7 +30,8 @@ const IDENTITY_PROVIDERS_MODULE_SQL = ` SELECT s.schema_name, ps.schema_name AS private_schema_name, - ipm.table_name + ipm.table_name, + ipm.prefix FROM metaschema_modules_public.identity_providers_module ipm JOIN metaschema_public.schema s ON s.id = ipm.schema_id JOIN metaschema_public.schema ps ON ps.id = ipm.private_schema_id @@ -73,6 +74,7 @@ interface IdentityProvidersModuleRow { schema_name: string; private_schema_name: string; table_name: string; + prefix: string; } interface ProviderRow { @@ -138,6 +140,8 @@ export const identityProvidersLoader: ModuleLoader = schemaName: moduleRow.schema_name, privateSchemaName: moduleRow.private_schema_name, tableName: moduleRow.table_name, + prefix: moduleRow.prefix, + rotateSecretFunction: `rotate_identity_provider_${moduleRow.prefix}_secret`, signInIdentityFunction: 'sign_in_identity', signUpIdentityFunction: 'sign_up_identity', providers, diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index fa6ab5bdb..6e3a607a8 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -202,6 +202,10 @@ export interface IdentityProvidersConfig { schemaName: string; privateSchemaName: string; tableName: string; + // Module prefix for generated function names (e.g., 'app' → rotate_identity_provider_app_secret) + prefix: string; + // Generated function name for rotating client secrets + rotateSecretFunction: string; // Function names (defaults until DB schema adds these columns) signInIdentityFunction: string; signUpIdentityFunction: string;