From 589b6adde6aaac36d78c847ff24347069ca78f30 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 23 May 2026 06:54:11 +0000 Subject: [PATCH 1/9] feat: OAuth middleware with module loaders for identity/secrets/auth config Replace manual SQL queries and hardcoded schema assumptions in OAuth middleware with module loaders from express-context: New loaders: - encryptedSecretsLoader: resolves encrypted_secrets schema from metaschema_modules_public.encrypted_secrets_module - userAuthLoader: resolves user_auth_module for sign_in/sign_up function names and schema (no longer assumes privateSchema) - identityProvidersLoader: resolves identity_providers_module for provider config table location OAuth middleware changes: - Uses req.constructive.useModule() for all schema lookups - Uses req.constructive.withPgClient() for properly scoped RLS transactions (replaces manual set_config calls) - Removes express-rate-limit (DB already handles rate limiting) - Uses getNodeEnv() instead of process.env.NODE_ENV - Extracts email_verified from raw provider profile data - Passes loaders registry to createContextMiddleware in server.ts Addresses review comments from PR #1141. --- graphql/server/package.json | 1 + graphql/server/src/middleware/oauth.ts | 692 ++++++++++++++++++ graphql/server/src/server.ts | 9 +- packages/express-context/src/index.ts | 6 + .../src/loaders/encrypted-secrets.ts | 53 ++ .../src/loaders/identity-providers.ts | 65 ++ packages/express-context/src/loaders/index.ts | 12 + .../express-context/src/loaders/user-auth.ts | 79 ++ packages/express-context/src/types.ts | 26 + pnpm-lock.yaml | 226 +----- 10 files changed, 963 insertions(+), 206 deletions(-) create mode 100644 graphql/server/src/middleware/oauth.ts create mode 100644 packages/express-context/src/loaders/encrypted-secrets.ts create mode 100644 packages/express-context/src/loaders/identity-providers.ts create mode 100644 packages/express-context/src/loaders/user-auth.ts diff --git a/graphql/server/package.json b/graphql/server/package.json index be18bfea0..6e89520bb 100644 --- a/graphql/server/package.json +++ b/graphql/server/package.json @@ -46,6 +46,7 @@ "@constructive-io/express-context": "workspace:^", "@constructive-io/graphql-env": "workspace:^", "@constructive-io/graphql-types": "workspace:^", + "@constructive-io/oauth": "workspace:^", "@constructive-io/s3-utils": "workspace:^", "@constructive-io/upload-names": "workspace:^", "@constructive-io/url-domains": "workspace:^", diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts new file mode 100644 index 000000000..e71942497 --- /dev/null +++ b/graphql/server/src/middleware/oauth.ts @@ -0,0 +1,692 @@ +/** + * OAuth / SSO Middleware + * + * Express router for OAuth2/OIDC identity-based sign-in. Uses module loaders + * from @constructive-io/express-context to discover schemas and config at + * runtime rather than hardcoding assumptions about where tables live. + * + * Resolves per-database: + * - identityProviders → schema where identity_providers table lives + * - encryptedSecrets → schema for decrypting client secrets + * - userAuth → schema + function names for sign_in_identity / sign_up_identity + * - authSettings → cookie, captcha, and session config + * - rlsModule → private/public schema references + * + * All DB queries run through `req.constructive.withPgClient()` which + * applies pgSettings (role, claims, request_id) via SET LOCAL, replacing + * the manual `set_config()` calls in the original implementation. + */ + +import crypto from 'crypto'; +import { Router, Request, Response } from 'express'; +import { OAuthClient, OAuthProfile } from '@constructive-io/oauth'; +import { Logger } from '@pgpmjs/logger'; +import { getNodeEnv } from '@pgpmjs/env'; +import type { ConstructiveOptions } from '@constructive-io/graphql-types'; +import type { + AuthSettings, + ConstructiveContext, + EncryptedSecretsConfig, + IdentityProvidersConfig, + UserAuthConfig, +} from '@constructive-io/express-context'; + +import { + DEVICE_TOKEN_COOKIE_NAME, + getSessionCookieConfig, + getDeviceTokenCookieConfig, + setSessionCookie, + setDeviceTokenCookie, + parseCookieValue, +} from './cookie'; + +const log = new Logger('oauth'); + +const OAUTH_STATE_COOKIE = 'oauth_state'; +const DEFAULT_OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes +const DEFAULT_ERROR_REDIRECT_PATH = '/auth/error'; + +// ============================================================================= +// Signed State Utilities +// ============================================================================= + +interface StatePayload { + redirect_uri: string; + provider: string; + nonce: string; + exp: number; +} + +function getStateSecret(): string { + const secret = process.env.OAUTH_SECRET; + if (!secret) { + throw new Error('OAUTH_SECRET environment variable is required'); + } + return secret; +} + +function createSignedState( + payload: { redirect_uri: string; provider: string }, + maxAge: number, +): string { + const data: StatePayload = { + ...payload, + nonce: crypto.randomBytes(16).toString('hex'), + exp: Date.now() + maxAge, + }; + const json = JSON.stringify(data); + const sig = crypto + .createHmac('sha256', getStateSecret()) + .update(json) + .digest('base64url'); + return Buffer.from(json).toString('base64url') + '.' + sig; +} + +function verifySignedState(state: string): StatePayload | null { + try { + const [payloadB64, sig] = state.split('.'); + if (!payloadB64 || !sig) return null; + + const json = Buffer.from(payloadB64, 'base64url').toString(); + const expectedSig = crypto + .createHmac('sha256', getStateSecret()) + .update(json) + .digest('base64url'); + + if ( + !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig)) + ) { + return null; + } + + const data = JSON.parse(json) as StatePayload; + if (data.exp < Date.now()) { + return null; + } + + return data; + } catch { + return null; + } +} + +// ============================================================================= +// Module Resolution Helpers +// ============================================================================= + +interface OAuthModules { + identityProviders: IdentityProvidersConfig; + encryptedSecrets: EncryptedSecretsConfig; + userAuth: UserAuthConfig; + authSettings: AuthSettings | undefined; +} + +async function resolveOAuthModules( + ctx: ConstructiveContext, +): Promise { + const [identityProviders, encryptedSecrets, userAuth, authSettings] = + await Promise.all([ + ctx.useModule('identityProviders'), + ctx.useModule('encryptedSecrets'), + ctx.useModule('userAuth'), + ctx.useModule('authSettings'), + ]); + + if (!identityProviders || !encryptedSecrets || !userAuth) { + return null; + } + + return { identityProviders, encryptedSecrets, userAuth, authSettings }; +} + +// ============================================================================= +// Identity Provider Database Functions +// ============================================================================= + +interface IdentityProviderConfig { + slug: string; + kind: 'oauth2' | 'oidc'; + display_name: string; + enabled: boolean; + client_id: string; + client_secret: string; + authorization_url: string | null; + token_url: string | null; + userinfo_url: string | null; + scopes: string[]; + pkce_enabled: boolean; +} + +async function getEnabledProviders( + ctx: ConstructiveContext, + modules: OAuthModules, +): Promise { + const { privateSchemaName, tableName } = modules.identityProviders; + const sql = ` + SELECT slug FROM "${privateSchemaName}"."${tableName}" + WHERE enabled = true AND client_id IS NOT NULL AND client_secret_id IS NOT NULL + `; + const result = await ctx.pool.query(sql); + return result.rows.map((row: { slug: string }) => row.slug); +} + +async function getIdentityProvider( + ctx: ConstructiveContext, + modules: OAuthModules, + providerSlug: string, +): Promise { + const { privateSchemaName, tableName } = modules.identityProviders; + const { schemaName: encryptedSchema } = modules.encryptedSecrets; + + const sql = ` + SELECT + ip.slug, + ip.kind, + ip.display_name, + ip.enabled, + ip.client_id, + "${encryptedSchema}".get(ip.client_secret_id, 'oauth_client_secret') as client_secret, + ip.authorization_url, + ip.token_url, + ip.userinfo_url, + ip.scopes, + ip.pkce_enabled + FROM "${privateSchemaName}"."${tableName}" ip + WHERE ip.slug = $1 AND ip.enabled = true + `; + + const result = await ctx.pool.query(sql, [providerSlug]); + if (result.rows.length === 0) { + return null; + } + + const row = result.rows[0]; + if (!row.client_id || !row.client_secret) { + return null; + } + + return { + slug: row.slug, + kind: row.kind, + display_name: row.display_name, + enabled: row.enabled, + client_id: row.client_id, + client_secret: row.client_secret, + authorization_url: row.authorization_url, + token_url: row.token_url, + userinfo_url: row.userinfo_url, + scopes: row.scopes || [], + pkce_enabled: row.pkce_enabled ?? true, + }; +} + +function createOAuthClientForProvider( + providerConfig: IdentityProviderConfig, + baseUrl: string, +): OAuthClient { + return new OAuthClient({ + providers: { + [providerConfig.slug]: { + clientId: providerConfig.client_id, + clientSecret: providerConfig.client_secret, + }, + }, + baseUrl, + callbackPath: '/auth/{provider}/callback', + }); +} + +// ============================================================================= +// Database Functions +// ============================================================================= + +interface SignInIdentityResult { + id?: string; + user_id?: string; + access_token?: string; + access_token_expires_at?: string; + is_verified?: boolean; + totp_enabled?: boolean; + mfa_required?: boolean; + mfa_challenge_token?: string; + out_device_token?: string; +} + +async function generateCrossOriginToken( + ctx: ConstructiveContext, + modules: OAuthModules, + accessToken: string, +): Promise { + const otToken = crypto.randomBytes(32).toString('base64url'); + const { schemaName } = modules.userAuth; + + const sql = ` + UPDATE "${schemaName}".session_credentials + SET ot_token = $1 + WHERE secret_hash = digest($2::text, 'sha256') + RETURNING id + `; + + const result = await ctx.pool.query(sql, [otToken, accessToken]); + if (result.rows.length === 0) { + throw new Error('Failed to set cross-origin token'); + } + + return otToken; +} + +// ============================================================================= +// OAuth Routes +// ============================================================================= + +function getBaseUrl(req: Request): string { + const protocol = req.protocol || 'http'; + const host = req.get('host') || 'localhost:3000'; + return `${protocol}://${host}`; +} + +/** + * Extract email_verified from the raw provider response. + * OAuthProfile.raw contains the original provider data which includes + * email_verified for OIDC providers (Google, etc.). + */ +function isEmailVerified(profile: OAuthProfile): boolean { + const raw = profile.raw as Record | null; + if (!raw) return false; + if (typeof raw.email_verified === 'boolean') return raw.email_verified; + if (typeof raw.verified_email === 'boolean') return raw.verified_email; + return false; +} + +function redirectToError( + res: Response, + baseUrl: string, + errorPath: string, + error: string, + provider: string, + errorDescription?: string, +): void { + const errorUrl = new URL(errorPath, baseUrl); + errorUrl.searchParams.set('error', error); + errorUrl.searchParams.set('provider', provider); + if (errorDescription) { + errorUrl.searchParams.set('error_description', errorDescription); + } + res.redirect(errorUrl.toString()); +} + +export function createOAuthRoutes(_opts: ConstructiveOptions): Router { + const router = Router(); + const isProduction = getNodeEnv() === 'production'; + + // GET /auth/providers - List available providers from database + router.get('/providers', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.json({ providers: [] }); + } + + try { + const modules = await resolveOAuthModules(ctx); + if (!modules) { + return res.json({ providers: [] }); + } + const providers = await getEnabledProviders(ctx, modules); + res.json({ providers }); + } catch (error) { + log.error('[oauth] Failed to fetch providers:', error); + res.json({ providers: [] }); + } + }); + + // GET /auth/error - Pass to next middleware stack for frontend to handle + router.get('/error', (_req: Request, _res: Response, next) => { + next('router'); + }); + + // GET /auth/:provider - Initiate OAuth flow + router.get('/:provider', async (req: Request, res: Response) => { + const { provider } = req.params; + const redirectUri = (req.query.redirect_uri as string) || '/'; + const ctx = req.constructive; + const baseUrl = getBaseUrl(req); + + if (!ctx) { + log.error(`[oauth] No constructive context for ${provider} initiation`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'API_NOT_CONFIGURED', + provider, + ); + } + + try { + const modules = await resolveOAuthModules(ctx); + if (!modules) { + log.error(`[oauth] Required modules not provisioned for ${provider}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'MODULES_NOT_CONFIGURED', + provider, + ); + } + + const providerConfig = await getIdentityProvider(ctx, modules, provider); + if (!providerConfig) { + log.warn(`[oauth] Provider ${provider} not found or not configured`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'PROVIDER_NOT_CONFIGURED', + provider, + ); + } + + const stateMaxAge = DEFAULT_OAUTH_STATE_MAX_AGE; + const state = createSignedState( + { redirect_uri: redirectUri, provider }, + stateMaxAge, + ); + + res.cookie(OAUTH_STATE_COOKIE, state, { + httpOnly: true, + secure: isProduction, + maxAge: stateMaxAge, + sameSite: 'lax', + }); + + const client = createOAuthClientForProvider(providerConfig, baseUrl); + const { url } = client.getAuthorizationUrl({ provider, state }); + log.info(`[oauth] Initiating OAuth flow for provider: ${provider}`); + res.redirect(url); + } catch (error) { + log.error(`[oauth] Failed to initiate OAuth for ${provider}:`, error); + redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'OAUTH_INIT_FAILED', + provider, + ); + } + }); + + // GET /auth/:provider/callback - Handle OAuth callback + router.get( + '/:provider/callback', + async (req: Request, res: Response) => { + const { provider } = req.params; + const { + code, + state, + error: oauthError, + error_description: errorDescription, + } = req.query; + const baseUrl = getBaseUrl(req); + + const storedState = parseCookieValue(req, OAUTH_STATE_COOKIE); + res.clearCookie(OAUTH_STATE_COOKIE); + + // Handle OAuth provider errors + if (oauthError) { + log.warn(`[oauth] Provider ${provider} returned error: ${oauthError}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + oauthError as string, + provider, + errorDescription as string | undefined, + ); + } + + // Verify state + if (state !== storedState) { + log.warn(`[oauth] State mismatch for ${provider}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'INVALID_STATE', + provider, + ); + } + + const statePayload = verifySignedState(storedState as string); + if (!statePayload) { + log.warn(`[oauth] Invalid or expired state for ${provider}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'INVALID_STATE', + provider, + ); + } + + const { redirect_uri: redirectUri } = statePayload; + const ctx = req.constructive; + + if (!ctx) { + log.error( + `[oauth] No constructive context for ${provider} callback`, + ); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'API_NOT_CONFIGURED', + provider, + ); + } + + try { + const modules = await resolveOAuthModules(ctx); + if (!modules) { + log.error( + `[oauth] Required modules not provisioned for ${provider}`, + ); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'MODULES_NOT_CONFIGURED', + provider, + ); + } + + const providerConfig = await getIdentityProvider( + ctx, + modules, + provider, + ); + if (!providerConfig) { + log.error(`[oauth] Provider ${provider} not found in database`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'PROVIDER_NOT_CONFIGURED', + provider, + ); + } + + const client = createOAuthClientForProvider(providerConfig, baseUrl); + const profile = await client.handleCallback({ + provider, + code: code as string, + }); + log.info(`[oauth] Got profile for ${provider}: ${profile.email}`); + + const deviceToken = + parseCookieValue(req, DEVICE_TOKEN_COOKIE_NAME) ?? null; + + // Calculate target origin for cross-origin flow + const currentOrigin = baseUrl; + let targetOrigin: string; + try { + const redirectUrl = new URL(redirectUri, currentOrigin); + targetOrigin = redirectUrl.origin; + } catch { + targetOrigin = currentOrigin; + } + + const userAgent = req.get('user-agent') || ''; + const { userAuth } = modules; + + // Use withPgClient to run sign_in/sign_up within a properly scoped + // RLS transaction. pgSettings (role, claims, request_id) are applied + // automatically via SET LOCAL, replacing the manual set_config calls. + const result = await ctx.withPgClient( + async (client) => { + // Set OAuth-specific JWT claims on this transaction + await client.query( + `SELECT set_config('jwt.claims.user_agent', $1, true), + set_config('jwt.claims.origin', $2, true)`, + [userAgent, targetOrigin], + ); + + const emailVerified = isEmailVerified(profile); + const details = { + provider: profile.provider, + sub: profile.providerId, + email: profile.email, + email_verified: emailVerified, + name: profile.name, + picture: profile.picture, + raw_userinfo: profile.raw, + }; + + // sign_in_identity lives in the userAuth schema, NOT assumed to + // be in the RLS privateSchema. + const signInSql = ` + SELECT * FROM "${userAuth.schemaName}".sign_in_identity( + $1::text, $2::text, $3::jsonb, $4::text, 'access_token'::text, $5::boolean, $6::text + ) + `; + + try { + const signInResult = await client.query(signInSql, [ + profile.provider, + profile.providerId, + JSON.stringify(details), + profile.email, + true, + deviceToken, + ]); + return signInResult.rows[0] || {}; + } catch (err: any) { + const errorMessage = err.message || ''; + + if (!errorMessage.includes('IDENTITY_ACCOUNT_NOT_FOUND')) { + throw err; + } + + log.info( + `[oauth] Account not found for ${profile.email}, attempting signup`, + ); + + if (!emailVerified) { + log.warn( + `[oauth] Rejecting unverified email for signup: ${profile.email}`, + ); + return { _error: 'EMAIL_NOT_VERIFIED' } as any; + } + + // sign_up_identity also lives in the userAuth schema + const signUpSql = ` + SELECT * FROM "${userAuth.schemaName}".sign_up_identity( + $1::text, $2::text, $3::text, $4::jsonb, 'access_token'::text, $5::boolean, $6::text + ) + `; + + const signUpResult = await client.query(signUpSql, [ + profile.provider, + profile.providerId, + profile.email, + JSON.stringify(details), + true, + deviceToken, + ]); + return signUpResult.rows[0] || {}; + } + }, + ); + + // Handle error sentinels from within the transaction + if ((result as any)._error === 'EMAIL_NOT_VERIFIED') { + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'EMAIL_NOT_VERIFIED', + provider, + ); + } + + // Handle MFA required + if (result.mfa_required && result.mfa_challenge_token) { + log.info(`[oauth] MFA required for ${profile.email}`); + const mfaUrl = new URL('/auth/mfa', baseUrl); + mfaUrl.searchParams.set('token', result.mfa_challenge_token); + mfaUrl.searchParams.set('redirect_uri', redirectUri); + return res.redirect(mfaUrl.toString()); + } + + if (!result.access_token) { + throw new Error('No access token returned from sign_in_identity'); + } + + const isCrossOrigin = targetOrigin !== currentOrigin; + + if (isCrossOrigin) { + const otToken = await generateCrossOriginToken( + ctx, + modules, + result.access_token, + ); + const redirectUrl = new URL(redirectUri, currentOrigin); + redirectUrl.searchParams.set('token', otToken); + log.info( + `[oauth] OAuth success for ${profile.email}, cross-origin redirect`, + ); + return res.redirect(redirectUrl.toString()); + } else { + const sessionConfig = getSessionCookieConfig( + modules.authSettings, + true, + ); + setSessionCookie(res, result.access_token, sessionConfig); + + if (result.out_device_token) { + const deviceConfig = getDeviceTokenCookieConfig( + modules.authSettings, + ); + setDeviceTokenCookie(res, result.out_device_token, deviceConfig); + } + + log.info( + `[oauth] OAuth success for ${profile.email}, same-origin redirect`, + ); + return res.redirect(redirectUri); + } + } catch (error: any) { + log.error(`[oauth] Callback failed for ${provider}:`, error); + redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'CALLBACK_FAILED', + provider, + ); + } + }, + ); + + return router; +} diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index e2be61a07..8607c9871 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -39,7 +39,8 @@ import { createCaptchaMiddleware } from './middleware/captcha'; import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie'; import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload'; import { createLlmApiRouter } from './middleware/llm-api'; -import { createContextMiddleware, requestIdMiddleware } from '@constructive-io/express-context'; +import { createOAuthRoutes } from './middleware/oauth'; +import { createContextMiddleware, createDefaultRegistry, requestIdMiddleware } from '@constructive-io/express-context'; import { startDebugSampler } from './diagnostics/debug-sampler'; const log = new Logger('server'); @@ -167,7 +168,7 @@ class Server { app.use(api); app.post('/upload', uploadAuthenticate, ...uploadRoute); app.use(authenticate); - app.use(createContextMiddleware({ pg: effectiveOpts.pg })); + app.use(createContextMiddleware({ pg: effectiveOpts.pg, loaders: createDefaultRegistry() })); app.use(createCaptchaMiddleware()); // CSRF protection for cookie-authenticated requests @@ -199,6 +200,10 @@ class Server { app.use(csrfSetToken); // Set CSRF token cookie on all requests app.use('/graphql', csrfProtect); // Enforce CSRF on GraphQL mutations + // OAuth / SSO routes — mounted before graphile so OAuth callbacks + // are handled without going through PostGraphile + app.use('/auth', createOAuthRoutes(effectiveOpts)); + // 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/index.ts b/packages/express-context/src/index.ts index f70907c6a..11f28ce27 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -54,6 +54,9 @@ export type { PublicKeyChallengeData, PubkeyChallengeSettings, RlsModule, + EncryptedSecretsConfig, + IdentityProvidersConfig, + UserAuthConfig, WebauthnSettings, WithPgClient, } from './types'; @@ -92,6 +95,9 @@ export { pubkeyLoader, rlsLoader, webauthnLoader, + encryptedSecretsLoader, + userAuthLoader, + identityProvidersLoader, } from './loaders'; // Side-effect: Express type augmentation diff --git a/packages/express-context/src/loaders/encrypted-secrets.ts b/packages/express-context/src/loaders/encrypted-secrets.ts new file mode 100644 index 000000000..5f79f77cb --- /dev/null +++ b/packages/express-context/src/loaders/encrypted-secrets.ts @@ -0,0 +1,53 @@ +/** + * Encrypted Secrets Module Loader + * + * Resolves the schema name for the encrypted_secrets table from + * metaschema_modules_public.encrypted_secrets_module. Used by OAuth + * and other modules that need to decrypt secrets stored in the tenant DB. + */ + +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface EncryptedSecretsConfig { + schemaName: string; + tableName: string; +} + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const ENCRYPTED_SECRETS_MODULE_SQL = ` + SELECT + s.schema_name, + esm.table_name + FROM metaschema_modules_public.encrypted_secrets_module esm + JOIN metaschema_public.schema s ON s.id = esm.schema_id + WHERE esm.database_id = $1 + LIMIT 1 +`; + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const encryptedSecretsLoader: ModuleLoader = + createModuleLoader({ + name: 'encryptedSecrets', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + + const result = await tenantPool.query<{ + schema_name: string; + table_name: string; + }>(ENCRYPTED_SECRETS_MODULE_SQL, [databaseId]); + + const row = result.rows[0]; + if (!row) return undefined; + + return { + schemaName: row.schema_name, + tableName: row.table_name, + }; + }, + }); diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts new file mode 100644 index 000000000..96d655aea --- /dev/null +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -0,0 +1,65 @@ +/** + * Identity Providers Module Loader + * + * Resolves the identity_providers_module config from metaschema_modules_public. + * Provides schema names where the identity_providers table lives, used by + * OAuth/SSO middleware to look up provider definitions (client_id, encrypted + * client_secret, scopes, etc.). + */ + +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface IdentityProvidersConfig { + schemaName: string; + privateSchemaName: string; + tableName: string; +} + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const IDENTITY_PROVIDERS_MODULE_SQL = ` + SELECT + s.schema_name, + ps.schema_name AS private_schema_name, + ipm.table_name + 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 + WHERE ipm.database_id = $1 + LIMIT 1 +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface IdentityProvidersModuleRow { + schema_name: string; + private_schema_name: string; + table_name: string; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const identityProvidersLoader: ModuleLoader = + createModuleLoader({ + name: 'identityProviders', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + + const result = await tenantPool.query( + IDENTITY_PROVIDERS_MODULE_SQL, + [databaseId], + ); + const row = result.rows[0]; + if (!row) return undefined; + + return { + schemaName: row.schema_name, + privateSchemaName: row.private_schema_name, + tableName: row.table_name, + }; + }, + }); diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index 70187df40..ef4eac3cf 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -12,6 +12,9 @@ * - pubkeyChallengeSettings (services_public.pubkey_settings) * - webauthnSettings(services_public.webauthn_settings) * - authSettings (metaschema_modules_public.sessions_module → tenant DB) + * - encryptedSecrets (metaschema_modules_public.encrypted_secrets_module) + * - userAuth (metaschema_modules_public.user_auth_module) + * - identityProviders (metaschema_modules_public.identity_providers_module) * * To add a new per-db lookup, implement a ModuleLoader and register it: * @@ -47,6 +50,9 @@ export { authSettingsLoader } from './auth-settings'; export { billingLoader } from './billing'; export { inferenceLogLoader } from './inference-log'; export { agentChatLoader } from './agent-chat'; +export { encryptedSecretsLoader } from './encrypted-secrets'; +export { userAuthLoader } from './user-auth'; +export { identityProvidersLoader } from './identity-providers'; /** * Convenience: create a registry pre-loaded with all built-in loaders. @@ -61,6 +67,9 @@ import { authSettingsLoader } from './auth-settings'; import { billingLoader } from './billing'; import { inferenceLogLoader } from './inference-log'; import { agentChatLoader } from './agent-chat'; +import { encryptedSecretsLoader } from './encrypted-secrets'; +import { userAuthLoader } from './user-auth'; +import { identityProvidersLoader } from './identity-providers'; export function createDefaultRegistry() { const registry = createLoaderRegistry(); @@ -73,5 +82,8 @@ export function createDefaultRegistry() { registry.register(billingLoader); registry.register(inferenceLogLoader); registry.register(agentChatLoader); + registry.register(encryptedSecretsLoader); + registry.register(userAuthLoader); + registry.register(identityProvidersLoader); return registry; } diff --git a/packages/express-context/src/loaders/user-auth.ts b/packages/express-context/src/loaders/user-auth.ts new file mode 100644 index 000000000..9bcda1fca --- /dev/null +++ b/packages/express-context/src/loaders/user-auth.ts @@ -0,0 +1,79 @@ +/** + * User Auth Module Loader + * + * Resolves the user_auth_module config from metaschema_modules_public. + * Provides schema name and function names for sign-in/sign-up operations + * including identity-based (OAuth/SSO) auth functions. + */ + +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface UserAuthConfig { + schemaName: string; + signInFunction: string; + signUpFunction: string; + signOutFunction: string; + signInCrossOriginFunction: string | null; + requestCrossOriginTokenFunction: string | null; + extendTokenExpires: string; +} + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const USER_AUTH_MODULE_SQL = ` + SELECT + s.schema_name, + uam.sign_in_function, + uam.sign_up_function, + uam.sign_out_function, + uam.sign_in_cross_origin_function, + uam.request_cross_origin_token_function, + uam.extend_token_expires + FROM metaschema_modules_public.user_auth_module uam + JOIN metaschema_public.schema s ON s.id = uam.schema_id + WHERE uam.database_id = $1 + LIMIT 1 +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface UserAuthModuleRow { + schema_name: string; + sign_in_function: string; + sign_up_function: string; + sign_out_function: string; + sign_in_cross_origin_function: string | null; + request_cross_origin_token_function: string | null; + extend_token_expires: string; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const userAuthLoader: ModuleLoader = + createModuleLoader({ + name: 'userAuth', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + + const result = await tenantPool.query( + USER_AUTH_MODULE_SQL, + [databaseId], + ); + const row = result.rows[0]; + if (!row) return undefined; + + return { + schemaName: row.schema_name, + signInFunction: row.sign_in_function, + signUpFunction: row.sign_up_function, + signOutFunction: row.sign_out_function, + signInCrossOriginFunction: row.sign_in_cross_origin_function, + requestCrossOriginTokenFunction: row.request_cross_origin_token_function, + extendTokenExpires: row.extend_token_expires, + }; + }, + }); diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index b2fd2ff45..bd2f70c39 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -150,6 +150,29 @@ export interface AgentChatConfig { taskTableName: string | null; } +// ─── OAuth / Identity Types ───────────────────────────────────────────────── + +export interface EncryptedSecretsConfig { + schemaName: string; + tableName: string; +} + +export interface UserAuthConfig { + schemaName: string; + signInFunction: string; + signUpFunction: string; + signOutFunction: string; + signInCrossOriginFunction: string | null; + requestCrossOriginTokenFunction: string | null; + extendTokenExpires: string; +} + +export interface IdentityProvidersConfig { + schemaName: string; + privateSchemaName: string; + tableName: string; +} + // ─── Module Types Map ─────────────────────────────────────────────────────── /** @@ -170,6 +193,9 @@ export interface BuiltinModuleMap { billing: BillingConfig; inferenceLog: InferenceLogConfig; agentChat: AgentChatConfig; + encryptedSecrets: EncryptedSecretsConfig; + userAuth: UserAuthConfig; + identityProviders: IdentityProvidersConfig; } // ─── Constructive Context ─────────────────────────────────────────────────── diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 944af29b5..3bfe2fc53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,7 +260,7 @@ importers: version: 5.2.1 grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) + version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) graphile-realtime-subscriptions: specifier: workspace:^ version: link:../graphile-realtime-subscriptions/dist @@ -272,7 +272,7 @@ importers: version: link:../../postgres/pg-cache/dist postgraphile: specifier: 5.0.3 - version: 5.0.3(4080119c6ab3f2725faab12a7cbc5738) + version: 5.0.3(ad3b1ddbbeba5ca9cd3b71263258e931) devDependencies: '@types/express': specifier: ^5.0.6 @@ -285,7 +285,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) + version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) publishDirectory: dist graphile/graphile-connection-filter: @@ -731,7 +731,7 @@ importers: version: 0.3.0 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) + version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) publishDirectory: dist graphile/graphile-search: @@ -833,7 +833,7 @@ importers: version: 1.0.2(graphql@16.13.0) grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) + version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) graphile-bucket-provisioner-plugin: specifier: workspace:* version: link:../graphile-bucket-provisioner-plugin/dist @@ -899,7 +899,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(4080119c6ab3f2725faab12a7cbc5738) + version: 5.0.3(ad3b1ddbbeba5ca9cd3b71263258e931) request-ip: specifier: ^3.3.0 version: 3.3.0 @@ -930,7 +930,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) + version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) publishDirectory: dist graphile/graphile-sql-expression-validator: @@ -1178,7 +1178,7 @@ importers: version: 5.2.1 grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) + version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) graphile-cache: specifier: workspace:^ version: link:../../graphile/graphile-cache/dist @@ -1199,7 +1199,7 @@ importers: version: link:../../postgres/pg-env/dist postgraphile: specifier: 5.0.3 - version: 5.0.3(4080119c6ab3f2725faab12a7cbc5738) + version: 5.0.3(ad3b1ddbbeba5ca9cd3b71263258e931) devDependencies: '@types/express': specifier: ^5.0.6 @@ -1212,7 +1212,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) + version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) publishDirectory: dist graphql/gql-ast: @@ -1473,6 +1473,9 @@ importers: '@constructive-io/graphql-types': specifier: workspace:^ version: link:../types/dist + '@constructive-io/oauth': + specifier: workspace:^ + version: link:../../packages/oauth/dist '@constructive-io/s3-utils': specifier: workspace:^ version: link:../../uploads/s3-utils/dist @@ -1517,7 +1520,7 @@ importers: version: 1.0.2(graphql@16.13.0) grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) + version: 1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) graphile-build: specifier: 5.0.2 version: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) @@ -1568,7 +1571,7 @@ importers: version: 5.0.1 postgraphile: specifier: 5.0.3 - version: 5.0.3(4080119c6ab3f2725faab12a7cbc5738) + version: 5.0.3(ad3b1ddbbeba5ca9cd3b71263258e931) request-ip: specifier: ^3.3.0 version: 3.3.0 @@ -1611,7 +1614,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) + version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) publishDirectory: dist graphql/server-test: @@ -2039,7 +2042,7 @@ importers: version: 7.2.2 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) + version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) publishDirectory: dist jobs/knative-job-worker: @@ -2502,7 +2505,7 @@ importers: version: 0.3.0 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) + version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) publishDirectory: dist packages/smtppostmaster: @@ -2531,7 +2534,7 @@ importers: version: 3.18.4 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) + version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) publishDirectory: dist packages/upload-client: @@ -11328,20 +11331,6 @@ snapshots: - immer - use-sync-external-store - '@graphiql/plugin-doc-explorer@0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.15)(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': - dependencies: - '@graphiql/react': 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - '@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - graphql: 16.13.0 - react: 19.2.4 - react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) - react-dom: 19.2.4(react@19.2.4) - zustand: 5.0.11(@types/react@19.2.15)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - transitivePeerDependencies: - - '@types/react' - - immer - - use-sync-external-store - '@graphiql/plugin-doc-explorer@0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.15)(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': dependencies: '@graphiql/react': 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) @@ -11388,22 +11377,6 @@ snapshots: - immer - use-sync-external-store - '@graphiql/plugin-history@0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@22.19.19)(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': - dependencies: - '@graphiql/react': 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - '@graphiql/toolkit': 0.11.3(@types/node@22.19.19)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0) - react: 19.2.4 - react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) - react-dom: 19.2.4(react@19.2.4) - zustand: 5.0.11(@types/react@19.2.15)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - transitivePeerDependencies: - - '@types/node' - - '@types/react' - - graphql - - graphql-ws - - immer - - use-sync-external-store - '@graphiql/plugin-history@0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@25.9.1)(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': dependencies: '@graphiql/react': 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) @@ -11482,37 +11455,6 @@ snapshots: - immer - use-sync-external-store - '@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': - dependencies: - '@graphiql/toolkit': 0.11.3(@types/node@22.19.19)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - clsx: 1.2.1 - framer-motion: 12.36.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - get-value: 3.0.1 - graphql: 16.13.0 - graphql-language-service: 5.5.0(graphql@16.13.0) - jsonc-parser: 3.3.1 - markdown-it: 14.1.1 - monaco-editor: 0.52.2 - monaco-graphql: 1.7.3(graphql@16.13.0)(monaco-editor@0.52.2)(prettier@3.8.1) - prettier: 3.8.1 - react: 19.2.4 - react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) - react-dom: 19.2.4(react@19.2.4) - set-value: 4.1.0 - zustand: 5.0.11(@types/react@19.2.15)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - transitivePeerDependencies: - - '@emotion/is-prop-valid' - - '@types/node' - - '@types/react' - - '@types/react-dom' - - graphql-ws - - immer - - use-sync-external-store - '@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': dependencies: '@graphiql/toolkit': 0.11.3(@types/node@25.9.1)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0) @@ -11564,16 +11506,6 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@graphiql/toolkit@0.11.3(@types/node@22.19.19)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)': - dependencies: - '@n1ru4l/push-pull-async-iterable-iterator': 3.2.0 - graphql: 16.13.0 - meros: 1.3.2(@types/node@22.19.19) - optionalDependencies: - graphql-ws: 6.0.8(graphql@16.13.0)(ws@8.20.1) - transitivePeerDependencies: - - '@types/node' - '@graphiql/toolkit@0.11.3(@types/node@25.9.1)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)': dependencies: '@n1ru4l/push-pull-async-iterable-iterator': 3.2.0 @@ -13273,7 +13205,6 @@ snapshots: '@types/node@25.9.1': dependencies: undici-types: 7.24.6 - optional: true '@types/nodemailer@7.0.11': dependencies: @@ -15090,31 +15021,6 @@ snapshots: - supports-color - use-sync-external-store - grafserv@1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1): - dependencies: - '@graphile/lru': 5.0.0 - debug: 4.4.3(supports-color@5.5.0) - eventemitter3: 5.0.4 - grafast: 1.0.2(graphql@16.13.0) - graphile-config: 1.0.1 - graphql: 16.13.0 - graphql-ws: 6.0.8(graphql@16.13.0)(ws@8.20.1) - ruru: 2.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(debug@4.4.3)(graphile-config@1.0.1)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - tslib: 2.8.1 - optionalDependencies: - ws: 8.20.1 - transitivePeerDependencies: - - '@fastify/websocket' - - '@types/node' - - '@types/react' - - '@types/react-dom' - - crossws - - immer - - react - - react-dom - - supports-color - - use-sync-external-store - grafserv@1.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1): dependencies: '@graphile/lru': 5.0.0 @@ -15292,24 +15198,6 @@ snapshots: - immer - use-sync-external-store - graphiql@5.2.2(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): - dependencies: - '@graphiql/plugin-doc-explorer': 0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.15)(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - '@graphiql/plugin-history': 0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@22.19.19)(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - '@graphiql/react': 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - graphql: 16.13.0 - react: 19.2.4 - react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) - react-dom: 19.2.4(react@19.2.4) - transitivePeerDependencies: - - '@emotion/is-prop-valid' - - '@types/node' - - '@types/react' - - '@types/react-dom' - - graphql-ws - - immer - - use-sync-external-store - graphiql@5.2.2(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): dependencies: '@graphiql/plugin-doc-explorer': 0.4.1(@graphiql/react@0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.15)(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) @@ -16528,10 +16416,6 @@ snapshots: optionalDependencies: '@types/node': 22.19.15 - meros@1.3.2(@types/node@22.19.19): - optionalDependencies: - '@types/node': 22.19.19 - meros@1.3.2(@types/node@25.9.1): optionalDependencies: '@types/node': 25.9.1 @@ -17611,33 +17495,6 @@ snapshots: - supports-color - utf-8-validate - postgraphile@5.0.3(4080119c6ab3f2725faab12a7cbc5738): - dependencies: - '@dataplan/json': 1.0.0(grafast@1.0.2(graphql@16.13.0)) - '@dataplan/pg': 1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0) - '@graphile/lru': 5.0.0 - '@types/node': 22.19.19 - '@types/pg': 8.20.0 - debug: 4.4.3(supports-color@5.5.0) - grafast: 1.0.2(graphql@16.13.0) - grafserv: 1.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.20.1) - graphile-build: 5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0) - graphile-build-pg: 5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1) - graphile-config: 1.0.1 - graphile-utils: 5.0.1(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build-pg@5.0.2(@dataplan/pg@1.0.3(@dataplan/json@1.0.0(grafast@1.0.2(graphql@16.13.0)))(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.0.2(graphql@16.13.0))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.0.2(grafast@1.0.2(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0))(graphile-config@1.0.1)(graphql@16.13.0)(tamedevil@0.1.1) - graphql: 16.13.0 - iterall: 1.3.0 - jsonwebtoken: 9.0.3 - pg: 8.21.0 - pg-sql2: 5.0.1 - tamedevil: 0.1.1 - tslib: 2.8.1 - ws: 8.20.1 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - postgraphile@5.0.3(7bbb0860e34b6a7498373453b4dbfe21): dependencies: '@dataplan/json': 1.0.0(grafast@1.0.2(graphql@16.13.0)) @@ -18102,23 +17959,6 @@ snapshots: - immer - use-sync-external-store - ruru-types@2.0.0(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): - dependencies: - '@graphiql/toolkit': 0.11.3(@types/node@22.19.19)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0) - graphiql: 5.2.2(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - graphql: 16.13.0 - optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - transitivePeerDependencies: - - '@emotion/is-prop-valid' - - '@types/node' - - '@types/react' - - '@types/react-dom' - - graphql-ws - - immer - - use-sync-external-store - ruru-types@2.0.0(@emotion/is-prop-valid@1.4.0)(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): dependencies: '@graphiql/toolkit': 0.11.3(@types/node@25.9.1)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0) @@ -18178,27 +18018,6 @@ snapshots: - immer - use-sync-external-store - ruru@2.0.0(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(debug@4.4.3)(graphile-config@1.0.1)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): - dependencies: - '@emotion/is-prop-valid': 1.4.0 - graphile-config: 1.0.1 - graphql: 16.13.0 - http-proxy: 1.18.1(debug@4.4.3) - ruru-types: 2.0.0(@emotion/is-prop-valid@1.4.0)(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - tslib: 2.8.1 - yargs: 17.7.2 - optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - transitivePeerDependencies: - - '@types/node' - - '@types/react' - - '@types/react-dom' - - debug - - graphql-ws - - immer - - use-sync-external-store - ruru@2.0.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(debug@4.4.3)(graphile-config@1.0.1)(graphql-ws@6.0.8(graphql@16.13.0)(ws@8.20.1))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): dependencies: '@emotion/is-prop-valid': 1.4.0 @@ -18689,14 +18508,14 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3): + ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 22.19.19 + '@types/node': 25.9.1 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -18777,8 +18596,7 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.24.6: - optional: true + undici-types@7.24.6: {} undici@7.25.0: {} From c6e6a43b0881f59c0e14d2f941839434cbc12747 Mon Sep 17 00:00:00 2001 From: Lucas Jiang <2862605953@qq.com> Date: Tue, 26 May 2026 10:22:31 +0800 Subject: [PATCH 2/9] feat(oauth): integrate DB settings for state cookie and error redirect - Add PgInterval type and oauthStateMaxAge/RequireVerifiedEmail/ErrorRedirectPath to AuthSettings - Update authSettingsLoader to SELECT OAuth fields from app_settings_auth - Replace hardcoded DEFAULT_OAUTH_STATE_MAX_AGE with authSettings.oauthStateMaxAge - Replace hardcoded DEFAULT_ERROR_REDIRECT_PATH with authSettings.oauthErrorRedirectPath - Use authSettings.oauthRequireVerifiedEmail to control signup email verification - Apply authSettings cookie config (httpOnly, secure, sameSite) to OAuth state cookie - Handle pg interval objects and HH:MM:SS string format in parseIntervalToMs Co-Authored-By: Claude Opus 4.5 --- graphql/server/src/middleware/oauth.ts | 93 +++++++++++++++---- .../src/loaders/auth-settings.ts | 17 +++- packages/express-context/src/types.ts | 17 +++- 3 files changed, 105 insertions(+), 22 deletions(-) diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index e71942497..3159ebb63 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -46,6 +46,59 @@ const OAUTH_STATE_COOKIE = 'oauth_state'; const DEFAULT_OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes const DEFAULT_ERROR_REDIRECT_PATH = '/auth/error'; +interface PgInterval { + years?: number; + months?: number; + days?: number; + hours?: number; + minutes?: number; + seconds?: number; + milliseconds?: number; +} + +/** + * Parse PostgreSQL interval to milliseconds. + * Handles: pg library object {minutes: 10}, string '10 minutes', '00:10:00' + */ +function parseIntervalToMs( + interval: string | PgInterval | null | undefined, +): number { + if (!interval) return DEFAULT_OAUTH_STATE_MAX_AGE; + + // Handle pg library interval object (e.g., {minutes: 10}) + if (typeof interval === 'object') { + const ms = + (interval.days || 0) * 24 * 60 * 60 * 1000 + + (interval.hours || 0) * 60 * 60 * 1000 + + (interval.minutes || 0) * 60 * 1000 + + (interval.seconds || 0) * 1000 + + (interval.milliseconds || 0); + return ms || DEFAULT_OAUTH_STATE_MAX_AGE; + } + + // Handle HH:MM:SS format (PostgreSQL default interval output) + const hhmmss = interval.match(/^(\d+):(\d+):(\d+)$/); + if (hhmmss) { + const hours = parseInt(hhmmss[1], 10); + const minutes = parseInt(hhmmss[2], 10); + const seconds = parseInt(hhmmss[3], 10); + return (hours * 3600 + minutes * 60 + seconds) * 1000; + } + + // Handle "N unit" format (e.g., "10 minutes") + const match = interval.match(/^(\d+)\s*(second|minute|hour|day)s?$/i); + if (!match) return DEFAULT_OAUTH_STATE_MAX_AGE; + const value = parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + const multipliers: Record = { + second: 1000, + minute: 60 * 1000, + hour: 60 * 60 * 1000, + day: 24 * 60 * 60 * 1000, + }; + return value * (multipliers[unit] || 60 * 1000); +} + // ============================================================================= // Signed State Utilities // ============================================================================= @@ -376,28 +429,32 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { } const providerConfig = await getIdentityProvider(ctx, modules, provider); + const { authSettings } = modules; + const errorRedirectPath = + authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH; + if (!providerConfig) { log.warn(`[oauth] Provider ${provider} not found or not configured`); return redirectToError( res, baseUrl, - DEFAULT_ERROR_REDIRECT_PATH, + errorRedirectPath, 'PROVIDER_NOT_CONFIGURED', provider, ); } - const stateMaxAge = DEFAULT_OAUTH_STATE_MAX_AGE; + const stateMaxAge = parseIntervalToMs(authSettings?.oauthStateMaxAge); const state = createSignedState( { redirect_uri: redirectUri, provider }, stateMaxAge, ); res.cookie(OAUTH_STATE_COOKIE, state, { - httpOnly: true, - secure: isProduction, + httpOnly: authSettings?.cookieHttponly ?? true, + secure: authSettings?.cookieSecure ?? isProduction, maxAge: stateMaxAge, - sameSite: 'lax', + sameSite: (authSettings?.cookieSamesite as 'lax' | 'strict' | 'none') ?? 'lax', }); const client = createOAuthClientForProvider(providerConfig, baseUrl); @@ -485,8 +542,9 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { ); } + let modules: OAuthModules | null = null; try { - const modules = await resolveOAuthModules(ctx); + modules = await resolveOAuthModules(ctx); if (!modules) { log.error( `[oauth] Required modules not provisioned for ${provider}`, @@ -500,6 +558,12 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { ); } + const { authSettings } = modules; + const errorRedirectPath = + authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH; + const requireVerifiedEmail = + authSettings?.oauthRequireVerifiedEmail ?? true; + const providerConfig = await getIdentityProvider( ctx, modules, @@ -510,7 +574,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { return redirectToError( res, baseUrl, - DEFAULT_ERROR_REDIRECT_PATH, + errorRedirectPath, 'PROVIDER_NOT_CONFIGURED', provider, ); @@ -591,7 +655,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { `[oauth] Account not found for ${profile.email}, attempting signup`, ); - if (!emailVerified) { + if (requireVerifiedEmail && !emailVerified) { log.warn( `[oauth] Rejecting unverified email for signup: ${profile.email}`, ); @@ -623,7 +687,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { return redirectToError( res, baseUrl, - DEFAULT_ERROR_REDIRECT_PATH, + errorRedirectPath, 'EMAIL_NOT_VERIFIED', provider, ); @@ -677,13 +741,10 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { } } catch (error: any) { log.error(`[oauth] Callback failed for ${provider}:`, error); - redirectToError( - res, - baseUrl, - DEFAULT_ERROR_REDIRECT_PATH, - 'CALLBACK_FAILED', - provider, - ); + const fallbackPath = + modules?.authSettings?.oauthErrorRedirectPath || + DEFAULT_ERROR_REDIRECT_PATH; + redirectToError(res, baseUrl, fallbackPath, 'CALLBACK_FAILED', provider); } }, ); diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index ff5fcfb93..413eb3696 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -10,7 +10,7 @@ * database rather than the services database. */ -import type { AuthSettings } from '../types'; +import type { AuthSettings, PgInterval } from '../types'; import type { LoaderContext, ModuleLoader } from './types'; import { createModuleLoader } from './create-loader'; @@ -33,7 +33,10 @@ const buildAuthSettingsQuery = (schemaName: string, tableName: string) => ` cookie_path, remember_me_duration, enable_captcha, - captcha_site_key + captcha_site_key, + oauth_state_max_age, + oauth_require_verified_email, + oauth_error_redirect_path FROM "${schemaName}"."${tableName}" LIMIT 1 `; @@ -45,11 +48,14 @@ interface AuthSettingsRow { cookie_samesite: string; cookie_domain: string | null; cookie_httponly: boolean; - cookie_max_age: string | null; + cookie_max_age: string | PgInterval | null; cookie_path: string; - remember_me_duration: string | null; + remember_me_duration: string | PgInterval | null; enable_captcha: boolean; captcha_site_key: string | null; + oauth_state_max_age: string | PgInterval | null; + oauth_require_verified_email: boolean; + oauth_error_redirect_path: string | null; } // ─── Loader ───────────────────────────────────────────────────────────────── @@ -84,6 +90,9 @@ export const authSettingsLoader: ModuleLoader = createModuleLoader rememberMeDuration: row.remember_me_duration, enableCaptcha: row.enable_captcha, captchaSiteKey: row.captcha_site_key, + oauthStateMaxAge: row.oauth_state_max_age, + oauthRequireVerifiedEmail: row.oauth_require_verified_email, + oauthErrorRedirectPath: row.oauth_error_redirect_path, }; }, }); diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index bd2f70c39..6736f7c76 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -85,16 +85,29 @@ export interface RlsModule { currentUserAgent: string; } +export interface PgInterval { + years?: number; + months?: number; + days?: number; + hours?: number; + minutes?: number; + seconds?: number; + milliseconds?: number; +} + export interface AuthSettings { cookieSecure?: boolean; cookieSamesite?: string; cookieDomain?: string | null; cookieHttponly?: boolean; - cookieMaxAge?: string | null; + cookieMaxAge?: string | PgInterval | null; cookiePath?: string; - rememberMeDuration?: string | null; + rememberMeDuration?: string | PgInterval | null; enableCaptcha?: boolean; captchaSiteKey?: string | null; + oauthStateMaxAge?: string | PgInterval | null; + oauthRequireVerifiedEmail?: boolean; + oauthErrorRedirectPath?: string | null; } export interface ApiStructure { From 317f9c42f9c06f6ea6e169e0db30133853d8261a Mon Sep 17 00:00:00 2001 From: Lucas Jiang <2862605953@qq.com> Date: Tue, 26 May 2026 11:02:48 +0800 Subject: [PATCH 3/9] fix(loaders): use config_secrets_org_module for encryptedSecrets loader The loader was querying non-existent encrypted_secrets_module table. Changed to use config_secrets_org_module which points to app_secrets table - the correct location for OAuth client secrets. Co-Authored-By: Claude Opus 4.5 --- .../express-context/src/loaders/encrypted-secrets.ts | 12 ++++++------ packages/express-context/src/loaders/index.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/express-context/src/loaders/encrypted-secrets.ts b/packages/express-context/src/loaders/encrypted-secrets.ts index 5f79f77cb..32276d239 100644 --- a/packages/express-context/src/loaders/encrypted-secrets.ts +++ b/packages/express-context/src/loaders/encrypted-secrets.ts @@ -1,8 +1,8 @@ /** * Encrypted Secrets Module Loader * - * Resolves the schema name for the encrypted_secrets table from - * metaschema_modules_public.encrypted_secrets_module. Used by OAuth + * Resolves the schema name for the app_secrets table from + * metaschema_modules_public.config_secrets_org_module. Used by OAuth * and other modules that need to decrypt secrets stored in the tenant DB. */ @@ -21,10 +21,10 @@ export interface EncryptedSecretsConfig { const ENCRYPTED_SECRETS_MODULE_SQL = ` SELECT s.schema_name, - esm.table_name - FROM metaschema_modules_public.encrypted_secrets_module esm - JOIN metaschema_public.schema s ON s.id = esm.schema_id - WHERE esm.database_id = $1 + csm.table_name + FROM metaschema_modules_public.config_secrets_org_module csm + JOIN metaschema_public.schema s ON s.id = csm.schema_id + WHERE csm.database_id = $1 LIMIT 1 `; diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index ef4eac3cf..7b97934cd 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -12,7 +12,7 @@ * - pubkeyChallengeSettings (services_public.pubkey_settings) * - webauthnSettings(services_public.webauthn_settings) * - authSettings (metaschema_modules_public.sessions_module → tenant DB) - * - encryptedSecrets (metaschema_modules_public.encrypted_secrets_module) + * - encryptedSecrets (metaschema_modules_public.config_secrets_org_module) * - userAuth (metaschema_modules_public.user_auth_module) * - identityProviders (metaschema_modules_public.identity_providers_module) * From ebe0dafd77da3e1f7aa0af04803b87b04a86a730 Mon Sep 17 00:00:00 2001 From: Lucas Jiang <2862605953@qq.com> Date: Tue, 26 May 2026 16:52:19 +0800 Subject: [PATCH 4/9] feat(express-context): add sessionCredentialsSchemaName to userAuth loader JOIN session_credentials_table_id to resolve the correct schema where session_credentials table lives, instead of assuming it's in the same schema as the auth functions. Co-Authored-By: Claude Opus 4.5 --- packages/express-context/src/loaders/user-auth.ts | 7 +++++++ packages/express-context/src/types.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/packages/express-context/src/loaders/user-auth.ts b/packages/express-context/src/loaders/user-auth.ts index 9bcda1fca..b4447d8b6 100644 --- a/packages/express-context/src/loaders/user-auth.ts +++ b/packages/express-context/src/loaders/user-auth.ts @@ -13,6 +13,7 @@ import { createModuleLoader } from './create-loader'; export interface UserAuthConfig { schemaName: string; + sessionCredentialsSchemaName: string; signInFunction: string; signUpFunction: string; signOutFunction: string; @@ -26,6 +27,7 @@ export interface UserAuthConfig { const USER_AUTH_MODULE_SQL = ` SELECT s.schema_name, + sc_schema.schema_name AS session_credentials_schema_name, uam.sign_in_function, uam.sign_up_function, uam.sign_out_function, @@ -34,6 +36,8 @@ const USER_AUTH_MODULE_SQL = ` uam.extend_token_expires FROM metaschema_modules_public.user_auth_module uam JOIN metaschema_public.schema s ON s.id = uam.schema_id + LEFT JOIN metaschema_public.table sc_table ON sc_table.id = uam.session_credentials_table_id + LEFT JOIN metaschema_public.schema sc_schema ON sc_schema.id = sc_table.schema_id WHERE uam.database_id = $1 LIMIT 1 `; @@ -42,6 +46,7 @@ const USER_AUTH_MODULE_SQL = ` interface UserAuthModuleRow { schema_name: string; + session_credentials_schema_name: string | null; sign_in_function: string; sign_up_function: string; sign_out_function: string; @@ -68,6 +73,8 @@ export const userAuthLoader: ModuleLoader = return { schemaName: row.schema_name, + sessionCredentialsSchemaName: + row.session_credentials_schema_name || row.schema_name, signInFunction: row.sign_in_function, signUpFunction: row.sign_up_function, signOutFunction: row.sign_out_function, diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 6736f7c76..63f8ab55a 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -172,6 +172,7 @@ export interface EncryptedSecretsConfig { export interface UserAuthConfig { schemaName: string; + sessionCredentialsSchemaName: string; signInFunction: string; signUpFunction: string; signOutFunction: string; From c310328e02b3f0b24bf80bf9b41685250f9e8c75 Mon Sep 17 00:00:00 2001 From: Lucas Jiang <2862605953@qq.com> Date: Tue, 26 May 2026 16:52:30 +0800 Subject: [PATCH 5/9] feat(express-context): add connectedAccounts loader Add loader for connected_accounts_module to query identity associations. Used by OAuth middleware to check if an identity exists before attempting sign_in_identity (avoids catching IDENTITY_ACCOUNT_NOT_FOUND exception). Co-Authored-By: Claude Opus 4.5 --- packages/express-context/src/index.ts | 3 + .../src/loaders/connected-accounts.ts | 63 +++++++++++++++++++ packages/express-context/src/loaders/index.ts | 4 ++ packages/express-context/src/types.ts | 7 +++ 4 files changed, 77 insertions(+) create mode 100644 packages/express-context/src/loaders/connected-accounts.ts diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 11f28ce27..28e677d2d 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -52,8 +52,10 @@ export type { GenericModuleData, InferenceLogConfig, PublicKeyChallengeData, + PgInterval, PubkeyChallengeSettings, RlsModule, + ConnectedAccountsConfig, EncryptedSecretsConfig, IdentityProvidersConfig, UserAuthConfig, @@ -98,6 +100,7 @@ export { encryptedSecretsLoader, userAuthLoader, identityProvidersLoader, + connectedAccountsLoader, } from './loaders'; // Side-effect: Express type augmentation diff --git a/packages/express-context/src/loaders/connected-accounts.ts b/packages/express-context/src/loaders/connected-accounts.ts new file mode 100644 index 000000000..fe56332e5 --- /dev/null +++ b/packages/express-context/src/loaders/connected-accounts.ts @@ -0,0 +1,63 @@ +/** + * Connected Accounts Module Loader + * + * Resolves the connected_accounts_module config from metaschema_modules_public. + * Provides schema names for querying OAuth identity associations. + */ + +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface ConnectedAccountsConfig { + schemaName: string; + privateSchemaName: string; + tableName: string; +} + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const CONNECTED_ACCOUNTS_MODULE_SQL = ` + SELECT + s.schema_name, + ps.schema_name AS private_schema_name, + cam.table_name + FROM metaschema_modules_public.connected_accounts_module cam + JOIN metaschema_public.schema s ON s.id = cam.schema_id + JOIN metaschema_public.schema ps ON ps.id = cam.private_schema_id + WHERE cam.database_id = $1 + LIMIT 1 +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface ConnectedAccountsModuleRow { + schema_name: string; + private_schema_name: string; + table_name: string; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const connectedAccountsLoader: ModuleLoader = + createModuleLoader({ + name: 'connectedAccounts', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + + const result = await tenantPool.query( + CONNECTED_ACCOUNTS_MODULE_SQL, + [databaseId], + ); + const row = result.rows[0]; + if (!row) return undefined; + + return { + schemaName: row.schema_name, + privateSchemaName: row.private_schema_name, + tableName: row.table_name, + }; + }, + }); diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index 7b97934cd..897bcb9cf 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -15,6 +15,7 @@ * - encryptedSecrets (metaschema_modules_public.config_secrets_org_module) * - userAuth (metaschema_modules_public.user_auth_module) * - identityProviders (metaschema_modules_public.identity_providers_module) + * - connectedAccounts (metaschema_modules_public.connected_accounts_module) * * To add a new per-db lookup, implement a ModuleLoader and register it: * @@ -53,6 +54,7 @@ export { agentChatLoader } from './agent-chat'; export { encryptedSecretsLoader } from './encrypted-secrets'; export { userAuthLoader } from './user-auth'; export { identityProvidersLoader } from './identity-providers'; +export { connectedAccountsLoader } from './connected-accounts'; /** * Convenience: create a registry pre-loaded with all built-in loaders. @@ -70,6 +72,7 @@ import { agentChatLoader } from './agent-chat'; import { encryptedSecretsLoader } from './encrypted-secrets'; import { userAuthLoader } from './user-auth'; import { identityProvidersLoader } from './identity-providers'; +import { connectedAccountsLoader } from './connected-accounts'; export function createDefaultRegistry() { const registry = createLoaderRegistry(); @@ -85,5 +88,6 @@ export function createDefaultRegistry() { registry.register(encryptedSecretsLoader); registry.register(userAuthLoader); registry.register(identityProvidersLoader); + registry.register(connectedAccountsLoader); return registry; } diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 63f8ab55a..25193f6a9 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -187,6 +187,12 @@ export interface IdentityProvidersConfig { tableName: string; } +export interface ConnectedAccountsConfig { + schemaName: string; + privateSchemaName: string; + tableName: string; +} + // ─── Module Types Map ─────────────────────────────────────────────────────── /** @@ -210,6 +216,7 @@ export interface BuiltinModuleMap { encryptedSecrets: EncryptedSecretsConfig; userAuth: UserAuthConfig; identityProviders: IdentityProvidersConfig; + connectedAccounts: ConnectedAccountsConfig; } // ─── Constructive Context ─────────────────────────────────────────────────── From 496ed7a013c04aa32bb2a517a8b0b6a1548428e2 Mon Sep 17 00:00:00 2001 From: Lucas Jiang <2862605953@qq.com> Date: Tue, 26 May 2026 16:52:40 +0800 Subject: [PATCH 6/9] feat(graphql-server): handle PgInterval in session cookie config Add parseIntervalToSeconds to handle PostgreSQL interval objects returned by pg library for cookieMaxAge and rememberMeDuration fields. Co-Authored-By: Claude Opus 4.5 --- graphql/server/src/middleware/cookie.ts | 27 ++++++++++++++++++++----- graphql/server/src/types.ts | 1 + 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/graphql/server/src/middleware/cookie.ts b/graphql/server/src/middleware/cookie.ts index bb9244639..d7a88131b 100644 --- a/graphql/server/src/middleware/cookie.ts +++ b/graphql/server/src/middleware/cookie.ts @@ -1,11 +1,28 @@ import type { Request, Response } from 'express'; -import type { AuthSettings } from '../types'; +import type { AuthSettings, PgInterval } from '../types'; export const SESSION_COOKIE_NAME = 'constructive_session'; export const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token'; const DEVICE_TOKEN_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds +const parseIntervalToSeconds = (interval: string | PgInterval | null | undefined): number | null => { + if (!interval) return null; + if (typeof interval === 'string') { + const parsed = parseInt(interval, 10); + return isNaN(parsed) ? null : parsed; + } + let totalSeconds = 0; + if (interval.years) totalSeconds += interval.years * 365 * 24 * 60 * 60; + if (interval.months) totalSeconds += interval.months * 30 * 24 * 60 * 60; + if (interval.days) totalSeconds += interval.days * 24 * 60 * 60; + if (interval.hours) totalSeconds += interval.hours * 60 * 60; + if (interval.minutes) totalSeconds += interval.minutes * 60; + if (interval.seconds) totalSeconds += interval.seconds; + if (interval.milliseconds) totalSeconds += interval.milliseconds / 1000; + return totalSeconds > 0 ? totalSeconds : null; +}; + export interface CookieConfig { secure: boolean; sameSite: 'strict' | 'lax' | 'none'; @@ -25,11 +42,11 @@ export const getSessionCookieConfig = ( const DEFAULT_MAX_AGE = 86400; // 24 hours let maxAge = DEFAULT_MAX_AGE; if (rememberMe && authSettings?.rememberMeDuration) { - const parsed = parseInt(authSettings.rememberMeDuration, 10); - if (!isNaN(parsed)) maxAge = parsed; + const parsed = parseIntervalToSeconds(authSettings.rememberMeDuration); + if (parsed !== null) maxAge = parsed; } else if (authSettings?.cookieMaxAge) { - const parsed = parseInt(authSettings.cookieMaxAge, 10); - if (!isNaN(parsed)) maxAge = parsed; + const parsed = parseIntervalToSeconds(authSettings.cookieMaxAge); + if (parsed !== null) maxAge = parsed; } return { diff --git a/graphql/server/src/types.ts b/graphql/server/src/types.ts index 474ad0fc2..12435f624 100644 --- a/graphql/server/src/types.ts +++ b/graphql/server/src/types.ts @@ -11,6 +11,7 @@ export type { CorsModuleData, DatabaseSettings, GenericModuleData, + PgInterval, PubkeyChallengeSettings, PublicKeyChallengeData, RlsModule, From b450bd08583289025d2813ffdda4fa911c0cb5fb Mon Sep 17 00:00:00 2001 From: Lucas Jiang <2862605953@qq.com> Date: Tue, 26 May 2026 16:52:52 +0800 Subject: [PATCH 7/9] feat(graphql-server): identity pre-check via connectedAccounts query Replace try/catch IDENTITY_ACCOUNT_NOT_FOUND pattern with explicit identity existence check. Query connected_accounts table using ctx.pool (bypasses RLS) before entering the sign-in transaction. Benefits: - Cleaner control flow (if/else instead of exception handling) - Email verification check happens before transaction starts - Avoids transaction abort on expected "not found" case Addresses reviewer feedback from PR #1141 and #1220. Co-Authored-By: Claude Opus 4.5 --- graphql/server/src/middleware/oauth.ts | 89 ++++++++++++++------------ 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index 3159ebb63..084e97e72 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -25,6 +25,7 @@ import { getNodeEnv } from '@pgpmjs/env'; import type { ConstructiveOptions } from '@constructive-io/graphql-types'; import type { AuthSettings, + ConnectedAccountsConfig, ConstructiveContext, EncryptedSecretsConfig, IdentityProvidersConfig, @@ -172,24 +173,26 @@ interface OAuthModules { encryptedSecrets: EncryptedSecretsConfig; userAuth: UserAuthConfig; authSettings: AuthSettings | undefined; + connectedAccounts: ConnectedAccountsConfig | undefined; } async function resolveOAuthModules( ctx: ConstructiveContext, ): Promise { - const [identityProviders, encryptedSecrets, userAuth, authSettings] = + const [identityProviders, encryptedSecrets, userAuth, authSettings, connectedAccounts] = await Promise.all([ ctx.useModule('identityProviders'), ctx.useModule('encryptedSecrets'), ctx.useModule('userAuth'), ctx.useModule('authSettings'), + ctx.useModule('connectedAccounts'), ]); if (!identityProviders || !encryptedSecrets || !userAuth) { return null; } - return { identityProviders, encryptedSecrets, userAuth, authSettings }; + return { identityProviders, encryptedSecrets, userAuth, authSettings, connectedAccounts }; } // ============================================================================= @@ -601,7 +604,39 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { } const userAgent = req.get('user-agent') || ''; - const { userAuth } = modules; + const { identityProviders, connectedAccounts } = modules; + const authPrivateSchema = identityProviders.privateSchemaName; + + // Check if identity exists using ctx.pool (bypasses RLS) + let identityExists = false; + if (connectedAccounts) { + const checkSql = ` + SELECT 1 FROM "${connectedAccounts.privateSchemaName}"."${connectedAccounts.tableName}" + WHERE service = $1 AND identifier = $2 + LIMIT 1 + `; + const checkResult = await ctx.pool.query(checkSql, [ + profile.provider, + profile.providerId, + ]); + identityExists = checkResult.rows.length > 0; + } + + const emailVerified = isEmailVerified(profile); + + // For signup, check email verification requirement before entering transaction + if (!identityExists && requireVerifiedEmail && !emailVerified) { + log.warn( + `[oauth] Rejecting unverified email for signup: ${profile.email}`, + ); + return redirectToError( + res, + baseUrl, + errorRedirectPath, + 'EMAIL_NOT_VERIFIED', + provider, + ); + } // Use withPgClient to run sign_in/sign_up within a properly scoped // RLS transaction. pgSettings (role, claims, request_id) are applied @@ -615,7 +650,6 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { [userAgent, targetOrigin], ); - const emailVerified = isEmailVerified(profile); const details = { provider: profile.provider, sub: profile.providerId, @@ -626,15 +660,13 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { raw_userinfo: profile.raw, }; - // sign_in_identity lives in the userAuth schema, NOT assumed to - // be in the RLS privateSchema. - const signInSql = ` - SELECT * FROM "${userAuth.schemaName}".sign_in_identity( - $1::text, $2::text, $3::jsonb, $4::text, 'access_token'::text, $5::boolean, $6::text - ) - `; - - try { + if (identityExists) { + // Identity exists, sign in + const signInSql = ` + SELECT * FROM "${authPrivateSchema}".sign_in_identity( + $1::text, $2::text, $3::jsonb, $4::text, 'access_token'::text, $5::boolean, $6::text + ) + `; const signInResult = await client.query(signInSql, [ profile.provider, profile.providerId, @@ -644,31 +676,17 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { deviceToken, ]); return signInResult.rows[0] || {}; - } catch (err: any) { - const errorMessage = err.message || ''; - - if (!errorMessage.includes('IDENTITY_ACCOUNT_NOT_FOUND')) { - throw err; - } - + } else { + // Identity doesn't exist, sign up log.info( `[oauth] Account not found for ${profile.email}, attempting signup`, ); - if (requireVerifiedEmail && !emailVerified) { - log.warn( - `[oauth] Rejecting unverified email for signup: ${profile.email}`, - ); - return { _error: 'EMAIL_NOT_VERIFIED' } as any; - } - - // sign_up_identity also lives in the userAuth schema const signUpSql = ` - SELECT * FROM "${userAuth.schemaName}".sign_up_identity( + SELECT * FROM "${authPrivateSchema}".sign_up_identity( $1::text, $2::text, $3::text, $4::jsonb, 'access_token'::text, $5::boolean, $6::text ) `; - const signUpResult = await client.query(signUpSql, [ profile.provider, profile.providerId, @@ -682,17 +700,6 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { }, ); - // Handle error sentinels from within the transaction - if ((result as any)._error === 'EMAIL_NOT_VERIFIED') { - return redirectToError( - res, - baseUrl, - errorRedirectPath, - 'EMAIL_NOT_VERIFIED', - provider, - ); - } - // Handle MFA required if (result.mfa_required && result.mfa_challenge_token) { log.info(`[oauth] MFA required for ${profile.email}`); From 2de79a9b1cf9e4b1f68412c184bcabfd2be2feeb Mon Sep 17 00:00:00 2001 From: Lucas Jiang <2862605953@qq.com> Date: Tue, 26 May 2026 18:21:48 +0800 Subject: [PATCH 8/9] fix(oauth): use JOIN to fetch client_secret instead of non-existent get function The code was calling a schema.get(uuid) function that doesn't exist. Changed to JOIN the app_secrets table directly to fetch the secret value. Co-Authored-By: Claude Opus 4.5 --- graphql/server/src/middleware/oauth.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index 084e97e72..4d20b772e 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -232,7 +232,8 @@ async function getIdentityProvider( providerSlug: string, ): Promise { const { privateSchemaName, tableName } = modules.identityProviders; - const { schemaName: encryptedSchema } = modules.encryptedSecrets; + const { schemaName: encryptedSchema, tableName: encryptedTableName } = + modules.encryptedSecrets; const sql = ` SELECT @@ -241,13 +242,14 @@ async function getIdentityProvider( ip.display_name, ip.enabled, ip.client_id, - "${encryptedSchema}".get(ip.client_secret_id, 'oauth_client_secret') as client_secret, + convert_from(secrets.value, 'UTF8') as client_secret, ip.authorization_url, ip.token_url, ip.userinfo_url, ip.scopes, ip.pkce_enabled FROM "${privateSchemaName}"."${tableName}" ip + LEFT JOIN "${encryptedSchema}"."${encryptedTableName}" secrets ON secrets.id = ip.client_secret_id WHERE ip.slug = $1 AND ip.enabled = true `; From f0b1be4f8c106a8081e8260573c972149e4f3f72 Mon Sep 17 00:00:00 2001 From: Lucas Jiang <2862605953@qq.com> Date: Tue, 26 May 2026 18:59:20 +0800 Subject: [PATCH 9/9] fix(express-context): use config_secrets_user_module for app_secrets lookup The encryptedSecretsLoader was incorrectly using config_secrets_org_module which tracks org_secrets table. The app_secrets table is created by the config_secrets_user_module generator in the same schema as user_secrets. Changed to query config_secrets_user_module and hardcode tableName to 'app_secrets' since both tables share the same schema. Co-Authored-By: Claude Opus 4.5 --- .../src/loaders/encrypted-secrets.ts | 16 ++++++++-------- packages/express-context/src/loaders/index.ts | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/express-context/src/loaders/encrypted-secrets.ts b/packages/express-context/src/loaders/encrypted-secrets.ts index 32276d239..241396449 100644 --- a/packages/express-context/src/loaders/encrypted-secrets.ts +++ b/packages/express-context/src/loaders/encrypted-secrets.ts @@ -2,8 +2,11 @@ * Encrypted Secrets Module Loader * * Resolves the schema name for the app_secrets table from - * metaschema_modules_public.config_secrets_org_module. Used by OAuth - * and other modules that need to decrypt secrets stored in the tenant DB. + * metaschema_modules_public.config_secrets_user_module. The generator creates + * both user_secrets and app_secrets in the same schema, so we use the schema + * from config_secrets_user_module and hardcode table name to 'app_secrets'. + * + * Used by OAuth and other modules that need to decrypt secrets stored in the tenant DB. */ import type { LoaderContext, ModuleLoader } from './types'; @@ -19,10 +22,8 @@ export interface EncryptedSecretsConfig { // ─── SQL ──────────────────────────────────────────────────────────────────── const ENCRYPTED_SECRETS_MODULE_SQL = ` - SELECT - s.schema_name, - csm.table_name - FROM metaschema_modules_public.config_secrets_org_module csm + SELECT s.schema_name + FROM metaschema_modules_public.config_secrets_user_module csm JOIN metaschema_public.schema s ON s.id = csm.schema_id WHERE csm.database_id = $1 LIMIT 1 @@ -39,7 +40,6 @@ export const encryptedSecretsLoader: ModuleLoader = const result = await tenantPool.query<{ schema_name: string; - table_name: string; }>(ENCRYPTED_SECRETS_MODULE_SQL, [databaseId]); const row = result.rows[0]; @@ -47,7 +47,7 @@ export const encryptedSecretsLoader: ModuleLoader = return { schemaName: row.schema_name, - tableName: row.table_name, + tableName: 'app_secrets', }; }, }); diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index 897bcb9cf..31c490d95 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -12,7 +12,7 @@ * - pubkeyChallengeSettings (services_public.pubkey_settings) * - webauthnSettings(services_public.webauthn_settings) * - authSettings (metaschema_modules_public.sessions_module → tenant DB) - * - encryptedSecrets (metaschema_modules_public.config_secrets_org_module) + * - encryptedSecrets (metaschema_modules_public.config_secrets_user_module → app_secrets) * - userAuth (metaschema_modules_public.user_auth_module) * - identityProviders (metaschema_modules_public.identity_providers_module) * - connectedAccounts (metaschema_modules_public.connected_accounts_module)