From 141b27830eef81babde8eda301301eeceafd241b Mon Sep 17 00:00:00 2001 From: zetazzz Date: Fri, 12 Jun 2026 21:41:08 +0800 Subject: [PATCH 01/32] oauth related express context chagnes --- packages/express-context/package.json | 1 + packages/express-context/src/index.ts | 11 ++ .../src/loaders/auth-settings.ts | 19 ++- .../src/loaders/connected-accounts.ts | 56 ++++++++ .../src/loaders/encrypted-secrets.ts | 24 ++++ .../src/loaders/identity-providers.ts | 135 ++++++++++++++++++ packages/express-context/src/loaders/index.ts | 16 +++ .../express-context/src/loaders/user-auth.ts | 74 ++++++++++ packages/express-context/src/types.ts | 72 +++++++++- pnpm-lock.yaml | 3 + 10 files changed, 404 insertions(+), 7 deletions(-) create mode 100644 packages/express-context/src/loaders/connected-accounts.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/packages/express-context/package.json b/packages/express-context/package.json index 51972cef39..d13a219194 100644 --- a/packages/express-context/package.json +++ b/packages/express-context/package.json @@ -34,6 +34,7 @@ "@pgpmjs/logger": "workspace:^", "@pgpmjs/server-utils": "workspace:^", "@pgpmjs/types": "workspace:^", + "@pgsql/quotes": "^17.1.0", "lru-cache": "^11.2.7", "pg": "^8.21.0", "pg-cache": "workspace:^", diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index ef35b2c415..e4f15e1f4a 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -46,13 +46,20 @@ export type { BuiltinModuleMap, ComputeConfig, ComputeModuleConfig, + ConnectedAccountsConfig, ConstructiveAPIToken, ConstructiveContext, DatabaseSettings, + EncryptedSecretsConfig, + IdentityProviderConfigMap, + IdentityProviderFullConfig, + IdentityProvidersConfig, InferenceLogConfig, LlmConfig, + PgInterval, PubkeyChallengeSettings, RlsModule, + UserAuthConfig, WebauthnSettings, WithPgClient, } from './types'; @@ -87,15 +94,19 @@ export { authSettingsLoader, billingLoader, computeLoader, + connectedAccountsLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, createModuleLoader, databaseSettingsLoader, + encryptedSecretsLoader, + identityProvidersLoader, inferenceLogLoader, pubkeyLoader, rlsLoader, llmLoader, + userAuthLoader, webauthnLoader, } from './loaders'; diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index 2b0a7c505d..948d4a832c 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 routing database. */ -import type { AuthSettings } from '../types'; +import type { AuthSettings, PgInterval } from '../types'; import { createModuleLoader } from './create-loader'; import type { LoaderContext, ModuleLoader } from './types'; @@ -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 ───────────────────────────────────────────────────────────────── @@ -83,7 +89,10 @@ export const authSettingsLoader: ModuleLoader = createModuleLoader cookiePath: row.cookie_path, rememberMeDuration: row.remember_me_duration, enableCaptcha: row.enable_captcha, - captchaSiteKey: row.captcha_site_key + 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/loaders/connected-accounts.ts b/packages/express-context/src/loaders/connected-accounts.ts new file mode 100644 index 0000000000..2284326f51 --- /dev/null +++ b/packages/express-context/src/loaders/connected-accounts.ts @@ -0,0 +1,56 @@ +/** + * Connected Accounts Module Loader + * + * Resolves the connected_accounts_module config from metaschema_modules_public. + * Provides schema names for querying OAuth identity associations. + */ + +import type { ConnectedAccountsConfig } from '../types'; +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── 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/encrypted-secrets.ts b/packages/express-context/src/loaders/encrypted-secrets.ts new file mode 100644 index 0000000000..1f1eed028d --- /dev/null +++ b/packages/express-context/src/loaders/encrypted-secrets.ts @@ -0,0 +1,24 @@ +/** + * Platform Secrets Module Loader + * + * Returns the fixed schema and table for platform_secrets. OAuth identity + * providers store client_secret_id references in this platform-scoped table. + */ + +import type { EncryptedSecretsConfig } from '../types'; +import type { ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const encryptedSecretsLoader: ModuleLoader = + createModuleLoader({ + name: 'encryptedSecrets', + ttlMs: 5 * 60_000, + async resolve() { + return { + schemaName: 'constructive_store_private', + tableName: 'platform_secrets', + }; + }, + }); 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 0000000000..4e9118a16a --- /dev/null +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -0,0 +1,135 @@ +/** + * Identity Providers Module Loader + * + * Resolves the identity_providers_module config from metaschema_modules_public + * and loads all enabled providers with their full runtime configuration. + */ + +import { QuoteUtils } from '@pgsql/quotes'; + +import type { + IdentityProviderConfigMap, + IdentityProvidersConfig, +} from '../types'; +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const IDENTITY_PROVIDERS_MODULE_SQL = ` + SELECT + s.schema_name, + ps.schema_name AS private_schema_name, + ipm.table_name, + ipm.prefix + FROM metaschema_modules_public.identity_providers_module ipm + JOIN metaschema_public.schema s ON s.id = ipm.schema_id + JOIN metaschema_public.schema ps ON ps.id = ipm.private_schema_id + WHERE ipm.database_id = $1 + LIMIT 1 +`; + +function buildProvidersSql(ipSchema: string, ipTable: string): string { + return ` + SELECT + ip.slug, + ip.kind, + ip.display_name, + ip.enabled, + ip.client_id, + CASE + WHEN secrets.algo = 'pgp' THEN + convert_from(decode(pgp_sym_decrypt(secrets.value, secrets.key_id::text), 'hex'), 'SQL_ASCII') + WHEN secrets.algo = 'crypt' THEN + convert_from(secrets.value, 'SQL_ASCII') + ELSE + convert_from(secrets.value, 'UTF8') + END AS client_secret, + ip.authorization_url, + ip.token_url, + ip.userinfo_url, + ip.scopes, + ip.pkce_enabled + FROM ${QuoteUtils.quoteQualifiedIdentifier(ipSchema, ipTable)} ip + LEFT JOIN "constructive_store_private"."platform_secrets" secrets ON secrets.id = ip.client_secret_id + WHERE ip.enabled = true + AND ip.client_id IS NOT NULL + AND ip.client_secret_id IS NOT NULL + `; +} + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface IdentityProvidersModuleRow { + schema_name: string; + private_schema_name: string; + table_name: string; + prefix: string; +} + +interface ProviderRow { + slug: string; + kind: 'oauth2' | 'oidc'; + display_name: string; + enabled: boolean; + client_id: string; + client_secret: string | null; + authorization_url: string | null; + token_url: string | null; + userinfo_url: string | null; + scopes: string[] | null; + pkce_enabled: boolean | null; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const identityProvidersLoader: ModuleLoader = + createModuleLoader({ + name: 'identityProviders', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + + const moduleResult = await tenantPool.query( + IDENTITY_PROVIDERS_MODULE_SQL, + [databaseId], + ); + const moduleRow = moduleResult.rows[0]; + if (!moduleRow) return undefined; + + const providersResult = await tenantPool.query( + buildProvidersSql(moduleRow.private_schema_name, moduleRow.table_name), + ); + + const providers: IdentityProviderConfigMap = new Map(); + for (const row of providersResult.rows) { + if (!row.client_id || !row.client_secret) { + continue; + } + providers.set(row.slug, { + slug: row.slug, + kind: row.kind, + displayName: row.display_name, + enabled: row.enabled, + clientId: row.client_id, + clientSecret: row.client_secret, + authorizationUrl: row.authorization_url, + tokenUrl: row.token_url, + userinfoUrl: row.userinfo_url, + scopes: row.scopes || [], + pkceEnabled: row.pkce_enabled ?? true, + }); + } + + return { + schemaName: moduleRow.schema_name, + privateSchemaName: moduleRow.private_schema_name, + tableName: moduleRow.table_name, + prefix: moduleRow.prefix, + rotateSecretFunction: `rotate_identity_provider_${moduleRow.prefix}_secret`, + signInIdentityFunction: 'sign_in_identity', + signUpIdentityFunction: 'sign_up_identity', + providers, + }; + }, + }); diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index a1fcbb424c..48ae2bcd37 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -12,6 +12,10 @@ * - pubkeyChallengeSettings (routing-plane pubkey_settings) * - webauthnSettings(routing-plane webauthn_settings) * - authSettings (metaschema_modules_public.sessions_module → tenant DB) + * - encryptedSecrets (constructive_store_private.platform_secrets) + * - userAuth (metaschema_modules_public.user_auth_module) + * - identityProviders (metaschema_modules_public.identity_providers_module + providers Map) + * - connectedAccounts (metaschema_modules_public.connected_accounts_module) * * To add a new per-db lookup, implement a ModuleLoader and register it: * @@ -42,12 +46,16 @@ export { agentChatLoader } from './agent-chat'; export { authSettingsLoader } from './auth-settings'; export { billingLoader } from './billing'; export { computeLoader } from './compute'; +export { connectedAccountsLoader } from './connected-accounts'; export { corsLoader } from './cors'; export { databaseSettingsLoader } from './database-settings'; +export { encryptedSecretsLoader } from './encrypted-secrets'; +export { identityProvidersLoader } from './identity-providers'; export { inferenceLogLoader } from './inference-log'; export { llmLoader } from './llm'; export { pubkeyLoader } from './pubkey'; export { rlsLoader } from './rls'; +export { userAuthLoader } from './user-auth'; export { webauthnLoader } from './webauthn'; /** @@ -57,13 +65,17 @@ import { agentChatLoader } from './agent-chat'; import { authSettingsLoader } from './auth-settings'; import { billingLoader } from './billing'; import { computeLoader } from './compute'; +import { connectedAccountsLoader } from './connected-accounts'; import { corsLoader } from './cors'; import { databaseSettingsLoader } from './database-settings'; +import { encryptedSecretsLoader } from './encrypted-secrets'; +import { identityProvidersLoader } from './identity-providers'; import { inferenceLogLoader } from './inference-log'; import { llmLoader } from './llm'; import { pubkeyLoader } from './pubkey'; import { createLoaderRegistry } from './registry'; import { rlsLoader } from './rls'; +import { userAuthLoader } from './user-auth'; import { webauthnLoader } from './webauthn'; export function createDefaultRegistry() { @@ -79,5 +91,9 @@ export function createDefaultRegistry() { registry.register(agentChatLoader); registry.register(llmLoader); registry.register(computeLoader); + registry.register(encryptedSecretsLoader); + registry.register(userAuthLoader); + registry.register(identityProvidersLoader); + registry.register(connectedAccountsLoader); 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 0000000000..731e5dda84 --- /dev/null +++ b/packages/express-context/src/loaders/user-auth.ts @@ -0,0 +1,74 @@ +/** + * 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 { UserAuthConfig } from '../types'; +import type { LoaderContext, ModuleLoader } from './types'; +import { createModuleLoader } from './create-loader'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +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, + 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 + 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 +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface UserAuthModuleRow { + schema_name: string; + session_credentials_schema_name: string | null; + 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, + sessionCredentialsSchemaName: + row.session_credentials_schema_name || 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 ab77655d8b..f792fafc6a 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -66,16 +66,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 { @@ -169,6 +182,57 @@ export interface LlmConfig { ragContextLimit: number | null; } +// ─── OAuth / Identity Types ───────────────────────────────────────────────── + +export interface EncryptedSecretsConfig { + schemaName: string; + tableName: string; +} + +export interface UserAuthConfig { + schemaName: string; + sessionCredentialsSchemaName: string; + signInFunction: string; + signUpFunction: string; + signOutFunction: string; + signInCrossOriginFunction: string | null; + requestCrossOriginTokenFunction: string | null; + extendTokenExpires: string; +} + +export interface IdentityProviderFullConfig { + slug: string; + kind: 'oauth2' | 'oidc'; + displayName: string; + enabled: boolean; + clientId: string; + clientSecret: string; + authorizationUrl: string | null; + tokenUrl: string | null; + userinfoUrl: string | null; + scopes: string[]; + pkceEnabled: boolean; +} + +export type IdentityProviderConfigMap = Map; + +export interface IdentityProvidersConfig { + schemaName: string; + privateSchemaName: string; + tableName: string; + prefix: string; + rotateSecretFunction: string; + signInIdentityFunction: string; + signUpIdentityFunction: string; + providers: IdentityProviderConfigMap; +} + +export interface ConnectedAccountsConfig { + schemaName: string; + privateSchemaName: string; + tableName: string; +} + // ─── Module Types Map ─────────────────────────────────────────────────────── /** @@ -191,6 +255,10 @@ export interface BuiltinModuleMap { agentChat: AgentChatConfig; llm: LlmConfig; compute: ComputeConfig; + encryptedSecrets: EncryptedSecretsConfig; + userAuth: UserAuthConfig; + identityProviders: IdentityProvidersConfig; + connectedAccounts: ConnectedAccountsConfig; } // ─── Constructive Context ─────────────────────────────────────────────────── diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 845a00a723..c027015d30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2523,6 +2523,9 @@ importers: '@pgpmjs/types': specifier: workspace:^ version: link:../../pgpm/types/dist + '@pgsql/quotes': + specifier: ^17.1.0 + version: 17.1.0 lru-cache: specifier: ^11.2.7 version: 11.2.7 From 92eab0bbed866cc30a3d064b2c366ccdcbe93184 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Sun, 14 Jun 2026 15:58:00 +0800 Subject: [PATCH 02/32] fixed ipm and types --- .../oauth/oauth-implementation-followup.md | 9 ++ packages/express-context/src/index.ts | 2 - .../src/loaders/auth-settings.ts | 58 ++++++------- .../src/loaders/connected-accounts.ts | 13 +-- .../src/loaders/encrypted-secrets.ts | 24 ------ .../src/loaders/identity-providers.ts | 86 +++++++++++-------- packages/express-context/src/loaders/index.ts | 4 - .../express-context/src/loaders/user-auth.ts | 18 ++-- packages/express-context/src/types.ts | 67 +++++++++++++-- 9 files changed, 153 insertions(+), 128 deletions(-) create mode 100644 docs/plan/oauth/oauth-implementation-followup.md delete mode 100644 packages/express-context/src/loaders/encrypted-secrets.ts diff --git a/docs/plan/oauth/oauth-implementation-followup.md b/docs/plan/oauth/oauth-implementation-followup.md new file mode 100644 index 0000000000..943bdb620d --- /dev/null +++ b/docs/plan/oauth/oauth-implementation-followup.md @@ -0,0 +1,9 @@ +# OAuth Implementation Follow-up + +## Identity Providers Module Scope + +- Investigate the platform `identity_providers_module` record whose `scope` is currently `app`. For platform-level OAuth configuration, the expected scope appears to be `platform`; keeping it as `app` may be a provisioning or migration artifact and can make platform OAuth semantics ambiguous. + +## Identity Auth Function Metadata + +- Consider making `sign_in_identity_function` and `sign_up_identity_function` metadata-driven. These functions are generated by the user auth module, but the current express-context config uses hardcoded function names. A future database/schema update could expose these function names through module metadata, similar to other auth functions. diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index e4f15e1f4a..17e20110fe 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -50,7 +50,6 @@ export type { ConstructiveAPIToken, ConstructiveContext, DatabaseSettings, - EncryptedSecretsConfig, IdentityProviderConfigMap, IdentityProviderFullConfig, IdentityProvidersConfig, @@ -100,7 +99,6 @@ export { createLoaderRegistry, createModuleLoader, databaseSettingsLoader, - encryptedSecretsLoader, identityProvidersLoader, inferenceLogLoader, pubkeyLoader, diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index 948d4a832c..14bb9bb977 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -10,7 +10,9 @@ * database rather than the routing database. */ -import type { AuthSettings, PgInterval } from '../types'; +import { QuoteUtils } from '@pgsql/quotes'; + +import type { AuthSettings, AuthSettingsRow } from '../types'; import { createModuleLoader } from './create-loader'; import type { LoaderContext, ModuleLoader } from './types'; @@ -23,39 +25,29 @@ const AUTH_SETTINGS_DISCOVERY_SQL = ` LIMIT 1 `; -const buildAuthSettingsQuery = (schemaName: string, tableName: string) => ` - SELECT - cookie_secure, - cookie_samesite, - cookie_domain, - cookie_httponly, - cookie_max_age, - cookie_path, - remember_me_duration, - enable_captcha, - captcha_site_key, - oauth_state_max_age, - oauth_require_verified_email, - oauth_error_redirect_path - FROM "${schemaName}"."${tableName}" - LIMIT 1 -`; - -// ─── Row Types ────────────────────────────────────────────────────────────── +function buildAuthSettingsQuery(schemaName: string, tableName: string): string { + const authSettingsTable = QuoteUtils.quoteQualifiedIdentifier( + schemaName, + tableName, + ); -interface AuthSettingsRow { - cookie_secure: boolean; - cookie_samesite: string; - cookie_domain: string | null; - cookie_httponly: boolean; - cookie_max_age: string | PgInterval | null; - cookie_path: string; - 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; + return ` + SELECT + cookie_secure, + cookie_samesite, + cookie_domain, + cookie_httponly, + cookie_max_age, + cookie_path, + remember_me_duration, + enable_captcha, + captcha_site_key, + oauth_state_max_age, + oauth_require_verified_email, + oauth_error_redirect_path + FROM ${authSettingsTable} + LIMIT 1 + `; } // ─── Loader ───────────────────────────────────────────────────────────────── diff --git a/packages/express-context/src/loaders/connected-accounts.ts b/packages/express-context/src/loaders/connected-accounts.ts index 2284326f51..885dd12f78 100644 --- a/packages/express-context/src/loaders/connected-accounts.ts +++ b/packages/express-context/src/loaders/connected-accounts.ts @@ -5,7 +5,10 @@ * Provides schema names for querying OAuth identity associations. */ -import type { ConnectedAccountsConfig } from '../types'; +import type { + ConnectedAccountsConfig, + ConnectedAccountsModuleRow, +} from '../types'; import type { LoaderContext, ModuleLoader } from './types'; import { createModuleLoader } from './create-loader'; @@ -23,14 +26,6 @@ const CONNECTED_ACCOUNTS_MODULE_SQL = ` LIMIT 1 `; -// ─── Row Types ────────────────────────────────────────────────────────────── - -interface ConnectedAccountsModuleRow { - schema_name: string; - private_schema_name: string; - table_name: string; -} - // ─── Loader ───────────────────────────────────────────────────────────────── export const connectedAccountsLoader: ModuleLoader = diff --git a/packages/express-context/src/loaders/encrypted-secrets.ts b/packages/express-context/src/loaders/encrypted-secrets.ts deleted file mode 100644 index 1f1eed028d..0000000000 --- a/packages/express-context/src/loaders/encrypted-secrets.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Platform Secrets Module Loader - * - * Returns the fixed schema and table for platform_secrets. OAuth identity - * providers store client_secret_id references in this platform-scoped table. - */ - -import type { EncryptedSecretsConfig } from '../types'; -import type { ModuleLoader } from './types'; -import { createModuleLoader } from './create-loader'; - -// ─── Loader ───────────────────────────────────────────────────────────────── - -export const encryptedSecretsLoader: ModuleLoader = - createModuleLoader({ - name: 'encryptedSecrets', - ttlMs: 5 * 60_000, - async resolve() { - return { - schemaName: 'constructive_store_private', - tableName: 'platform_secrets', - }; - }, - }); diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts index 4e9118a16a..9cc66ff658 100644 --- a/packages/express-context/src/loaders/identity-providers.ts +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -1,8 +1,8 @@ /** * Identity Providers Module Loader * - * Resolves the identity_providers_module config from metaschema_modules_public - * and loads all enabled providers with their full runtime configuration. + * Resolves the identity_providers_module config for the current request and + * loads enabled provider credentials from the platform database. */ import { QuoteUtils } from '@pgsql/quotes'; @@ -10,6 +10,9 @@ import { QuoteUtils } from '@pgsql/quotes'; import type { IdentityProviderConfigMap, IdentityProvidersConfig, + IdentityProvidersModuleRow, + PlatformDatabaseRow, + ProviderRow, } from '../types'; import type { LoaderContext, ModuleLoader } from './types'; import { createModuleLoader } from './create-loader'; @@ -29,7 +32,20 @@ const IDENTITY_PROVIDERS_MODULE_SQL = ` LIMIT 1 `; -function buildProvidersSql(ipSchema: string, ipTable: string): string { +const PLATFORM_DATABASE_SQL = ` + SELECT id AS database_id + FROM metaschema_public.database + WHERE owner_id IS NULL + ORDER BY created_at ASC + LIMIT 1 +`; + +function buildProvidersSql( + ipSchema: string, + ipTable: string, +): string { + const providersTable = QuoteUtils.quoteQualifiedIdentifier(ipSchema, ipTable); + return ` SELECT ip.slug, @@ -50,37 +66,16 @@ function buildProvidersSql(ipSchema: string, ipTable: string): string { ip.userinfo_url, ip.scopes, ip.pkce_enabled - FROM ${QuoteUtils.quoteQualifiedIdentifier(ipSchema, ipTable)} ip - LEFT JOIN "constructive_store_private"."platform_secrets" secrets ON secrets.id = ip.client_secret_id + FROM ${providersTable} ip + LEFT JOIN "constructive_store_private"."platform_secrets" secrets + ON secrets.id = ip.client_secret_id + AND secrets.database_id = $1 WHERE ip.enabled = true AND ip.client_id IS NOT NULL AND ip.client_secret_id IS NOT NULL `; } -// ─── Row Types ────────────────────────────────────────────────────────────── - -interface IdentityProvidersModuleRow { - schema_name: string; - private_schema_name: string; - table_name: string; - prefix: string; -} - -interface ProviderRow { - slug: string; - kind: 'oauth2' | 'oidc'; - display_name: string; - enabled: boolean; - client_id: string; - client_secret: string | null; - authorization_url: string | null; - token_url: string | null; - userinfo_url: string | null; - scopes: string[] | null; - pkce_enabled: boolean | null; -} - // ─── Loader ───────────────────────────────────────────────────────────────── export const identityProvidersLoader: ModuleLoader = @@ -88,7 +83,7 @@ export const identityProvidersLoader: ModuleLoader = name: 'identityProviders', ttlMs: 5 * 60_000, async resolve(ctx: LoaderContext) { - const { tenantPool, databaseId } = ctx; + const { servicesPool, tenantPool, databaseId } = ctx; const moduleResult = await tenantPool.query( IDENTITY_PROVIDERS_MODULE_SQL, @@ -96,9 +91,32 @@ export const identityProvidersLoader: ModuleLoader = ); const moduleRow = moduleResult.rows[0]; if (!moduleRow) return undefined; + const functionPrefix = moduleRow.prefix || 'platform'; + + // Provider credentials are platform-managed; auth functions remain scoped + // to the current request database. + const platformDatabaseResult = + await servicesPool.query(PLATFORM_DATABASE_SQL); + const platformDatabaseId = platformDatabaseResult.rows[0]?.database_id; + if (!platformDatabaseId) return undefined; + + const providerModuleRow = + platformDatabaseId === databaseId + ? moduleRow + : ( + await servicesPool.query( + IDENTITY_PROVIDERS_MODULE_SQL, + [platformDatabaseId], + ) + ).rows[0]; + if (!providerModuleRow) return undefined; - const providersResult = await tenantPool.query( - buildProvidersSql(moduleRow.private_schema_name, moduleRow.table_name), + const providersResult = await servicesPool.query( + buildProvidersSql( + providerModuleRow.private_schema_name, + providerModuleRow.table_name, + ), + [platformDatabaseId], ); const providers: IdentityProviderConfigMap = new Map(); @@ -125,10 +143,8 @@ export const identityProvidersLoader: ModuleLoader = schemaName: moduleRow.schema_name, privateSchemaName: moduleRow.private_schema_name, tableName: moduleRow.table_name, - prefix: moduleRow.prefix, - rotateSecretFunction: `rotate_identity_provider_${moduleRow.prefix}_secret`, - signInIdentityFunction: 'sign_in_identity', - signUpIdentityFunction: 'sign_up_identity', + prefix: functionPrefix, + rotateSecretFunction: `rotate_identity_provider_${functionPrefix}_secret`, providers, }; }, diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index 48ae2bcd37..78173cb4ba 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -12,7 +12,6 @@ * - pubkeyChallengeSettings (routing-plane pubkey_settings) * - webauthnSettings(routing-plane webauthn_settings) * - authSettings (metaschema_modules_public.sessions_module → tenant DB) - * - encryptedSecrets (constructive_store_private.platform_secrets) * - userAuth (metaschema_modules_public.user_auth_module) * - identityProviders (metaschema_modules_public.identity_providers_module + providers Map) * - connectedAccounts (metaschema_modules_public.connected_accounts_module) @@ -49,7 +48,6 @@ export { computeLoader } from './compute'; export { connectedAccountsLoader } from './connected-accounts'; export { corsLoader } from './cors'; export { databaseSettingsLoader } from './database-settings'; -export { encryptedSecretsLoader } from './encrypted-secrets'; export { identityProvidersLoader } from './identity-providers'; export { inferenceLogLoader } from './inference-log'; export { llmLoader } from './llm'; @@ -68,7 +66,6 @@ import { computeLoader } from './compute'; import { connectedAccountsLoader } from './connected-accounts'; import { corsLoader } from './cors'; import { databaseSettingsLoader } from './database-settings'; -import { encryptedSecretsLoader } from './encrypted-secrets'; import { identityProvidersLoader } from './identity-providers'; import { inferenceLogLoader } from './inference-log'; import { llmLoader } from './llm'; @@ -91,7 +88,6 @@ export function createDefaultRegistry() { registry.register(agentChatLoader); registry.register(llmLoader); registry.register(computeLoader); - registry.register(encryptedSecretsLoader); registry.register(userAuthLoader); registry.register(identityProvidersLoader); registry.register(connectedAccountsLoader); diff --git a/packages/express-context/src/loaders/user-auth.ts b/packages/express-context/src/loaders/user-auth.ts index 731e5dda84..a38b60ea65 100644 --- a/packages/express-context/src/loaders/user-auth.ts +++ b/packages/express-context/src/loaders/user-auth.ts @@ -6,7 +6,7 @@ * including identity-based OAuth/SSO auth functions. */ -import type { UserAuthConfig } from '../types'; +import type { UserAuthConfig, UserAuthModuleRow } from '../types'; import type { LoaderContext, ModuleLoader } from './types'; import { createModuleLoader } from './create-loader'; @@ -30,18 +30,8 @@ const USER_AUTH_MODULE_SQL = ` LIMIT 1 `; -// ─── Row Types ────────────────────────────────────────────────────────────── - -interface UserAuthModuleRow { - schema_name: string; - session_credentials_schema_name: string | null; - 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; -} +const SIGN_IN_IDENTITY_FUNCTION = 'sign_in_identity'; +const SIGN_UP_IDENTITY_FUNCTION = 'sign_up_identity'; // ─── Loader ───────────────────────────────────────────────────────────────── @@ -65,6 +55,8 @@ export const userAuthLoader: ModuleLoader = row.session_credentials_schema_name || row.schema_name, signInFunction: row.sign_in_function, signUpFunction: row.sign_up_function, + signInIdentityFunction: SIGN_IN_IDENTITY_FUNCTION, + signUpIdentityFunction: SIGN_UP_IDENTITY_FUNCTION, signOutFunction: row.sign_out_function, signInCrossOriginFunction: row.sign_in_cross_origin_function, requestCrossOriginTokenFunction: row.request_cross_origin_token_function, diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index f792fafc6a..d917ec013b 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -184,22 +184,45 @@ export interface LlmConfig { // ─── OAuth / Identity Types ───────────────────────────────────────────────── -export interface EncryptedSecretsConfig { - schemaName: string; - tableName: string; -} - export interface UserAuthConfig { schemaName: string; sessionCredentialsSchemaName: string; signInFunction: string; signUpFunction: string; + signInIdentityFunction: string; + signUpIdentityFunction: string; signOutFunction: string; signInCrossOriginFunction: string | null; requestCrossOriginTokenFunction: string | null; extendTokenExpires: string; } +export interface UserAuthModuleRow { + schema_name: string; + session_credentials_schema_name: string | null; + 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; +} + +export interface AuthSettingsRow { + cookie_secure: boolean; + cookie_samesite: string; + cookie_domain: string | null; + cookie_httponly: boolean; + cookie_max_age: string | PgInterval | null; + cookie_path: string; + 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; +} + export interface IdentityProviderFullConfig { slug: string; kind: 'oauth2' | 'oidc'; @@ -222,17 +245,46 @@ export interface IdentityProvidersConfig { tableName: string; prefix: string; rotateSecretFunction: string; - signInIdentityFunction: string; - signUpIdentityFunction: string; providers: IdentityProviderConfigMap; } +export interface IdentityProvidersModuleRow { + schema_name: string; + private_schema_name: string; + table_name: string; + prefix: string; +} + +export interface PlatformDatabaseRow { + database_id: string; +} + +export interface ProviderRow { + slug: string; + kind: 'oauth2' | 'oidc'; + display_name: string; + enabled: boolean; + client_id: string; + client_secret: string | null; + authorization_url: string | null; + token_url: string | null; + userinfo_url: string | null; + scopes: string[] | null; + pkce_enabled: boolean | null; +} + export interface ConnectedAccountsConfig { schemaName: string; privateSchemaName: string; tableName: string; } +export interface ConnectedAccountsModuleRow { + schema_name: string; + private_schema_name: string; + table_name: string; +} + // ─── Module Types Map ─────────────────────────────────────────────────────── /** @@ -255,7 +307,6 @@ export interface BuiltinModuleMap { agentChat: AgentChatConfig; llm: LlmConfig; compute: ComputeConfig; - encryptedSecrets: EncryptedSecretsConfig; userAuth: UserAuthConfig; identityProviders: IdentityProvidersConfig; connectedAccounts: ConnectedAccountsConfig; From 4219d1f435fcd72375ee06eb22913495ac0d873d Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 16 Jun 2026 06:30:37 +0800 Subject: [PATCH 03/32] refactor(express-context): clarify OAuth module loaders and follow-ups --- docs/plan/oauth/oauth-implementation-followup.md | 8 ++++++++ packages/express-context/src/index.ts | 8 ++++---- .../express-context/src/loaders/auth-settings.ts | 6 ++++-- ...-accounts.ts => connected-accounts-module.ts} | 8 ++++---- packages/express-context/src/loaders/index.ts | 16 ++++++++-------- .../{user-auth.ts => user-auth-module.ts} | 8 ++++---- packages/express-context/src/types.ts | 8 ++++---- 7 files changed, 36 insertions(+), 26 deletions(-) rename packages/express-context/src/loaders/{connected-accounts.ts => connected-accounts-module.ts} (88%) rename packages/express-context/src/loaders/{user-auth.ts => user-auth-module.ts} (91%) diff --git a/docs/plan/oauth/oauth-implementation-followup.md b/docs/plan/oauth/oauth-implementation-followup.md index 943bdb620d..84b48b7251 100644 --- a/docs/plan/oauth/oauth-implementation-followup.md +++ b/docs/plan/oauth/oauth-implementation-followup.md @@ -7,3 +7,11 @@ ## Identity Auth Function Metadata - Consider making `sign_in_identity_function` and `sign_up_identity_function` metadata-driven. These functions are generated by the user auth module, but the current express-context config uses hardcoded function names. A future database/schema update could expose these function names through module metadata, similar to other auth functions. + +## Auth Settings Interval Parsing + +- Fix cookie/session duration parsing after the OAuth loader work lands. `app_settings_auth.cookie_max_age`, `remember_me_duration`, and `oauth_state_max_age` are PostgreSQL `interval` columns; `pg` returns them as `PostgresInterval` objects such as `{ days: 14 }` or `{ minutes: 10 }`. The current GraphQL server cookie helper still parses these values as second strings with `parseInt`, so loader-backed auth settings can silently fall back to defaults. This is an existing cookie auth settings compatibility issue exposed by the OAuth/auth settings loader path and should be handled in a follow-up PR. + +## Loader Cache TTL Semantics + +- Revisit `createModuleLoader` cache expiration semantics. The current implementation uses `updateAgeOnGet: true`, so each cache hit refreshes the TTL and frequently accessed config may not expire while traffic continues. The reference branch changed the default to `false`, but the desired behavior needs further design discussion, including whether `updateAgeOnGet` should remain global or become a per-loader option. diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 17e20110fe..1bf7b83a99 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -46,7 +46,7 @@ export type { BuiltinModuleMap, ComputeConfig, ComputeModuleConfig, - ConnectedAccountsConfig, + ConnectedAccountsModuleConfig, ConstructiveAPIToken, ConstructiveContext, DatabaseSettings, @@ -58,7 +58,7 @@ export type { PgInterval, PubkeyChallengeSettings, RlsModule, - UserAuthConfig, + UserAuthModuleConfig, WebauthnSettings, WithPgClient, } from './types'; @@ -93,7 +93,7 @@ export { authSettingsLoader, billingLoader, computeLoader, - connectedAccountsLoader, + connectedAccountsModuleLoader, corsLoader, createDefaultRegistry, createLoaderRegistry, @@ -104,7 +104,7 @@ export { pubkeyLoader, rlsLoader, llmLoader, - userAuthLoader, + userAuthModuleLoader, webauthnLoader, } from './loaders'; diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index 14bb9bb977..e395fc6435 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -22,6 +22,7 @@ const AUTH_SETTINGS_DISCOVERY_SQL = ` SELECT s.schema_name, sm.auth_settings_table_name AS table_name FROM metaschema_modules_public.sessions_module sm JOIN metaschema_public.schema s ON s.id = sm.schema_id + WHERE sm.database_id = $1 LIMIT 1 `; @@ -56,11 +57,12 @@ export const authSettingsLoader: ModuleLoader = createModuleLoader name: 'authSettings', ttlMs: 5 * 60_000, async resolve(ctx: LoaderContext) { - const { tenantPool } = ctx; + const { tenantPool, databaseId } = ctx; // Step 1: Discover schema + table from sessions_module const discovery = await tenantPool.query<{ schema_name: string; table_name: string }>( - AUTH_SETTINGS_DISCOVERY_SQL + AUTH_SETTINGS_DISCOVERY_SQL, + [databaseId], ); const resolved = discovery.rows[0]; if (!resolved) return undefined; diff --git a/packages/express-context/src/loaders/connected-accounts.ts b/packages/express-context/src/loaders/connected-accounts-module.ts similarity index 88% rename from packages/express-context/src/loaders/connected-accounts.ts rename to packages/express-context/src/loaders/connected-accounts-module.ts index 885dd12f78..00b71db2bb 100644 --- a/packages/express-context/src/loaders/connected-accounts.ts +++ b/packages/express-context/src/loaders/connected-accounts-module.ts @@ -6,7 +6,7 @@ */ import type { - ConnectedAccountsConfig, + ConnectedAccountsModuleConfig, ConnectedAccountsModuleRow, } from '../types'; import type { LoaderContext, ModuleLoader } from './types'; @@ -28,9 +28,9 @@ const CONNECTED_ACCOUNTS_MODULE_SQL = ` // ─── Loader ───────────────────────────────────────────────────────────────── -export const connectedAccountsLoader: ModuleLoader = - createModuleLoader({ - name: 'connectedAccounts', +export const connectedAccountsModuleLoader: ModuleLoader = + createModuleLoader({ + name: 'connectedAccountsModule', ttlMs: 5 * 60_000, async resolve(ctx: LoaderContext) { const { tenantPool, databaseId } = ctx; diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index 78173cb4ba..a38609f5ef 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -12,9 +12,9 @@ * - pubkeyChallengeSettings (routing-plane pubkey_settings) * - webauthnSettings(routing-plane webauthn_settings) * - authSettings (metaschema_modules_public.sessions_module → tenant DB) - * - userAuth (metaschema_modules_public.user_auth_module) + * - userAuthModule (metaschema_modules_public.user_auth_module) * - identityProviders (metaschema_modules_public.identity_providers_module + providers Map) - * - connectedAccounts (metaschema_modules_public.connected_accounts_module) + * - connectedAccountsModule (metaschema_modules_public.connected_accounts_module) * * To add a new per-db lookup, implement a ModuleLoader and register it: * @@ -45,7 +45,7 @@ export { agentChatLoader } from './agent-chat'; export { authSettingsLoader } from './auth-settings'; export { billingLoader } from './billing'; export { computeLoader } from './compute'; -export { connectedAccountsLoader } from './connected-accounts'; +export { connectedAccountsModuleLoader } from './connected-accounts-module'; export { corsLoader } from './cors'; export { databaseSettingsLoader } from './database-settings'; export { identityProvidersLoader } from './identity-providers'; @@ -53,7 +53,7 @@ export { inferenceLogLoader } from './inference-log'; export { llmLoader } from './llm'; export { pubkeyLoader } from './pubkey'; export { rlsLoader } from './rls'; -export { userAuthLoader } from './user-auth'; +export { userAuthModuleLoader } from './user-auth-module'; export { webauthnLoader } from './webauthn'; /** @@ -63,7 +63,7 @@ import { agentChatLoader } from './agent-chat'; import { authSettingsLoader } from './auth-settings'; import { billingLoader } from './billing'; import { computeLoader } from './compute'; -import { connectedAccountsLoader } from './connected-accounts'; +import { connectedAccountsModuleLoader } from './connected-accounts-module'; import { corsLoader } from './cors'; import { databaseSettingsLoader } from './database-settings'; import { identityProvidersLoader } from './identity-providers'; @@ -72,7 +72,7 @@ import { llmLoader } from './llm'; import { pubkeyLoader } from './pubkey'; import { createLoaderRegistry } from './registry'; import { rlsLoader } from './rls'; -import { userAuthLoader } from './user-auth'; +import { userAuthModuleLoader } from './user-auth-module'; import { webauthnLoader } from './webauthn'; export function createDefaultRegistry() { @@ -88,8 +88,8 @@ export function createDefaultRegistry() { registry.register(agentChatLoader); registry.register(llmLoader); registry.register(computeLoader); - registry.register(userAuthLoader); + registry.register(userAuthModuleLoader); registry.register(identityProvidersLoader); - registry.register(connectedAccountsLoader); + registry.register(connectedAccountsModuleLoader); return registry; } diff --git a/packages/express-context/src/loaders/user-auth.ts b/packages/express-context/src/loaders/user-auth-module.ts similarity index 91% rename from packages/express-context/src/loaders/user-auth.ts rename to packages/express-context/src/loaders/user-auth-module.ts index a38b60ea65..f1f7d73dd6 100644 --- a/packages/express-context/src/loaders/user-auth.ts +++ b/packages/express-context/src/loaders/user-auth-module.ts @@ -6,7 +6,7 @@ * including identity-based OAuth/SSO auth functions. */ -import type { UserAuthConfig, UserAuthModuleRow } from '../types'; +import type { UserAuthModuleConfig, UserAuthModuleRow } from '../types'; import type { LoaderContext, ModuleLoader } from './types'; import { createModuleLoader } from './create-loader'; @@ -35,9 +35,9 @@ const SIGN_UP_IDENTITY_FUNCTION = 'sign_up_identity'; // ─── Loader ───────────────────────────────────────────────────────────────── -export const userAuthLoader: ModuleLoader = - createModuleLoader({ - name: 'userAuth', +export const userAuthModuleLoader: ModuleLoader = + createModuleLoader({ + name: 'userAuthModule', ttlMs: 5 * 60_000, async resolve(ctx: LoaderContext) { const { tenantPool, databaseId } = ctx; diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index d917ec013b..fd177db4a6 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -184,7 +184,7 @@ export interface LlmConfig { // ─── OAuth / Identity Types ───────────────────────────────────────────────── -export interface UserAuthConfig { +export interface UserAuthModuleConfig { schemaName: string; sessionCredentialsSchemaName: string; signInFunction: string; @@ -273,7 +273,7 @@ export interface ProviderRow { pkce_enabled: boolean | null; } -export interface ConnectedAccountsConfig { +export interface ConnectedAccountsModuleConfig { schemaName: string; privateSchemaName: string; tableName: string; @@ -307,9 +307,9 @@ export interface BuiltinModuleMap { agentChat: AgentChatConfig; llm: LlmConfig; compute: ComputeConfig; - userAuth: UserAuthConfig; + userAuthModule: UserAuthModuleConfig; identityProviders: IdentityProvidersConfig; - connectedAccounts: ConnectedAccountsConfig; + connectedAccountsModule: ConnectedAccountsModuleConfig; } // ─── Constructive Context ─────────────────────────────────────────────────── From 3f9c00d5c7e622a6bdbae529b3fecb940d07f602 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 16 Jun 2026 08:18:01 +0800 Subject: [PATCH 04/32] Add OAuth email verification handling and tests --- packages/oauth/README.md | 1 + packages/oauth/__tests__/oauth-client.test.ts | 282 +++++++++++++++++- packages/oauth/src/oauth-client.ts | 28 +- packages/oauth/src/providers/facebook.ts | 1 + packages/oauth/src/providers/github.ts | 25 +- packages/oauth/src/providers/google.ts | 1 + packages/oauth/src/providers/index.ts | 8 +- packages/oauth/src/providers/linkedin.ts | 1 + packages/oauth/src/types.ts | 2 + 9 files changed, 334 insertions(+), 15 deletions(-) diff --git a/packages/oauth/README.md b/packages/oauth/README.md index f8c6866041..505dc19dd0 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -120,6 +120,7 @@ interface OAuthProfile { email: string | null; name: string | null; picture: string | null; + emailVerified: boolean | null; // Whether the provider verified the email raw: unknown; // Original provider response } ``` diff --git a/packages/oauth/__tests__/oauth-client.test.ts b/packages/oauth/__tests__/oauth-client.test.ts index 129701d874..92de5a48eb 100644 --- a/packages/oauth/__tests__/oauth-client.test.ts +++ b/packages/oauth/__tests__/oauth-client.test.ts @@ -1,7 +1,23 @@ import { OAuthClient, createOAuthClient } from '../src/oauth-client'; -import { getProvider, getProviderIds } from '../src/providers'; +import { GITHUB_EMAILS_URL, getProvider, getProviderIds } from '../src/providers'; import { generateState, verifyState } from '../src/utils/state'; +const originalFetch = global.fetch; + +function jsonResponse(body: unknown, ok = true, status = 200) { + return { + ok, + status, + json: jest.fn().mockResolvedValue(body), + text: jest.fn().mockResolvedValue(typeof body === 'string' ? body : JSON.stringify(body)), + }; +} + +afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); +}); + describe('OAuthClient', () => { const config = { providers: { @@ -116,6 +132,263 @@ describe('OAuthClient', () => { expect(returnedConfig.stateCookieMaxAge).toBe(300); }); }); + + describe('getUserProfile', () => { + it('should enrich GitHub public email verification serially', async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + name: 'Octo Cat', + email: 'octo@example.com', + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'octo@example.com', + primary: true, + verified: true, + }, + ]) + ); + global.fetch = fetchMock; + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('octo@example.com'); + expect(profile.emailVerified).toBe(true); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.github.com/user', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer github-token', + 'User-Agent': 'Constructive-OAuth', + }), + }) + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + GITHUB_EMAILS_URL, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer github-token', + 'User-Agent': 'Constructive-OAuth', + }), + }) + ); + }); + + it('should preserve explicit GitHub unverified email status', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: 'octo@example.com', + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'octo@example.com', + primary: true, + verified: false, + }, + ]) + ); + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('octo@example.com'); + expect(profile.emailVerified).toBe(false); + }); + + it('should not replace a public GitHub email when no email-list match exists', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: 'public@example.com', + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'primary@example.com', + primary: true, + verified: true, + }, + ]) + ); + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('public@example.com'); + expect(profile.emailVerified).toBeNull(); + }); + + it('should use the best GitHub fallback email when profile email is private', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: null, + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'secondary@example.com', + primary: false, + verified: true, + }, + { + email: 'primary@example.com', + primary: true, + verified: true, + }, + ]) + ); + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('primary@example.com'); + expect(profile.emailVerified).toBe(true); + }); + + it('should use a verified GitHub fallback email when there is no primary verified email', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: null, + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'first@example.com', + primary: false, + verified: false, + }, + { + email: 'verified@example.com', + primary: false, + verified: true, + }, + { + email: 'primary@example.com', + primary: true, + verified: false, + }, + ]) + ); + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('verified@example.com'); + expect(profile.emailVerified).toBe(true); + }); + + it('should use the primary GitHub fallback email when no verified email exists', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: null, + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'secondary@example.com', + primary: false, + verified: false, + }, + { + email: 'primary@example.com', + primary: true, + verified: false, + }, + ]) + ); + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('primary@example.com'); + expect(profile.emailVerified).toBe(false); + }); + + it('should keep the base GitHub profile when email enrichment fails', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: 'octo@example.com', + }) + ) + .mockResolvedValueOnce(jsonResponse('forbidden', false, 403)); + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('github', 'github-token'); + + expect(profile.email).toBe('octo@example.com'); + expect(profile.emailVerified).toBeNull(); + }); + + it('should fail when the GitHub profile request fails', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce(jsonResponse('server error', false, 500)); + global.fetch = fetchMock; + + const client = createOAuthClient(config); + + await expect(client.getUserProfile('github', 'github-token')).rejects.toMatchObject({ + code: 'USER_PROFILE_FAILED', + statusCode: 500, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('should not call the GitHub emails endpoint for other providers', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + sub: '123456789', + email: 'test@gmail.com', + email_verified: true, + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient(config); + const profile = await client.getUserProfile('google', 'google-token'); + + expect(profile.email).toBe('test@gmail.com'); + expect(profile.emailVerified).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); }); describe('providers', () => { @@ -192,6 +465,7 @@ describe('provider profile mapping', () => { const profile = google.mapProfile({ sub: '123456789', email: 'test@gmail.com', + email_verified: true, name: 'Test User', picture: 'https://example.com/photo.jpg', }); @@ -201,6 +475,7 @@ describe('provider profile mapping', () => { expect(profile.email).toBe('test@gmail.com'); expect(profile.name).toBe('Test User'); expect(profile.picture).toBe('https://example.com/photo.jpg'); + expect(profile.emailVerified).toBe(true); }); it('should map GitHub profile correctly', () => { @@ -218,6 +493,7 @@ describe('provider profile mapping', () => { expect(profile.email).toBe('test@github.com'); expect(profile.name).toBe('Test User'); expect(profile.picture).toBe('https://avatars.githubusercontent.com/u/12345'); + expect(profile.emailVerified).toBeNull(); }); it('should map Facebook profile correctly', () => { @@ -234,6 +510,7 @@ describe('provider profile mapping', () => { expect(profile.email).toBe('test@facebook.com'); expect(profile.name).toBe('Test User'); expect(profile.picture).toBe('https://example.com/fb-photo.jpg'); + expect(profile.emailVerified).toBe(true); }); it('should map LinkedIn profile correctly', () => { @@ -241,6 +518,7 @@ describe('provider profile mapping', () => { const profile = linkedin.mapProfile({ sub: 'linkedin-123', email: 'test@linkedin.com', + email_verified: true, name: 'Test User', picture: 'https://example.com/li-photo.jpg', }); @@ -250,6 +528,7 @@ describe('provider profile mapping', () => { expect(profile.email).toBe('test@linkedin.com'); expect(profile.name).toBe('Test User'); expect(profile.picture).toBe('https://example.com/li-photo.jpg'); + expect(profile.emailVerified).toBe(true); }); it('should handle missing optional fields', () => { @@ -263,5 +542,6 @@ describe('provider profile mapping', () => { expect(profile.email).toBeNull(); expect(profile.name).toBeNull(); expect(profile.picture).toBeNull(); + expect(profile.emailVerified).toBeNull(); }); }); diff --git a/packages/oauth/src/oauth-client.ts b/packages/oauth/src/oauth-client.ts index 3f53bdf292..378c2952da 100644 --- a/packages/oauth/src/oauth-client.ts +++ b/packages/oauth/src/oauth-client.ts @@ -7,7 +7,7 @@ import { CallbackParams, createOAuthError, } from './types'; -import { getProvider, GITHUB_EMAILS_URL, extractPrimaryEmail } from './providers'; +import { getProvider, GITHUB_EMAILS_URL, selectGitHubEmail } from './providers'; import { generateState } from './utils/state'; export class OAuthClient { @@ -153,10 +153,10 @@ export class OAuthClient { } const data = await response.json(); - let profile = provider.mapProfile(data); + const profile = provider.mapProfile(data); - if (providerId === 'github' && !profile.email) { - profile = await this.fetchGitHubEmail(accessToken, profile); + if (providerId === 'github') { + return this.fetchGitHubEmail(accessToken, profile); } return profile; @@ -181,14 +181,26 @@ export class OAuthClient { }); if (response.ok) { - const emails = await response.json(); - const email = extractPrimaryEmail(emails); + const data = await response.json(); + if (!Array.isArray(data)) { + return profile; + } + + const email = selectGitHubEmail(data, profile.email); if (email) { - return { ...profile, email }; + if (profile.email && email.email !== profile.email) { + return profile; + } + + return { + ...profile, + email: email.email, + emailVerified: email.verified, + }; } } } catch { - // Ignore email fetch errors, return profile without email + // Ignore email fetch errors, return profile without email verification details. } return profile; } diff --git a/packages/oauth/src/providers/facebook.ts b/packages/oauth/src/providers/facebook.ts index 56c146dd1c..3becc005d6 100644 --- a/packages/oauth/src/providers/facebook.ts +++ b/packages/oauth/src/providers/facebook.ts @@ -29,6 +29,7 @@ export const facebookProvider: OAuthProviderConfig = { email: profile.email || null, name: profile.name || null, picture: profile.picture?.data?.url || null, + emailVerified: profile.email ? true : null, raw: data, }; }, diff --git a/packages/oauth/src/providers/github.ts b/packages/oauth/src/providers/github.ts index 04ba3a5290..80f33f6254 100644 --- a/packages/oauth/src/providers/github.ts +++ b/packages/oauth/src/providers/github.ts @@ -8,7 +8,7 @@ interface GitHubProfile { avatar_url?: string; } -interface GitHubEmail { +export interface GitHubEmail { email: string; primary: boolean; verified: boolean; @@ -30,6 +30,7 @@ export const githubProvider: OAuthProviderConfig = { email: profile.email || null, name: profile.name || profile.login || null, picture: profile.avatar_url || null, + emailVerified: null, // GitHub requires separate /user/emails API call raw: data, }; }, @@ -37,10 +38,24 @@ export const githubProvider: OAuthProviderConfig = { export const GITHUB_EMAILS_URL = 'https://api.github.com/user/emails'; -export function extractPrimaryEmail(emails: GitHubEmail[]): string | null { +export function selectGitHubEmail( + emails: GitHubEmail[], + preferredEmail?: string | null +): GitHubEmail | null { + if (preferredEmail) { + const preferred = emails.find((e) => e.email === preferredEmail); + if (preferred) return preferred; + } + const primary = emails.find((e) => e.primary && e.verified); - if (primary) return primary.email; + if (primary) return primary; const verified = emails.find((e) => e.verified); - if (verified) return verified.email; - return emails[0]?.email || null; + if (verified) return verified; + const primaryUnverified = emails.find((e) => e.primary); + if (primaryUnverified) return primaryUnverified; + return emails[0] || null; +} + +export function extractPrimaryEmail(emails: GitHubEmail[]): string | null { + return selectGitHubEmail(emails)?.email || null; } diff --git a/packages/oauth/src/providers/google.ts b/packages/oauth/src/providers/google.ts index eaeac0a11d..f1d17b87fa 100644 --- a/packages/oauth/src/providers/google.ts +++ b/packages/oauth/src/providers/google.ts @@ -26,6 +26,7 @@ export const googleProvider: OAuthProviderConfig = { email: profile.email || null, name: profile.name || null, picture: profile.picture || null, + emailVerified: profile.email_verified ?? null, raw: data, }; }, diff --git a/packages/oauth/src/providers/index.ts b/packages/oauth/src/providers/index.ts index ec0a37f564..44956eb87d 100644 --- a/packages/oauth/src/providers/index.ts +++ b/packages/oauth/src/providers/index.ts @@ -1,6 +1,11 @@ import { OAuthProviderConfig } from '../types'; import { googleProvider } from './google'; -import { githubProvider, GITHUB_EMAILS_URL, extractPrimaryEmail } from './github'; +import { + githubProvider, + GITHUB_EMAILS_URL, + extractPrimaryEmail, + selectGitHubEmail, +} from './github'; import { facebookProvider } from './facebook'; import { linkedinProvider } from './linkedin'; @@ -26,4 +31,5 @@ export { linkedinProvider, GITHUB_EMAILS_URL, extractPrimaryEmail, + selectGitHubEmail, }; diff --git a/packages/oauth/src/providers/linkedin.ts b/packages/oauth/src/providers/linkedin.ts index a7658c859b..ec46c69988 100644 --- a/packages/oauth/src/providers/linkedin.ts +++ b/packages/oauth/src/providers/linkedin.ts @@ -26,6 +26,7 @@ export const linkedinProvider: OAuthProviderConfig = { email: profile.email || null, name: profile.name || null, picture: profile.picture || null, + emailVerified: profile.email_verified ?? null, raw: data, }; }, diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index db4ef07437..34164812a8 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -16,6 +16,8 @@ export interface OAuthProfile { email: string | null; name: string | null; picture: string | null; + /** Whether the email is verified by the provider. null if unknown/unsupported. */ + emailVerified: boolean | null; raw: unknown; } From dfd3da3f3b2e79025795c1251df9d7966e746b37 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 16 Jun 2026 09:24:09 +0800 Subject: [PATCH 05/32] Migrate GraphQL OAuth routes and admin API integration --- .../oauth/oauth-implementation-followup.md | 4 + .../__snapshots__/merge.test.ts.snap | 3 + graphql/server/package.json | 1 + graphql/server/src/index.ts | 2 + .../src/middleware/app-settings-auth.ts | 248 +++++++ graphql/server/src/middleware/cookie.ts | 4 +- .../src/middleware/identity-providers.ts | 389 ++++++++++ graphql/server/src/middleware/oauth.ts | 674 ++++++++++++++++++ graphql/server/src/server.ts | 13 + graphql/server/src/types.ts | 1 + .../__snapshots__/merge.test.ts.snap | 3 + pgpm/env/src/env.ts | 8 +- pgpm/types/src/pgpm.ts | 10 + 13 files changed, 1357 insertions(+), 3 deletions(-) create mode 100644 graphql/server/src/middleware/app-settings-auth.ts create mode 100644 graphql/server/src/middleware/identity-providers.ts create mode 100644 graphql/server/src/middleware/oauth.ts diff --git a/docs/plan/oauth/oauth-implementation-followup.md b/docs/plan/oauth/oauth-implementation-followup.md index 84b48b7251..41fa917bc1 100644 --- a/docs/plan/oauth/oauth-implementation-followup.md +++ b/docs/plan/oauth/oauth-implementation-followup.md @@ -15,3 +15,7 @@ ## Loader Cache TTL Semantics - Revisit `createModuleLoader` cache expiration semantics. The current implementation uses `updateAgeOnGet: true`, so each cache hit refreshes the TTL and frequently accessed config may not expire while traffic continues. The reference branch changed the default to `false`, but the desired behavior needs further design discussion, including whether `updateAgeOnGet` should remain global or become a per-loader option. + +## Env Config Consolidation + +- Consider moving existing CAPTCHA and upload environment variables into the shared `@pgpmjs/env` config surface in a separate cleanup PR. The reference OAuth branch added `RECAPTCHA_SECRET_KEY` and `MAX_UPLOAD_FILE_SIZE` to `PgpmOptions`, but this OAuth migration keeps those existing middleware paths on direct `process.env` reads to avoid widening the PR scope beyond OAuth/server admin APIs. diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index 6383de2044..8810e65aa1 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -16,6 +16,7 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "roleName": "env_role", "routingSchema": "routing_public", }, + "captcha": {}, "cdn": { "awsAccessKey": "minioadmin", "awsRegion": "us-east-1", @@ -80,6 +81,7 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "useTx": false, }, }, + "oauth": {}, "pg": { "database": "config-db", "host": "override-host", @@ -109,5 +111,6 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "port": 587, "secure": false, }, + "upload": {}, } `; diff --git a/graphql/server/package.json b/graphql/server/package.json index 76d942dacf..ce7cce2e89 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/query-builder": "workspace:^", "@constructive-io/s3-utils": "workspace:^", "@constructive-io/url-domains": "workspace:^", diff --git a/graphql/server/src/index.ts b/graphql/server/src/index.ts index 53dd89cc1e..f53532c457 100644 --- a/graphql/server/src/index.ts +++ b/graphql/server/src/index.ts @@ -3,6 +3,8 @@ export * from './server'; // Export middleware for use in testing packages export { createApiMiddleware, getSubdomain, getApiConfig } from './middleware/api'; export { createAuthenticateMiddleware } from './middleware/auth'; +export { createIdentityProvidersRouter } from './middleware/identity-providers'; +export { createAppSettingsAuthRouter } from './middleware/app-settings-auth'; export { cors } from './middleware/cors'; export { graphile } from './middleware/graphile'; export { flush, flushService } from './middleware/flush'; diff --git a/graphql/server/src/middleware/app-settings-auth.ts b/graphql/server/src/middleware/app-settings-auth.ts new file mode 100644 index 0000000000..16be195175 --- /dev/null +++ b/graphql/server/src/middleware/app-settings-auth.ts @@ -0,0 +1,248 @@ +/** + * App Settings Auth API + * + * Express router for managing auth settings (cookie config, captcha, OAuth settings). + * Requires administrator role. Reads/writes to app_settings_auth table via + * the authSettings loader discovery. + * + * Routes: + * GET /app-settings-auth → get current settings + * PATCH /app-settings-auth → update settings + */ + +import express, { Router, Request, Response } from 'express'; +import { Logger } from '@pgpmjs/logger'; +import { QuoteUtils } from '@pgsql/quotes'; +import type { ConstructiveContext } from '@constructive-io/express-context'; + +import './types'; + +const log = new Logger('app-settings-auth'); + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const AUTH_SETTINGS_DISCOVERY_SQL = ` + SELECT s.schema_name, sm.auth_settings_table AS table_name + FROM metaschema_modules_public.sessions_module sm + JOIN metaschema_public.schema s ON s.id = sm.schema_id + LIMIT 1 +`; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface AuthSettingsRow { + allow_identity_sign_in: boolean; + allow_identity_sign_up: boolean; + cookie_secure: boolean; + cookie_samesite: string; + cookie_domain: string | null; + cookie_httponly: boolean; + cookie_max_age: string | null; + cookie_path: string; + remember_me_duration: string | null; + enable_captcha: boolean; + captcha_site_key: string | null; + oauth_state_max_age: string | null; + oauth_require_verified_email: boolean; + oauth_error_redirect_path: string | null; +} + +interface UpdateAuthSettingsBody { + allowIdentitySignIn?: boolean; + allowIdentitySignUp?: boolean; + cookieSecure?: boolean; + cookieSamesite?: string; + cookieDomain?: string | null; + cookieHttponly?: boolean; + cookieMaxAge?: string | null; + cookiePath?: string; + rememberMeDuration?: string | null; + enableCaptcha?: boolean; + captchaSiteKey?: string | null; + oauthStateMaxAge?: string | null; + oauthRequireVerifiedEmail?: boolean; + oauthErrorRedirectPath?: string | null; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +async function isAppMember(ctx: ConstructiveContext): Promise { + const userId = ctx.userId; + if (!userId) return false; + + // Check if user is an app member (has a record in app_memberships_sprt) + const sql = ` + SELECT 1 FROM constructive_memberships_private.app_memberships_sprt + WHERE actor_id = $1 + LIMIT 1 + `; + const result = await ctx.pool.query(sql, [userId]); + return result.rows.length > 0; +} + +async function requireAppMember(ctx: ConstructiveContext, res: Response): Promise { + if (!(await isAppMember(ctx))) { + res.status(403).json({ error: 'MEMBERSHIP_REQUIRED' }); + return false; + } + return true; +} + +async function discoverAuthSettingsTable( + ctx: ConstructiveContext, +): Promise<{ schemaName: string; tableName: string } | null> { + const result = await ctx.pool.query<{ schema_name: string; table_name: string }>( + AUTH_SETTINGS_DISCOVERY_SQL, + ); + const row = result.rows[0]; + if (!row) return null; + return { schemaName: row.schema_name, tableName: row.table_name }; +} + +// ─── Router ───────────────────────────────────────────────────────────────── + +export function createAppSettingsAuthRouter(): Router { + const router = Router(); + + // Parse JSON body for PATCH requests + router.use(express.json()); + + /** + * GET /app-settings-auth + * Get current auth settings + */ + router.get('/app-settings-auth', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!(await requireAppMember(ctx, res))) return; + + try { + const table = await discoverAuthSettingsTable(ctx); + if (!table) { + return res.status(404).json({ error: 'Auth settings module not configured' }); + } + + const sql = ` + SELECT + allow_identity_sign_in, + allow_identity_sign_up, + cookie_secure, + cookie_samesite, + cookie_domain, + cookie_httponly, + cookie_max_age::text, + cookie_path, + remember_me_duration::text, + enable_captcha, + captcha_site_key, + oauth_state_max_age::text, + oauth_require_verified_email, + oauth_error_redirect_path + FROM ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} + LIMIT 1 + `; + const result = await ctx.pool.query(sql); + const settings = result.rows[0]; + + if (!settings) { + return res.status(404).json({ error: 'Auth settings not found' }); + } + + res.json({ + allowIdentitySignIn: settings.allow_identity_sign_in, + allowIdentitySignUp: settings.allow_identity_sign_up, + cookieSecure: settings.cookie_secure, + cookieSamesite: settings.cookie_samesite, + cookieDomain: settings.cookie_domain, + cookieHttponly: settings.cookie_httponly, + cookieMaxAge: settings.cookie_max_age, + cookiePath: settings.cookie_path, + rememberMeDuration: settings.remember_me_duration, + enableCaptcha: settings.enable_captcha, + captchaSiteKey: settings.captcha_site_key, + oauthStateMaxAge: settings.oauth_state_max_age, + oauthRequireVerifiedEmail: settings.oauth_require_verified_email, + oauthErrorRedirectPath: settings.oauth_error_redirect_path, + }); + } catch (error) { + log.error('[app-settings-auth] Failed to get settings:', error); + res.status(500).json({ error: 'Failed to get settings' }); + } + }); + + /** + * PATCH /app-settings-auth + * Update auth settings + */ + router.patch('/app-settings-auth', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!(await requireAppMember(ctx, res))) return; + + const body = req.body as UpdateAuthSettingsBody; + + try { + const table = await discoverAuthSettingsTable(ctx); + if (!table) { + return res.status(404).json({ error: 'Auth settings module not configured' }); + } + + const fieldMap: Record = { + allowIdentitySignIn: 'allow_identity_sign_in', + allowIdentitySignUp: 'allow_identity_sign_up', + cookieSecure: 'cookie_secure', + cookieSamesite: 'cookie_samesite', + cookieDomain: 'cookie_domain', + cookieHttponly: 'cookie_httponly', + cookieMaxAge: 'cookie_max_age', + cookiePath: 'cookie_path', + rememberMeDuration: 'remember_me_duration', + enableCaptcha: 'enable_captcha', + captchaSiteKey: 'captcha_site_key', + oauthStateMaxAge: 'oauth_state_max_age', + oauthRequireVerifiedEmail: 'oauth_require_verified_email', + oauthErrorRedirectPath: 'oauth_error_redirect_path', + }; + + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + for (const [camelKey, snakeKey] of Object.entries(fieldMap)) { + if (camelKey in body) { + const value = (body as Record)[camelKey]; + if (snakeKey.includes('_age') || snakeKey.includes('_duration')) { + setClauses.push(`${snakeKey} = $${paramIndex++}::interval`); + } else { + setClauses.push(`${snakeKey} = $${paramIndex++}`); + } + values.push(value); + } + } + + if (setClauses.length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + const sql = ` + UPDATE ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} + SET ${setClauses.join(', ')} + `; + await ctx.pool.query(sql, values); + + log.info('[app-settings-auth] Updated settings'); + res.json({ success: true }); + } catch (error) { + log.error('[app-settings-auth] Failed to update settings:', error); + res.status(500).json({ error: 'Failed to update settings' }); + } + }); + + return router; +} diff --git a/graphql/server/src/middleware/cookie.ts b/graphql/server/src/middleware/cookie.ts index bb92446396..69218e7154 100644 --- a/graphql/server/src/middleware/cookie.ts +++ b/graphql/server/src/middleware/cookie.ts @@ -24,10 +24,10 @@ export const getSessionCookieConfig = ( ): CookieConfig => { const DEFAULT_MAX_AGE = 86400; // 24 hours let maxAge = DEFAULT_MAX_AGE; - if (rememberMe && authSettings?.rememberMeDuration) { + if (rememberMe && typeof authSettings?.rememberMeDuration === 'string') { const parsed = parseInt(authSettings.rememberMeDuration, 10); if (!isNaN(parsed)) maxAge = parsed; - } else if (authSettings?.cookieMaxAge) { + } else if (typeof authSettings?.cookieMaxAge === 'string') { const parsed = parseInt(authSettings.cookieMaxAge, 10); if (!isNaN(parsed)) maxAge = parsed; } diff --git a/graphql/server/src/middleware/identity-providers.ts b/graphql/server/src/middleware/identity-providers.ts new file mode 100644 index 0000000000..a9fafdbb2e --- /dev/null +++ b/graphql/server/src/middleware/identity-providers.ts @@ -0,0 +1,389 @@ +/** + * Admin Identity Providers API + * + * Express router for managing OAuth/OIDC identity provider configurations. + * Requires administrator role. Uses module loaders from @constructive-io/express-context + * to discover schemas and function names at runtime. + * + * Routes: + * GET /identity-providers → list all providers + * GET /identity-providers/:slug → get provider details + * PATCH /identity-providers/:slug → update provider config + * POST /identity-providers/:slug/secret → rotate client secret + */ + +import express, { Router, Request, Response } from 'express'; +import { Logger } from '@pgpmjs/logger'; +import { QuoteUtils } from '@pgsql/quotes'; +import type { ConstructiveContext } from '@constructive-io/express-context'; + +import './types'; + +const log = new Logger('admin-identity-providers'); + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface ProviderRow { + id: string; + slug: string; + kind: 'oauth2' | 'oidc'; + display_name: string; + enabled: boolean; + is_built_in: boolean; + client_id: string | null; + client_secret_id: string | null; + authorization_url: string | null; + token_url: string | null; + userinfo_url: string | null; + scopes: string[] | null; + pkce_enabled: boolean | null; +} + +interface UpdateProviderBody { + clientId?: string; + enabled?: boolean; + scopes?: string[]; + authorizationUrl?: string; + tokenUrl?: string; + userinfoUrl?: string; + pkceEnabled?: boolean; +} + +interface RotateSecretBody { + clientSecret: string; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +async function isAppMember(ctx: ConstructiveContext): Promise { + const userId = ctx.userId; + if (!userId) return false; + + // Check if user is an app member (has a record in app_memberships_sprt) + const sql = ` + SELECT 1 FROM constructive_memberships_private.app_memberships_sprt + WHERE actor_id = $1 + LIMIT 1 + `; + const result = await ctx.pool.query(sql, [userId]); + return result.rows.length > 0; +} + +async function requireAppMember(ctx: ConstructiveContext, res: Response): Promise { + if (!(await isAppMember(ctx))) { + res.status(403).json({ error: 'MEMBERSHIP_REQUIRED' }); + return false; + } + return true; +} + +// ─── Router ───────────────────────────────────────────────────────────────── + +export function createIdentityProvidersRouter(): Router { + const router = Router(); + + // Parse JSON body for PATCH/POST requests + router.use(express.json()); + + /** + * GET /identity-providers + * List all identity providers (including disabled ones) + */ + router.get('/identity-providers', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!(await requireAppMember(ctx, res))) return; + + try { + const identityProviders = await ctx.useModule('identityProviders'); + if (!identityProviders) { + return res.status(404).json({ error: 'Identity providers module not configured' }); + } + + const { privateSchemaName, tableName } = identityProviders; + + const sql = ` + SELECT + id, slug, kind, display_name, enabled, is_built_in, + client_id, client_secret_id, + authorization_url, token_url, userinfo_url, + scopes, pkce_enabled + FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + ORDER BY is_built_in DESC, slug ASC + `; + const result = await ctx.pool.query(sql); + const providers = result.rows; + + res.json({ + providers: providers.map((p) => ({ + id: p.id, + slug: p.slug, + kind: p.kind, + displayName: p.display_name, + enabled: p.enabled, + isBuiltIn: p.is_built_in, + clientId: p.client_id, + hasSecret: !!p.client_secret_id, + authorizationUrl: p.authorization_url, + tokenUrl: p.token_url, + userinfoUrl: p.userinfo_url, + scopes: p.scopes || [], + pkceEnabled: p.pkce_enabled ?? true, + })), + }); + } catch (error) { + log.error('[admin-identity-providers] Failed to list providers:', error); + res.status(500).json({ error: 'Failed to list providers' }); + } + }); + + /** + * GET /identity-providers/:slug + * Get a single provider's details + */ + router.get('/identity-providers/:slug', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!(await requireAppMember(ctx, res))) return; + + const { slug } = req.params; + + try { + const identityProviders = await ctx.useModule('identityProviders'); + if (!identityProviders) { + return res.status(404).json({ error: 'Identity providers module not configured' }); + } + + const { privateSchemaName, tableName } = identityProviders; + + const sql = ` + SELECT + id, slug, kind, display_name, enabled, is_built_in, + client_id, client_secret_id, + authorization_url, token_url, userinfo_url, + scopes, pkce_enabled + FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + WHERE slug = $1 + `; + const result = await ctx.pool.query(sql, [slug]); + const provider = result.rows[0]; + + if (!provider) { + return res.status(404).json({ error: 'Provider not found' }); + } + + res.json({ + id: provider.id, + slug: provider.slug, + kind: provider.kind, + displayName: provider.display_name, + enabled: provider.enabled, + isBuiltIn: provider.is_built_in, + clientId: provider.client_id, + hasSecret: !!provider.client_secret_id, + authorizationUrl: provider.authorization_url, + tokenUrl: provider.token_url, + userinfoUrl: provider.userinfo_url, + scopes: provider.scopes || [], + pkceEnabled: provider.pkce_enabled ?? true, + }); + } catch (error) { + log.error(`[admin-identity-providers] Failed to get provider ${slug}:`, error); + res.status(500).json({ error: 'Failed to get provider' }); + } + }); + + /** + * PATCH /identity-providers/:slug + * Update provider configuration (client_id, enabled, scopes, urls) + */ + router.patch('/identity-providers/:slug', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!(await requireAppMember(ctx, res))) return; + + const { slug } = req.params; + const body = req.body as UpdateProviderBody; + + try { + const identityProviders = await ctx.useModule('identityProviders'); + if (!identityProviders) { + return res.status(404).json({ error: 'Identity providers module not configured' }); + } + + const { privateSchemaName, tableName } = identityProviders; + + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (body.clientId !== undefined) { + setClauses.push(`client_id = $${paramIndex++}`); + values.push(body.clientId); + } + if (body.enabled !== undefined) { + setClauses.push(`enabled = $${paramIndex++}`); + values.push(body.enabled); + } + if (body.scopes !== undefined) { + setClauses.push(`scopes = $${paramIndex++}`); + values.push(body.scopes); + } + if (body.authorizationUrl !== undefined) { + setClauses.push(`authorization_url = $${paramIndex++}`); + values.push(body.authorizationUrl); + } + if (body.tokenUrl !== undefined) { + setClauses.push(`token_url = $${paramIndex++}`); + values.push(body.tokenUrl); + } + if (body.userinfoUrl !== undefined) { + setClauses.push(`userinfo_url = $${paramIndex++}`); + values.push(body.userinfoUrl); + } + if (body.pkceEnabled !== undefined) { + setClauses.push(`pkce_enabled = $${paramIndex++}`); + values.push(body.pkceEnabled); + } + + if (setClauses.length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + values.push(slug); + + const sql = ` + UPDATE ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + SET ${setClauses.join(', ')} + WHERE slug = $${paramIndex} + `; + const result = await ctx.pool.query(sql, values); + if (result.rowCount === 0) { + return res.status(404).json({ error: 'Provider not found' }); + } + + log.info(`[admin-identity-providers] Updated provider ${slug}`); + res.json({ success: true }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message === 'PROVIDER_NOT_FOUND') { + return res.status(404).json({ error: 'Provider not found' }); + } + log.error(`[admin-identity-providers] Failed to update provider ${slug}:`, error); + res.status(500).json({ error: 'Failed to update provider' }); + } + }); + + /** + * POST /identity-providers/:slug/secret + * Set or rotate the client secret for a provider + */ + router.post('/identity-providers/:slug/secret', async (req: Request, res: Response) => { + const ctx = req.constructive; + if (!ctx) { + return res.status(500).json({ error: 'Missing context' }); + } + + if (!(await requireAppMember(ctx, res))) return; + + const { slug } = req.params; + const body = req.body as RotateSecretBody; + + if (!body.clientSecret) { + return res.status(400).json({ error: 'clientSecret is required' }); + } + + try { + const identityProviders = await ctx.useModule('identityProviders'); + if (!identityProviders) { + return res.status(404).json({ error: 'Identity providers module not configured' }); + } + + const { privateSchemaName, tableName } = identityProviders; + const databaseId = ctx.databaseId; + if (!databaseId) { + return res.status(500).json({ error: 'Database context not available' }); + } + + // Get provider info + const lookupSql = ` + SELECT id, client_secret_id FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + WHERE slug = $1 + `; + const lookupResult = await ctx.pool.query<{ id: string; client_secret_id: string | null }>(lookupSql, [slug]); + if (lookupResult.rows.length === 0) { + return res.status(404).json({ error: 'Provider not found' }); + } + + const provider = lookupResult.rows[0]; + + // Ensure default namespace exists + const namespaceSql = ` + INSERT INTO constructive_infra_public.platform_namespaces (database_id, name) + VALUES ($1, 'default') + ON CONFLICT (database_id, name) DO UPDATE SET name = EXCLUDED.name + RETURNING id + `; + const namespaceResult = await ctx.pool.query<{ id: string }>(namespaceSql, [databaseId]); + const namespaceId = namespaceResult.rows[0].id; + + let secretId = provider.client_secret_id; + + if (secretId) { + // Update existing secret + const updateSecretSql = ` + UPDATE constructive_store_private.platform_secrets + SET value = $1::bytea, algo = 'plain', updated_at = now() + WHERE id = $2 + `; + await ctx.pool.query(updateSecretSql, [body.clientSecret, secretId]); + } else { + // Insert new secret + const insertSecretSql = ` + INSERT INTO constructive_store_private.platform_secrets (database_id, namespace_id, name, value, algo) + VALUES ($1, $2, $3, $4::bytea, 'plain') + RETURNING id + `; + const secretResult = await ctx.pool.query<{ id: string }>(insertSecretSql, [ + databaseId, + namespaceId, + `${slug}/client-secret`, + body.clientSecret, + ]); + secretId = secretResult.rows[0].id; + + // Link secret to provider + const linkSql = ` + UPDATE ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} + SET client_secret_id = $1 + WHERE id = $2 + `; + await ctx.pool.query(linkSql, [secretId, provider.id]); + } + + log.info(`[admin-identity-providers] Set secret for provider ${slug}`); + res.json({ success: true }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message === 'PROVIDER_NOT_FOUND') { + return res.status(404).json({ error: 'Provider not found' }); + } + if (message.includes('IDENTITY_PROVIDER_NOT_FOUND')) { + return res.status(404).json({ error: 'Provider not found' }); + } + log.error(`[admin-identity-providers] Failed to rotate secret for ${slug}:`, error); + res.status(500).json({ error: 'Failed to rotate secret' }); + } + }); + + return router; +} diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts new file mode 100644 index 0000000000..2b725a9d9a --- /dev/null +++ b/graphql/server/src/middleware/oauth.ts @@ -0,0 +1,674 @@ +/** + * 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 + * - userAuthModule → schema + function names for sign_in_identity / sign_up_identity + * - authSettings → cookie, captcha, and session config + * - connectedAccountsModule → schema for OAuth identity associations + * + * 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, getEnvVars } from '@pgpmjs/env'; +import { QuoteUtils } from '@pgsql/quotes'; +import type { ConstructiveOptions } from '@constructive-io/graphql-types'; +import type { + AuthSettings, + ConnectedAccountsModuleConfig, + ConstructiveContext, + IdentityProvidersConfig, + IdentityProviderFullConfig, + PgInterval, + UserAuthModuleConfig, +} 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'; + +/** + * Parse OAuth state interval to milliseconds. + * + * Kept local so OAuth can handle PgInterval values without changing the + * existing session cookie max-age parser in this migration. + */ +function parseIntervalToMs( + interval: string | PgInterval | null | undefined, +): number { + if (!interval) return DEFAULT_OAUTH_STATE_MAX_AGE; + if (typeof interval === 'string') { + const seconds = parseInt(interval, 10); + return Number.isNaN(seconds) + ? DEFAULT_OAUTH_STATE_MAX_AGE + : seconds * 1000; + } + + 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 * 1000 + : DEFAULT_OAUTH_STATE_MAX_AGE; +} + +// ============================================================================= +// Signed State Utilities +// ============================================================================= + +interface StatePayload { + redirect_uri: string; + provider: string; + nonce: string; + exp: number; +} + +function getStateSecret(): string { + const secret = getEnvVars().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; + userAuthModule: UserAuthModuleConfig; + authSettings: AuthSettings | undefined; + connectedAccountsModule: ConnectedAccountsModuleConfig | undefined; +} + +async function resolveOAuthModules( + ctx: ConstructiveContext, +): Promise { + const [ + identityProviders, + userAuthModule, + authSettings, + connectedAccountsModule, + ] = await Promise.all([ + ctx.useModule('identityProviders'), + ctx.useModule('userAuthModule'), + ctx.useModule('authSettings'), + ctx.useModule('connectedAccountsModule'), + ]); + + if (!identityProviders || !userAuthModule) { + return null; + } + + return { + identityProviders, + userAuthModule, + authSettings, + connectedAccountsModule, + }; +} + +// ============================================================================= +// OAuth Client Factory +// ============================================================================= + +function createOAuthClientForProvider( + providerConfig: IdentityProviderFullConfig, + baseUrl: string, +): OAuthClient { + return new OAuthClient({ + providers: { + [providerConfig.slug]: { + clientId: providerConfig.clientId, + clientSecret: providerConfig.clientSecret, + }, + }, + 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 { sessionCredentialsSchemaName } = modules.userAuthModule; + + const sql = ` + UPDATE ${QuoteUtils.quoteQualifiedIdentifier(sessionCredentialsSchemaName, 'session_credentials')} + SET ot_token = $1 + WHERE secret_hash = digest($2::text, 'sha256') + RETURNING id + `; + + // Intentional RLS bypass: runs as pool user because the session was just created + // server-side and the user hasn't authenticated via JWT yet. The accessToken hash + // ensures we only update the session we just created. + 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}`; +} + +/** + * Check if the user's email is verified by the OAuth provider. + */ +function isEmailVerified(profile: OAuthProfile): boolean { + return profile.emailVerified === true; +} + +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: [] }); + } + // Get all enabled provider slugs from the cached config map + const providers = Array.from(modules.identityProviders.providers.keys()); + 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 { authSettings, identityProviders } = modules; + const errorRedirectPath = + authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH; + + // Get provider config from cached map + const providerConfig = identityProviders.providers.get(provider); + if (!providerConfig) { + log.warn(`[oauth] Provider ${provider} not found or not configured`); + return redirectToError( + res, + baseUrl, + errorRedirectPath, + 'PROVIDER_NOT_CONFIGURED', + provider, + ); + } + + const stateMaxAge = parseIntervalToMs(authSettings?.oauthStateMaxAge); + const state = createSignedState( + { redirect_uri: redirectUri, provider }, + stateMaxAge, + ); + + res.cookie(OAUTH_STATE_COOKIE, state, { + httpOnly: authSettings?.cookieHttponly ?? true, + secure: authSettings?.cookieSecure ?? isProduction, + maxAge: stateMaxAge, + sameSite: (authSettings?.cookieSamesite as 'lax' | 'strict' | 'none') ?? '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, + ); + } + + let modules: OAuthModules | null = null; + try { + 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 { authSettings, identityProviders } = modules; + const errorRedirectPath = + authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH; + const requireVerifiedEmail = + authSettings?.oauthRequireVerifiedEmail ?? true; + + // Get provider config from cached map + const providerConfig = identityProviders.providers.get(provider); + if (!providerConfig) { + log.error(`[oauth] Provider ${provider} not found in database`); + return redirectToError( + res, + baseUrl, + errorRedirectPath, + '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 { connectedAccountsModule, userAuthModule } = modules; + const authPrivateSchema = userAuthModule.schemaName; + const signInFn = userAuthModule.signInIdentityFunction; + const signUpFn = userAuthModule.signUpIdentityFunction; + const emailVerified = isEmailVerified(profile); + + // Check if identity already exists via connectedAccounts loader + // This determines whether to sign_in or sign_up, avoiding SAVEPOINT/rollback + let identityExists = false; + if (connectedAccountsModule) { + const checkSql = ` + SELECT 1 FROM ${QuoteUtils.quoteQualifiedIdentifier(connectedAccountsModule.privateSchemaName, connectedAccountsModule.tableName)} + WHERE service = $1 AND identifier = $2 + LIMIT 1 + `; + // Intentional RLS bypass: pre-auth lookup for anonymous user who cannot query + // connected_accounts via RLS. Only checks existence by service+identifier. + const checkResult = await ctx.pool.query(checkSql, [ + profile.provider, + profile.providerId, + ]); + identityExists = checkResult.rowCount > 0; + log.info( + `[oauth] Identity check for ${profile.email}: ${identityExists ? 'exists' : 'new'}`, + ); + } + + // If new identity, check email verification requirement before proceeding + if (!identityExists && requireVerifiedEmail && !emailVerified) { + throw new Error('EMAIL_NOT_VERIFIED'); + } + + 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 details = { + provider: profile.provider, + sub: profile.providerId, + email: profile.email, + email_verified: emailVerified, + name: profile.name, + picture: profile.picture, + raw_userinfo: profile.raw, + }; + + if (identityExists) { + // Sign in existing identity + const signInSql = ` + SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(authPrivateSchema, signInFn)}( + $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, + JSON.stringify(details), + profile.email, + true, + deviceToken, + ]); + return signInResult.rows[0] || {}; + } else { + // Sign up new identity + log.info( + `[oauth] Creating new account for ${profile.email}`, + ); + const signUpSql = ` + SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(authPrivateSchema, signUpFn)}( + $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 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) { + const fallbackPath = + modules?.authSettings?.oauthErrorRedirectPath || + DEFAULT_ERROR_REDIRECT_PATH; + + // Handle specific error cases + if (error.message === 'EMAIL_NOT_VERIFIED') { + log.warn( + `[oauth] Rejecting unverified email for signup: ${provider}`, + ); + return redirectToError( + res, + baseUrl, + fallbackPath, + 'EMAIL_NOT_VERIFIED', + provider, + ); + } + + log.error(`[oauth] Callback failed for ${provider}:`, error); + redirectToError(res, baseUrl, fallbackPath, 'CALLBACK_FAILED', provider); + } + }, + ); + + return router; +} diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 97ffde9826..1c602b2331 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -42,6 +42,9 @@ import { debugMemory } from './middleware/observability/debug-memory'; import { localObservabilityOnly } from './middleware/observability/guard'; import { createRequestLogger } from './middleware/observability/request-logger'; import { getRoutingSchema } from './middleware/routing'; +import { createOAuthRoutes } from './middleware/oauth'; +import { createIdentityProvidersRouter } from './middleware/identity-providers'; +import { createAppSettingsAuthRouter } from './middleware/app-settings-auth'; const log = new Logger('server'); @@ -199,6 +202,16 @@ 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)); + + // Identity Providers API — mounted before graphile + app.use(createIdentityProvidersRouter()); + + // App Settings Auth API — mounted before graphile + app.use(createAppSettingsAuthRouter()); + // LLM Agent REST API — mounted before graphile so SSE streaming // routes are handled without going through PostGraphile app.use(createAgenticRouter()); diff --git a/graphql/server/src/types.ts b/graphql/server/src/types.ts index 85d9b64829..8d8493cee2 100644 --- a/graphql/server/src/types.ts +++ b/graphql/server/src/types.ts @@ -8,6 +8,7 @@ export type { ApiStructure, AuthSettings, DatabaseSettings, + PgInterval, PubkeyChallengeSettings, RlsModule, WebauthnSettings, diff --git a/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap b/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap index 46f89bc304..63de18bb5f 100644 --- a/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap @@ -2,6 +2,7 @@ exports[`getEnvOptions merges defaults, config, env, and overrides 1`] = ` { + "captcha": {}, "cdn": { "awsAccessKey": "minioadmin", "awsRegion": "us-east-1", @@ -54,6 +55,7 @@ exports[`getEnvOptions merges defaults, config, env, and overrides 1`] = ` "useTx": false, }, }, + "oauth": {}, "pg": { "database": "config-db", "host": "override-host", @@ -74,5 +76,6 @@ exports[`getEnvOptions merges defaults, config, env, and overrides 1`] = ` "port": 587, "secure": false, }, + "upload": {}, } `; diff --git a/pgpm/env/src/env.ts b/pgpm/env/src/env.ts index f510cf03fe..f5c0b86958 100644 --- a/pgpm/env/src/env.ts +++ b/pgpm/env/src/env.ts @@ -78,7 +78,10 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => SMTP_MAX_MESSAGES, SMTP_NAME, SMTP_LOGGER, - SMTP_DEBUG + SMTP_DEBUG, + + // OAuth env vars + OAUTH_SECRET } = env; return { @@ -173,6 +176,9 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => ...(SMTP_NAME && { name: SMTP_NAME }), ...(SMTP_LOGGER && { logger: parseEnvBoolean(SMTP_LOGGER) }), ...(SMTP_DEBUG && { debug: parseEnvBoolean(SMTP_DEBUG) }), + }, + oauth: { + ...(OAUTH_SECRET && { secret: OAUTH_SECRET }), } }; }; diff --git a/pgpm/types/src/pgpm.ts b/pgpm/types/src/pgpm.ts index ef28bd15c8..abdb8f4150 100644 --- a/pgpm/types/src/pgpm.ts +++ b/pgpm/types/src/pgpm.ts @@ -131,6 +131,14 @@ export interface CDNOptions { publicUrlPrefix?: string; } +/** + * OAuth configuration options + */ +export interface OAuthOptions { + /** Secret key for signing OAuth state tokens (HMAC-SHA256) */ + secret?: string; +} + /** * SMTP email configuration options */ @@ -299,6 +307,8 @@ export interface PgpmOptions { errorOutput?: ErrorOutputOptions; /** SMTP email configuration */ smtp?: SmtpOptions; + /** OAuth configuration */ + oauth?: OAuthOptions; /** * Directory (relative to the workspace root) where pgpm modules are installed * by `pgpm install`. Defaults to `extensions`. From 1e4699859e6f4fb0f8e4ca185982ac4b1314e0d1 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 16 Jun 2026 10:28:57 +0800 Subject: [PATCH 06/32] remove outdated oauth express middleware --- packages/oauth/README.md | 40 ---- packages/oauth/src/index.ts | 10 - packages/oauth/src/middleware/express.ts | 244 ----------------------- 3 files changed, 294 deletions(-) delete mode 100644 packages/oauth/src/middleware/express.ts diff --git a/packages/oauth/README.md b/packages/oauth/README.md index 505dc19dd0..0228ce9b56 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -54,42 +54,6 @@ const { url, state } = client.getAuthorizationUrl({ provider: 'google' }); const profile = await client.handleCallback({ provider: 'google', code }); ``` -### Express Middleware - -```typescript -import express from 'express'; -import cookieParser from 'cookie-parser'; -import { createOAuthMiddleware } from '@constructive-io/oauth'; - -const app = express(); -app.use(cookieParser()); - -const oauth = createOAuthMiddleware({ - providers: { - google: { clientId: '...', clientSecret: '...' }, - github: { clientId: '...', clientSecret: '...' }, - facebook: { clientId: '...', clientSecret: '...' }, - linkedin: { clientId: '...', clientSecret: '...' }, - }, - baseUrl: 'https://api.example.com', - onSuccess: async (profile, context) => { - // Handle successful authentication - // Create/update user in database, generate session token, etc. - return { user: profile }; - }, - onError: (error, context) => { - console.error('OAuth error:', error); - }, - successRedirect: 'https://app.example.com/dashboard', - errorRedirect: 'https://app.example.com/login?error=auth_failed', -}); - -// Mount routes -app.get('/auth/:provider', oauth.initiateAuth); -app.get('/auth/:provider/callback', oauth.handleCallback); -app.get('/auth/providers', oauth.getProviders); -``` - ## Supported Providers | Provider | Scopes | @@ -105,10 +69,6 @@ app.get('/auth/providers', oauth.getProviders); Creates an OAuth client instance. -### `createOAuthMiddleware(config)` - -Creates Express route handlers for OAuth flows. - ### `OAuthProfile` The normalized user profile returned after authentication: diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 4461826c81..10a95f5ba8 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -21,13 +21,3 @@ export { facebookProvider, linkedinProvider, } from './providers'; - -export { - createOAuthMiddleware, - OAuthMiddlewareConfig, - OAuthCallbackContext, - OAuthErrorContext, - OAuthRouteHandlers, - generateState, - verifyState, -} from './middleware/express'; diff --git a/packages/oauth/src/middleware/express.ts b/packages/oauth/src/middleware/express.ts deleted file mode 100644 index 889911e8d5..0000000000 --- a/packages/oauth/src/middleware/express.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { OAuthClient } from '../oauth-client'; -import { OAuthClientConfig, OAuthProfile, createOAuthError } from '../types'; -import { generateState, verifyState } from '../utils/state'; -import { getProviderIds } from '../providers'; - -export interface OAuthMiddlewareConfig extends OAuthClientConfig { - onSuccess: (profile: OAuthProfile, context: OAuthCallbackContext) => Promise; - onError?: (error: Error, context: OAuthErrorContext) => void; - successRedirect?: string; - errorRedirect?: string; -} - -export interface OAuthCallbackContext { - provider: string; - profile: OAuthProfile; - query: Record; -} - -export interface OAuthErrorContext { - provider?: string; - error: Error; - query: Record; -} - -export interface OAuthRouteHandlers { - initiateAuth: ( - req: { params: { provider: string }; query: Record }, - res: { - redirect: (url: string) => void; - cookie: (name: string, value: string, options: Record) => void; - status: (code: number) => { json: (data: unknown) => void }; - } - ) => void; - - handleCallback: ( - req: { - params: { provider: string }; - query: Record; - cookies: Record; - }, - res: { - redirect: (url: string) => void; - clearCookie: (name: string) => void; - status: (code: number) => { json: (data: unknown) => void }; - json: (data: unknown) => void; - } - ) => Promise; - - getProviders: ( - req: unknown, - res: { json: (data: unknown) => void } - ) => void; -} - -export function createOAuthMiddleware(config: OAuthMiddlewareConfig): OAuthRouteHandlers { - const client = new OAuthClient(config); - const clientConfig = client.getConfig(); - - const initiateAuth: OAuthRouteHandlers['initiateAuth'] = (req, res) => { - const { provider } = req.params; - - try { - const { url, state } = client.getAuthorizationUrl({ provider }); - - res.cookie(clientConfig.stateCookieName!, state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - maxAge: (clientConfig.stateCookieMaxAge || 600) * 1000, - sameSite: 'lax', - }); - - res.redirect(url); - } catch (error) { - if (config.onError) { - config.onError(error as Error, { - provider, - error: error as Error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', (error as Error).message); - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(400).json({ - error: 'oauth_error', - message: (error as Error).message, - provider, - }); - } - } - }; - - const handleCallback: OAuthRouteHandlers['handleCallback'] = async (req, res) => { - const { provider } = req.params; - const { code, state, error: oauthError, error_description } = req.query as Record< - string, - string - >; - - const storedState = req.cookies[clientConfig.stateCookieName!]; - res.clearCookie(clientConfig.stateCookieName!); - - if (oauthError) { - const error = createOAuthError( - error_description || oauthError, - 'OAUTH_PROVIDER_ERROR', - provider - ); - - if (config.onError) { - config.onError(error, { - provider, - error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', oauthError); - if (error_description) { - errorUrl.searchParams.set('error_description', error_description); - } - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(400).json({ - error: 'oauth_error', - message: error_description || oauthError, - provider, - }); - } - return; - } - - if (!verifyState(storedState, state)) { - const error = createOAuthError('Invalid state parameter', 'INVALID_STATE', provider); - - if (config.onError) { - config.onError(error, { - provider, - error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', 'invalid_state'); - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(400).json({ - error: 'invalid_state', - message: 'Invalid state parameter', - provider, - }); - } - return; - } - - if (!code) { - const error = createOAuthError('Missing authorization code', 'MISSING_CODE', provider); - - if (config.onError) { - config.onError(error, { - provider, - error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', 'missing_code'); - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(400).json({ - error: 'missing_code', - message: 'Missing authorization code', - provider, - }); - } - return; - } - - try { - const profile = await client.handleCallback({ provider, code }); - - const result = await config.onSuccess(profile, { - provider, - profile, - query: req.query as Record, - }); - - if (config.successRedirect) { - res.redirect(config.successRedirect); - } else { - res.json({ success: true, data: result }); - } - } catch (error) { - if (config.onError) { - config.onError(error as Error, { - provider, - error: error as Error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', 'callback_failed'); - errorUrl.searchParams.set('message', (error as Error).message); - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(500).json({ - error: 'callback_failed', - message: (error as Error).message, - provider, - }); - } - } - }; - - const getProviders: OAuthRouteHandlers['getProviders'] = (_req, res) => { - const configuredProviders = Object.keys(config.providers); - const availableProviders = getProviderIds().filter((id) => configuredProviders.includes(id)); - res.json({ providers: availableProviders }); - }; - - return { - initiateAuth, - handleCallback, - getProviders, - }; -} - -export { generateState, verifyState }; From 48eb2345c37de9c92dc950f790a9f986c92c75b5 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 16 Jun 2026 10:57:40 +0800 Subject: [PATCH 07/32] abstract functions of pg interval and signed state --- graphql/server/src/middleware/oauth.ts | 113 ++++-------------- graphql/server/src/utils/pg-interval.ts | 29 +++++ packages/oauth/__tests__/oauth-client.test.ts | 72 +++++++++++ packages/oauth/src/index.ts | 8 ++ packages/oauth/src/utils/signed-state.ts | 76 ++++++++++++ 5 files changed, 209 insertions(+), 89 deletions(-) create mode 100644 graphql/server/src/utils/pg-interval.ts create mode 100644 packages/oauth/src/utils/signed-state.ts diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index 2b725a9d9a..3b50c12c44 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -18,7 +18,12 @@ import crypto from 'crypto'; import { Router, Request, Response } from 'express'; -import { OAuthClient, OAuthProfile } from '@constructive-io/oauth'; +import { + OAuthClient, + OAuthProfile, + createSignedState, + verifySignedState, +} from '@constructive-io/oauth'; import { Logger } from '@pgpmjs/logger'; import { getNodeEnv, getEnvVars } from '@pgpmjs/env'; import { QuoteUtils } from '@pgsql/quotes'; @@ -29,7 +34,6 @@ import type { ConstructiveContext, IdentityProvidersConfig, IdentityProviderFullConfig, - PgInterval, UserAuthModuleConfig, } from '@constructive-io/express-context'; @@ -41,6 +45,7 @@ import { setDeviceTokenCookie, parseCookieValue, } from './cookie'; +import { pgIntervalToMilliseconds } from '../utils/pg-interval'; const log = new Logger('oauth'); @@ -48,49 +53,16 @@ const OAUTH_STATE_COOKIE = 'oauth_state'; const DEFAULT_OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes const DEFAULT_ERROR_REDIRECT_PATH = '/auth/error'; -/** - * Parse OAuth state interval to milliseconds. - * - * Kept local so OAuth can handle PgInterval values without changing the - * existing session cookie max-age parser in this migration. - */ -function parseIntervalToMs( - interval: string | PgInterval | null | undefined, -): number { - if (!interval) return DEFAULT_OAUTH_STATE_MAX_AGE; - if (typeof interval === 'string') { - const seconds = parseInt(interval, 10); - return Number.isNaN(seconds) - ? DEFAULT_OAUTH_STATE_MAX_AGE - : seconds * 1000; - } - - 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 * 1000 - : DEFAULT_OAUTH_STATE_MAX_AGE; -} - -// ============================================================================= -// Signed State Utilities -// ============================================================================= - -interface StatePayload { +interface OAuthStatePayload { redirect_uri: string; provider: string; - nonce: string; - exp: number; } -function getStateSecret(): string { +function getStateSecret(): string | undefined { + return getEnvVars().oauth?.secret; +} + +function requireStateSecret(): string { const secret = getEnvVars().oauth?.secret; if (!secret) { throw new Error('OAUTH_SECRET environment variable is required'); @@ -98,51 +70,6 @@ function getStateSecret(): string { 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 // ============================================================================= @@ -355,10 +282,15 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { ); } - const stateMaxAge = parseIntervalToMs(authSettings?.oauthStateMaxAge); + const stateMaxAge = + pgIntervalToMilliseconds(authSettings?.oauthStateMaxAge) ?? + DEFAULT_OAUTH_STATE_MAX_AGE; const state = createSignedState( { redirect_uri: redirectUri, provider }, - stateMaxAge, + { + secret: requireStateSecret(), + maxAgeMs: stateMaxAge, + }, ); res.cookie(OAUTH_STATE_COOKIE, state, { @@ -425,7 +357,10 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { ); } - const statePayload = verifySignedState(storedState as string); + const statePayload = verifySignedState( + storedState as string, + { secret: getStateSecret() }, + ); if (!statePayload) { log.warn(`[oauth] Invalid or expired state for ${provider}`); return redirectToError( diff --git a/graphql/server/src/utils/pg-interval.ts b/graphql/server/src/utils/pg-interval.ts new file mode 100644 index 0000000000..c198696ddd --- /dev/null +++ b/graphql/server/src/utils/pg-interval.ts @@ -0,0 +1,29 @@ +import type { PgInterval } from '@constructive-io/express-context'; + +/** + * Convert a PostgreSQL interval value from auth settings into milliseconds. + * + * Numeric string values are treated as seconds, matching the existing auth + * settings cookie parser behavior. + */ +export function pgIntervalToMilliseconds( + interval: string | PgInterval | null | undefined, +): number | null { + if (!interval) return null; + + if (typeof interval === 'string') { + const seconds = parseInt(interval, 10); + return Number.isNaN(seconds) ? null : seconds * 1000; + } + + 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 * 1000 : null; +} diff --git a/packages/oauth/__tests__/oauth-client.test.ts b/packages/oauth/__tests__/oauth-client.test.ts index 92de5a48eb..ce6f0b3f95 100644 --- a/packages/oauth/__tests__/oauth-client.test.ts +++ b/packages/oauth/__tests__/oauth-client.test.ts @@ -1,6 +1,7 @@ import { OAuthClient, createOAuthClient } from '../src/oauth-client'; import { GITHUB_EMAILS_URL, getProvider, getProviderIds } from '../src/providers'; import { generateState, verifyState } from '../src/utils/state'; +import { createSignedState, verifySignedState } from '../src/utils/signed-state'; const originalFetch = global.fetch; @@ -457,6 +458,77 @@ describe('state utilities', () => { expect(verifyState('short', 'much-longer-state')).toBe(false); }); }); + + describe('signed state', () => { + interface RedirectStatePayload { + redirect_uri: string; + provider: string; + } + + const payload: RedirectStatePayload = { + redirect_uri: '/dashboard', + provider: 'github', + }; + + it('should verify a signed state payload', () => { + const state = createSignedState(payload, { + secret: 'test-secret', + maxAgeMs: 60_000, + }); + + const verified = verifySignedState(state, { + secret: 'test-secret', + }); + + expect(verified).toMatchObject(payload); + expect(verified?.nonce).toHaveLength(32); + expect(typeof verified?.exp).toBe('number'); + }); + + it('should reject a signed state with the wrong secret', () => { + const state = createSignedState(payload, { + secret: 'test-secret', + maxAgeMs: 60_000, + }); + + expect( + verifySignedState(state, { + secret: 'other-secret', + }) + ).toBeNull(); + }); + + it('should reject an expired signed state', () => { + const state = createSignedState(payload, { + secret: 'test-secret', + maxAgeMs: -1, + }); + + expect( + verifySignedState(state, { + secret: 'test-secret', + }) + ).toBeNull(); + }); + + it('should return null when verifying without a secret', () => { + const state = createSignedState(payload, { + secret: 'test-secret', + maxAgeMs: 60_000, + }); + + expect(verifySignedState(state, {})).toBeNull(); + }); + + it('should require a secret when creating signed state', () => { + expect(() => + createSignedState(payload, { + secret: '', + maxAgeMs: 60_000, + }) + ).toThrow('OAuth state secret is required'); + }); + }); }); describe('provider profile mapping', () => { diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 10a95f5ba8..ca8b049304 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -21,3 +21,11 @@ export { facebookProvider, linkedinProvider, } from './providers'; + +export { + CreateSignedStateOptions, + VerifySignedStateOptions, + SignedStatePayload, + createSignedState, + verifySignedState, +} from './utils/signed-state'; diff --git a/packages/oauth/src/utils/signed-state.ts b/packages/oauth/src/utils/signed-state.ts new file mode 100644 index 0000000000..a33fbf5d3f --- /dev/null +++ b/packages/oauth/src/utils/signed-state.ts @@ -0,0 +1,76 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; + +export interface CreateSignedStateOptions { + secret: string; + maxAgeMs: number; +} + +export interface VerifySignedStateOptions { + secret?: string | null; +} + +export type SignedStatePayload = TPayload & { + nonce: string; + exp: number; +}; + +function assertSecret(secret: string | null | undefined): asserts secret is string { + if (!secret) { + throw new Error('OAuth state secret is required'); + } +} + +function signPayload(json: string, secret: string): string { + return createHmac('sha256', secret).update(json).digest('base64url'); +} + +export function createSignedState( + payload: TPayload, + options: CreateSignedStateOptions, +): string { + assertSecret(options.secret); + + const data: SignedStatePayload = { + ...payload, + nonce: randomBytes(16).toString('hex'), + exp: Date.now() + options.maxAgeMs, + }; + const json = JSON.stringify(data); + const signature = signPayload(json, options.secret); + + return `${Buffer.from(json).toString('base64url')}.${signature}`; +} + +export function verifySignedState( + state: string | null | undefined, + options: VerifySignedStateOptions, +): SignedStatePayload | null { + if (!state || !options.secret) return null; + + try { + const [payloadB64, signature] = state.split('.'); + if (!payloadB64 || !signature) return null; + + const json = Buffer.from(payloadB64, 'base64url').toString(); + const expectedSignature = signPayload(json, options.secret); + const signatureBuffer = Buffer.from(signature); + const expectedSignatureBuffer = Buffer.from(expectedSignature); + + if (signatureBuffer.length !== expectedSignatureBuffer.length) { + return null; + } + + if (!timingSafeEqual(signatureBuffer, expectedSignatureBuffer)) { + return null; + } + + const data = JSON.parse(json) as SignedStatePayload; + if (typeof data.exp !== 'number' || data.exp < Date.now()) { + return null; + } + + return data; + } catch { + return null; + } +} From 172979777b88f9992b566620ea0b68bcc4d8027f Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 16 Jun 2026 11:11:00 +0800 Subject: [PATCH 08/32] remove cross origin handoff token logic --- graphql/server/src/middleware/oauth.ts | 129 +++++++++++-------------- 1 file changed, 56 insertions(+), 73 deletions(-) diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index 3b50c12c44..37db2466b0 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -16,7 +16,6 @@ * the manual `set_config()` calls in the original implementation. */ -import crypto from 'crypto'; import { Router, Request, Response } from 'express'; import { OAuthClient, @@ -128,10 +127,6 @@ function createOAuthClientForProvider( }); } -// ============================================================================= -// Database Functions -// ============================================================================= - interface SignInIdentityResult { id?: string; user_id?: string; @@ -144,32 +139,6 @@ interface SignInIdentityResult { out_device_token?: string; } -async function generateCrossOriginToken( - ctx: ConstructiveContext, - modules: OAuthModules, - accessToken: string, -): Promise { - const otToken = crypto.randomBytes(32).toString('base64url'); - const { sessionCredentialsSchemaName } = modules.userAuthModule; - - const sql = ` - UPDATE ${QuoteUtils.quoteQualifiedIdentifier(sessionCredentialsSchemaName, 'session_credentials')} - SET ot_token = $1 - WHERE secret_hash = digest($2::text, 'sha256') - RETURNING id - `; - - // Intentional RLS bypass: runs as pool user because the session was just created - // server-side and the user hasn't authenticated via JWT yet. The accessToken hash - // ensures we only update the session we just created. - 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 // ============================================================================= @@ -180,6 +149,21 @@ function getBaseUrl(req: Request): string { return `${protocol}://${host}`; } +function normalizeRedirectUri( + redirectUri: string | undefined, + baseUrl: string, +): string | null { + const requestedRedirectUri = redirectUri || '/'; + + try { + const url = new URL(requestedRedirectUri, baseUrl); + if (url.origin !== new URL(baseUrl).origin) return null; + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return null; + } +} + /** * Check if the user's email is verified by the OAuth provider. */ @@ -237,7 +221,10 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): 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 requestedRedirectUri = + typeof req.query.redirect_uri === 'string' + ? req.query.redirect_uri + : undefined; const ctx = req.constructive; const baseUrl = getBaseUrl(req); @@ -269,6 +256,18 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { const errorRedirectPath = authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH; + const redirectUri = normalizeRedirectUri(requestedRedirectUri, baseUrl); + if (!redirectUri) { + log.warn(`[oauth] Rejected cross-origin redirect_uri for ${provider}`); + return redirectToError( + res, + baseUrl, + errorRedirectPath, + 'INVALID_REDIRECT_URI', + provider, + ); + } + // Get provider config from cached map const providerConfig = identityProviders.providers.get(provider); if (!providerConfig) { @@ -372,7 +371,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { ); } - const { redirect_uri: redirectUri } = statePayload; + const { redirect_uri: redirectUriFromState } = statePayload; const ctx = req.constructive; if (!ctx) { @@ -410,6 +409,18 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { const requireVerifiedEmail = authSettings?.oauthRequireVerifiedEmail ?? true; + const redirectUri = normalizeRedirectUri(redirectUriFromState, baseUrl); + if (!redirectUri) { + log.warn(`[oauth] Rejected cross-origin redirect_uri for ${provider}`); + return redirectToError( + res, + baseUrl, + errorRedirectPath, + 'INVALID_REDIRECT_URI', + provider, + ); + } + // Get provider config from cached map const providerConfig = identityProviders.providers.get(provider); if (!providerConfig) { @@ -433,16 +444,6 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { 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 { connectedAccountsModule, userAuthModule } = modules; const authPrivateSchema = userAuthModule.schemaName; @@ -482,7 +483,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { await client.query( `SELECT set_config('jwt.claims.user_agent', $1, true), set_config('jwt.claims.origin', $2, true)`, - [userAgent, targetOrigin], + [userAgent, baseUrl], ); const details = { @@ -547,39 +548,21 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { throw new Error('No access token returned from sign_in_identity'); } - const isCrossOrigin = targetOrigin !== currentOrigin; + const sessionConfig = getSessionCookieConfig( + modules.authSettings, + true, + ); + setSessionCookie(res, result.access_token, sessionConfig); - 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( + if (result.out_device_token) { + const deviceConfig = getDeviceTokenCookieConfig( 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); + setDeviceTokenCookie(res, result.out_device_token, deviceConfig); } + + log.info(`[oauth] OAuth success for ${profile.email}`); + return res.redirect(redirectUri); } catch (error: any) { const fallbackPath = modules?.authSettings?.oauthErrorRedirectPath || From 5f5378c68cbe1b3400317590f6a144782cf97b2e Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 16 Jun 2026 11:53:21 +0800 Subject: [PATCH 09/32] apply loaders to app setting auth middleware --- .../src/middleware/app-settings-auth.ts | 175 +++--------------- packages/express-context/src/index.ts | 3 + .../src/loaders/auth-settings.ts | 125 ++++++++++++- packages/express-context/src/loaders/index.ts | 2 +- packages/express-context/src/types.ts | 26 +++ 5 files changed, 176 insertions(+), 155 deletions(-) diff --git a/graphql/server/src/middleware/app-settings-auth.ts b/graphql/server/src/middleware/app-settings-auth.ts index 16be195175..6727774082 100644 --- a/graphql/server/src/middleware/app-settings-auth.ts +++ b/graphql/server/src/middleware/app-settings-auth.ts @@ -3,7 +3,7 @@ * * Express router for managing auth settings (cookie config, captcha, OAuth settings). * Requires administrator role. Reads/writes to app_settings_auth table via - * the authSettings loader discovery. + * the authSettings loader. * * Routes: * GET /app-settings-auth → get current settings @@ -12,65 +12,23 @@ import express, { Router, Request, Response } from 'express'; import { Logger } from '@pgpmjs/logger'; -import { QuoteUtils } from '@pgsql/quotes'; -import type { ConstructiveContext } from '@constructive-io/express-context'; +import { + updateAuthSettings, + type AuthSettings, + type ConstructiveContext, + type UpdateAuthSettingsInput, +} from '@constructive-io/express-context'; import './types'; const log = new Logger('app-settings-auth'); -// ─── SQL ──────────────────────────────────────────────────────────────────── - -const AUTH_SETTINGS_DISCOVERY_SQL = ` - SELECT s.schema_name, sm.auth_settings_table AS table_name - FROM metaschema_modules_public.sessions_module sm - JOIN metaschema_public.schema s ON s.id = sm.schema_id - LIMIT 1 -`; - -// ─── Types ────────────────────────────────────────────────────────────────── - -interface AuthSettingsRow { - allow_identity_sign_in: boolean; - allow_identity_sign_up: boolean; - cookie_secure: boolean; - cookie_samesite: string; - cookie_domain: string | null; - cookie_httponly: boolean; - cookie_max_age: string | null; - cookie_path: string; - remember_me_duration: string | null; - enable_captcha: boolean; - captcha_site_key: string | null; - oauth_state_max_age: string | null; - oauth_require_verified_email: boolean; - oauth_error_redirect_path: string | null; -} - -interface UpdateAuthSettingsBody { - allowIdentitySignIn?: boolean; - allowIdentitySignUp?: boolean; - cookieSecure?: boolean; - cookieSamesite?: string; - cookieDomain?: string | null; - cookieHttponly?: boolean; - cookieMaxAge?: string | null; - cookiePath?: string; - rememberMeDuration?: string | null; - enableCaptcha?: boolean; - captchaSiteKey?: string | null; - oauthStateMaxAge?: string | null; - oauthRequireVerifiedEmail?: boolean; - oauthErrorRedirectPath?: string | null; -} - // ─── Helpers ──────────────────────────────────────────────────────────────── async function isAppMember(ctx: ConstructiveContext): Promise { const userId = ctx.userId; if (!userId) return false; - // Check if user is an app member (has a record in app_memberships_sprt) const sql = ` SELECT 1 FROM constructive_memberships_private.app_memberships_sprt WHERE actor_id = $1 @@ -88,15 +46,23 @@ async function requireAppMember(ctx: ConstructiveContext, res: Response): Promis return true; } -async function discoverAuthSettingsTable( - ctx: ConstructiveContext, -): Promise<{ schemaName: string; tableName: string } | null> { - const result = await ctx.pool.query<{ schema_name: string; table_name: string }>( - AUTH_SETTINGS_DISCOVERY_SQL, - ); - const row = result.rows[0]; - if (!row) return null; - return { schemaName: row.schema_name, tableName: row.table_name }; +function sendAuthSettings(res: Response, settings: AuthSettings): void { + res.json({ + allowIdentitySignIn: settings.allowIdentitySignIn, + allowIdentitySignUp: settings.allowIdentitySignUp, + cookieSecure: settings.cookieSecure, + cookieSamesite: settings.cookieSamesite, + cookieDomain: settings.cookieDomain, + cookieHttponly: settings.cookieHttponly, + cookieMaxAge: settings.cookieMaxAge, + cookiePath: settings.cookiePath, + rememberMeDuration: settings.rememberMeDuration, + enableCaptcha: settings.enableCaptcha, + captchaSiteKey: settings.captchaSiteKey, + oauthStateMaxAge: settings.oauthStateMaxAge, + oauthRequireVerifiedEmail: settings.oauthRequireVerifiedEmail, + oauthErrorRedirectPath: settings.oauthErrorRedirectPath, + }); } // ─── Router ───────────────────────────────────────────────────────────────── @@ -120,53 +86,12 @@ export function createAppSettingsAuthRouter(): Router { if (!(await requireAppMember(ctx, res))) return; try { - const table = await discoverAuthSettingsTable(ctx); - if (!table) { - return res.status(404).json({ error: 'Auth settings module not configured' }); - } - - const sql = ` - SELECT - allow_identity_sign_in, - allow_identity_sign_up, - cookie_secure, - cookie_samesite, - cookie_domain, - cookie_httponly, - cookie_max_age::text, - cookie_path, - remember_me_duration::text, - enable_captcha, - captcha_site_key, - oauth_state_max_age::text, - oauth_require_verified_email, - oauth_error_redirect_path - FROM ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} - LIMIT 1 - `; - const result = await ctx.pool.query(sql); - const settings = result.rows[0]; - + const settings = await ctx.useModule('authSettings'); if (!settings) { return res.status(404).json({ error: 'Auth settings not found' }); } - res.json({ - allowIdentitySignIn: settings.allow_identity_sign_in, - allowIdentitySignUp: settings.allow_identity_sign_up, - cookieSecure: settings.cookie_secure, - cookieSamesite: settings.cookie_samesite, - cookieDomain: settings.cookie_domain, - cookieHttponly: settings.cookie_httponly, - cookieMaxAge: settings.cookie_max_age, - cookiePath: settings.cookie_path, - rememberMeDuration: settings.remember_me_duration, - enableCaptcha: settings.enable_captcha, - captchaSiteKey: settings.captcha_site_key, - oauthStateMaxAge: settings.oauth_state_max_age, - oauthRequireVerifiedEmail: settings.oauth_require_verified_email, - oauthErrorRedirectPath: settings.oauth_error_redirect_path, - }); + sendAuthSettings(res, settings); } catch (error) { log.error('[app-settings-auth] Failed to get settings:', error); res.status(500).json({ error: 'Failed to get settings' }); @@ -185,57 +110,17 @@ export function createAppSettingsAuthRouter(): Router { if (!(await requireAppMember(ctx, res))) return; - const body = req.body as UpdateAuthSettingsBody; + const body = req.body as UpdateAuthSettingsInput; try { - const table = await discoverAuthSettingsTable(ctx); - if (!table) { + const result = await updateAuthSettings(ctx, body); + if (result === 'not_configured') { return res.status(404).json({ error: 'Auth settings module not configured' }); } - - const fieldMap: Record = { - allowIdentitySignIn: 'allow_identity_sign_in', - allowIdentitySignUp: 'allow_identity_sign_up', - cookieSecure: 'cookie_secure', - cookieSamesite: 'cookie_samesite', - cookieDomain: 'cookie_domain', - cookieHttponly: 'cookie_httponly', - cookieMaxAge: 'cookie_max_age', - cookiePath: 'cookie_path', - rememberMeDuration: 'remember_me_duration', - enableCaptcha: 'enable_captcha', - captchaSiteKey: 'captcha_site_key', - oauthStateMaxAge: 'oauth_state_max_age', - oauthRequireVerifiedEmail: 'oauth_require_verified_email', - oauthErrorRedirectPath: 'oauth_error_redirect_path', - }; - - const setClauses: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - for (const [camelKey, snakeKey] of Object.entries(fieldMap)) { - if (camelKey in body) { - const value = (body as Record)[camelKey]; - if (snakeKey.includes('_age') || snakeKey.includes('_duration')) { - setClauses.push(`${snakeKey} = $${paramIndex++}::interval`); - } else { - setClauses.push(`${snakeKey} = $${paramIndex++}`); - } - values.push(value); - } - } - - if (setClauses.length === 0) { + if (result === 'no_fields') { return res.status(400).json({ error: 'No fields to update' }); } - const sql = ` - UPDATE ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} - SET ${setClauses.join(', ')} - `; - await ctx.pool.query(sql, values); - log.info('[app-settings-auth] Updated settings'); res.json({ success: true }); } catch (error) { diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 1bf7b83a99..826a4567fc 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -58,6 +58,8 @@ export type { PgInterval, PubkeyChallengeSettings, RlsModule, + UpdateAuthSettingsInput, + UpdateAuthSettingsResult, UserAuthModuleConfig, WebauthnSettings, WithPgClient, @@ -103,6 +105,7 @@ export { inferenceLogLoader, pubkeyLoader, rlsLoader, + updateAuthSettings, llmLoader, userAuthModuleLoader, webauthnLoader, diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index e395fc6435..fa2baf4878 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -11,8 +11,15 @@ */ import { QuoteUtils } from '@pgsql/quotes'; +import type { Pool } from 'pg'; -import type { AuthSettings, AuthSettingsRow } from '../types'; +import type { + AuthSettings, + AuthSettingsRow, + ConstructiveContext, + UpdateAuthSettingsInput, + UpdateAuthSettingsResult, +} from '../types'; import { createModuleLoader } from './create-loader'; import type { LoaderContext, ModuleLoader } from './types'; @@ -26,6 +33,50 @@ const AUTH_SETTINGS_DISCOVERY_SQL = ` LIMIT 1 `; +interface AuthSettingsTableRef { + schemaName: string; + tableName: string; +} + +const UPDATE_FIELD_MAP: Record< + keyof UpdateAuthSettingsInput, + { column: string; castInterval?: boolean } +> = { + allowIdentitySignIn: { column: 'allow_identity_sign_in' }, + allowIdentitySignUp: { column: 'allow_identity_sign_up' }, + cookieSecure: { column: 'cookie_secure' }, + cookieSamesite: { column: 'cookie_samesite' }, + cookieDomain: { column: 'cookie_domain' }, + cookieHttponly: { column: 'cookie_httponly' }, + cookieMaxAge: { column: 'cookie_max_age', castInterval: true }, + cookiePath: { column: 'cookie_path' }, + rememberMeDuration: { column: 'remember_me_duration', castInterval: true }, + enableCaptcha: { column: 'enable_captcha' }, + captchaSiteKey: { column: 'captcha_site_key' }, + oauthStateMaxAge: { column: 'oauth_state_max_age', castInterval: true }, + oauthRequireVerifiedEmail: { column: 'oauth_require_verified_email' }, + oauthErrorRedirectPath: { column: 'oauth_error_redirect_path' }, +}; + +async function discoverAuthSettingsTable( + pool: Pool, + databaseId: string | null | undefined, +): Promise { + if (!databaseId) return null; + + const discovery = await pool.query<{ schema_name: string; table_name: string }>( + AUTH_SETTINGS_DISCOVERY_SQL, + [databaseId], + ); + const resolved = discovery.rows[0]; + if (!resolved) return null; + + return { + schemaName: resolved.schema_name, + tableName: resolved.table_name, + }; +} + function buildAuthSettingsQuery(schemaName: string, tableName: string): string { const authSettingsTable = QuoteUtils.quoteQualifiedIdentifier( schemaName, @@ -34,6 +85,8 @@ function buildAuthSettingsQuery(schemaName: string, tableName: string): string { return ` SELECT + allow_identity_sign_in, + allow_identity_sign_up, cookie_secure, cookie_samesite, cookie_domain, @@ -51,6 +104,42 @@ function buildAuthSettingsQuery(schemaName: string, tableName: string): string { `; } +function buildUpdateAuthSettingsQuery( + schemaName: string, + tableName: string, + patch: UpdateAuthSettingsInput, +): { sql: string; values: unknown[] } | null { + const authSettingsTable = QuoteUtils.quoteQualifiedIdentifier( + schemaName, + tableName, + ); + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + for (const [field, config] of Object.entries(UPDATE_FIELD_MAP) as Array< + [keyof UpdateAuthSettingsInput, { column: string; castInterval?: boolean }] + >) { + if (field in patch) { + const cast = config.castInterval ? '::interval' : ''; + setClauses.push( + `${QuoteUtils.quoteIdentifier(config.column)} = $${paramIndex++}${cast}`, + ); + values.push(patch[field]); + } + } + + if (setClauses.length === 0) return null; + + return { + sql: ` + UPDATE ${authSettingsTable} + SET ${setClauses.join(', ')} + `, + values, + }; +} + // ─── Loader ───────────────────────────────────────────────────────────────── export const authSettingsLoader: ModuleLoader = createModuleLoader({ @@ -59,22 +148,18 @@ export const authSettingsLoader: ModuleLoader = createModuleLoader async resolve(ctx: LoaderContext) { const { tenantPool, databaseId } = ctx; - // Step 1: Discover schema + table from sessions_module - const discovery = await tenantPool.query<{ schema_name: string; table_name: string }>( - AUTH_SETTINGS_DISCOVERY_SQL, - [databaseId], - ); - const resolved = discovery.rows[0]; + const resolved = await discoverAuthSettingsTable(tenantPool, databaseId); if (!resolved) return undefined; - // Step 2: Query the actual auth settings table const result = await tenantPool.query( - buildAuthSettingsQuery(resolved.schema_name, resolved.table_name) + buildAuthSettingsQuery(resolved.schemaName, resolved.tableName), ); const row = result.rows[0]; if (!row) return undefined; return { + allowIdentitySignIn: row.allow_identity_sign_in, + allowIdentitySignUp: row.allow_identity_sign_up, cookieSecure: row.cookie_secure, cookieSamesite: row.cookie_samesite, cookieDomain: row.cookie_domain, @@ -90,3 +175,25 @@ export const authSettingsLoader: ModuleLoader = createModuleLoader }; } }); + +export async function updateAuthSettings( + ctx: ConstructiveContext, + patch: UpdateAuthSettingsInput, +): Promise { + const table = await discoverAuthSettingsTable(ctx.pool, ctx.databaseId); + if (!table) return 'not_configured'; + + const update = buildUpdateAuthSettingsQuery( + table.schemaName, + table.tableName, + patch, + ); + if (!update) return 'no_fields'; + + await ctx.pool.query(update.sql, update.values); + if (ctx.databaseId) { + authSettingsLoader.invalidate(ctx.databaseId); + } + + return 'updated'; +} diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index a38609f5ef..ce0c7dbaca 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -42,7 +42,7 @@ export { createLoaderRegistry } from './registry'; // Built-in loaders export { agentChatLoader } from './agent-chat'; -export { authSettingsLoader } from './auth-settings'; +export { authSettingsLoader, updateAuthSettings } from './auth-settings'; export { billingLoader } from './billing'; export { computeLoader } from './compute'; export { connectedAccountsModuleLoader } from './connected-accounts-module'; diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index fd177db4a6..6e90ad6deb 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -77,6 +77,8 @@ export interface PgInterval { } export interface AuthSettings { + allowIdentitySignIn?: boolean; + allowIdentitySignUp?: boolean; cookieSecure?: boolean; cookieSamesite?: string; cookieDomain?: string | null; @@ -91,6 +93,28 @@ export interface AuthSettings { oauthErrorRedirectPath?: string | null; } +export interface UpdateAuthSettingsInput { + allowIdentitySignIn?: boolean; + allowIdentitySignUp?: boolean; + cookieSecure?: boolean; + cookieSamesite?: string; + cookieDomain?: string | null; + cookieHttponly?: boolean; + cookieMaxAge?: string | null; + cookiePath?: string; + rememberMeDuration?: string | null; + enableCaptcha?: boolean; + captchaSiteKey?: string | null; + oauthStateMaxAge?: string | null; + oauthRequireVerifiedEmail?: boolean; + oauthErrorRedirectPath?: string | null; +} + +export type UpdateAuthSettingsResult = + | 'updated' + | 'not_configured' + | 'no_fields'; + export interface ApiStructure { apiId?: string; dbname: string; @@ -209,6 +233,8 @@ export interface UserAuthModuleRow { } export interface AuthSettingsRow { + allow_identity_sign_in: boolean; + allow_identity_sign_up: boolean; cookie_secure: boolean; cookie_samesite: string; cookie_domain: string | null; From 85f458d136638e3062c2d3d3a58e7d2bbba9d607 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 16 Jun 2026 12:40:35 +0800 Subject: [PATCH 10/32] Revert "apply loaders to app setting auth middleware" This reverts commit f77d301d1c780f348c0e9243633e34167ae42735. --- .../src/middleware/app-settings-auth.ts | 175 +++++++++++++++--- packages/express-context/src/index.ts | 5 +- .../src/loaders/auth-settings.ts | 125 +------------ packages/express-context/src/loaders/index.ts | 2 +- packages/express-context/src/types.ts | 26 --- 5 files changed, 156 insertions(+), 177 deletions(-) diff --git a/graphql/server/src/middleware/app-settings-auth.ts b/graphql/server/src/middleware/app-settings-auth.ts index 6727774082..16be195175 100644 --- a/graphql/server/src/middleware/app-settings-auth.ts +++ b/graphql/server/src/middleware/app-settings-auth.ts @@ -3,7 +3,7 @@ * * Express router for managing auth settings (cookie config, captcha, OAuth settings). * Requires administrator role. Reads/writes to app_settings_auth table via - * the authSettings loader. + * the authSettings loader discovery. * * Routes: * GET /app-settings-auth → get current settings @@ -12,23 +12,65 @@ import express, { Router, Request, Response } from 'express'; import { Logger } from '@pgpmjs/logger'; -import { - updateAuthSettings, - type AuthSettings, - type ConstructiveContext, - type UpdateAuthSettingsInput, -} from '@constructive-io/express-context'; +import { QuoteUtils } from '@pgsql/quotes'; +import type { ConstructiveContext } from '@constructive-io/express-context'; import './types'; const log = new Logger('app-settings-auth'); +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const AUTH_SETTINGS_DISCOVERY_SQL = ` + SELECT s.schema_name, sm.auth_settings_table AS table_name + FROM metaschema_modules_public.sessions_module sm + JOIN metaschema_public.schema s ON s.id = sm.schema_id + LIMIT 1 +`; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface AuthSettingsRow { + allow_identity_sign_in: boolean; + allow_identity_sign_up: boolean; + cookie_secure: boolean; + cookie_samesite: string; + cookie_domain: string | null; + cookie_httponly: boolean; + cookie_max_age: string | null; + cookie_path: string; + remember_me_duration: string | null; + enable_captcha: boolean; + captcha_site_key: string | null; + oauth_state_max_age: string | null; + oauth_require_verified_email: boolean; + oauth_error_redirect_path: string | null; +} + +interface UpdateAuthSettingsBody { + allowIdentitySignIn?: boolean; + allowIdentitySignUp?: boolean; + cookieSecure?: boolean; + cookieSamesite?: string; + cookieDomain?: string | null; + cookieHttponly?: boolean; + cookieMaxAge?: string | null; + cookiePath?: string; + rememberMeDuration?: string | null; + enableCaptcha?: boolean; + captchaSiteKey?: string | null; + oauthStateMaxAge?: string | null; + oauthRequireVerifiedEmail?: boolean; + oauthErrorRedirectPath?: string | null; +} + // ─── Helpers ──────────────────────────────────────────────────────────────── async function isAppMember(ctx: ConstructiveContext): Promise { const userId = ctx.userId; if (!userId) return false; + // Check if user is an app member (has a record in app_memberships_sprt) const sql = ` SELECT 1 FROM constructive_memberships_private.app_memberships_sprt WHERE actor_id = $1 @@ -46,23 +88,15 @@ async function requireAppMember(ctx: ConstructiveContext, res: Response): Promis return true; } -function sendAuthSettings(res: Response, settings: AuthSettings): void { - res.json({ - allowIdentitySignIn: settings.allowIdentitySignIn, - allowIdentitySignUp: settings.allowIdentitySignUp, - cookieSecure: settings.cookieSecure, - cookieSamesite: settings.cookieSamesite, - cookieDomain: settings.cookieDomain, - cookieHttponly: settings.cookieHttponly, - cookieMaxAge: settings.cookieMaxAge, - cookiePath: settings.cookiePath, - rememberMeDuration: settings.rememberMeDuration, - enableCaptcha: settings.enableCaptcha, - captchaSiteKey: settings.captchaSiteKey, - oauthStateMaxAge: settings.oauthStateMaxAge, - oauthRequireVerifiedEmail: settings.oauthRequireVerifiedEmail, - oauthErrorRedirectPath: settings.oauthErrorRedirectPath, - }); +async function discoverAuthSettingsTable( + ctx: ConstructiveContext, +): Promise<{ schemaName: string; tableName: string } | null> { + const result = await ctx.pool.query<{ schema_name: string; table_name: string }>( + AUTH_SETTINGS_DISCOVERY_SQL, + ); + const row = result.rows[0]; + if (!row) return null; + return { schemaName: row.schema_name, tableName: row.table_name }; } // ─── Router ───────────────────────────────────────────────────────────────── @@ -86,12 +120,53 @@ export function createAppSettingsAuthRouter(): Router { if (!(await requireAppMember(ctx, res))) return; try { - const settings = await ctx.useModule('authSettings'); + const table = await discoverAuthSettingsTable(ctx); + if (!table) { + return res.status(404).json({ error: 'Auth settings module not configured' }); + } + + const sql = ` + SELECT + allow_identity_sign_in, + allow_identity_sign_up, + cookie_secure, + cookie_samesite, + cookie_domain, + cookie_httponly, + cookie_max_age::text, + cookie_path, + remember_me_duration::text, + enable_captcha, + captcha_site_key, + oauth_state_max_age::text, + oauth_require_verified_email, + oauth_error_redirect_path + FROM ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} + LIMIT 1 + `; + const result = await ctx.pool.query(sql); + const settings = result.rows[0]; + if (!settings) { return res.status(404).json({ error: 'Auth settings not found' }); } - sendAuthSettings(res, settings); + res.json({ + allowIdentitySignIn: settings.allow_identity_sign_in, + allowIdentitySignUp: settings.allow_identity_sign_up, + cookieSecure: settings.cookie_secure, + cookieSamesite: settings.cookie_samesite, + cookieDomain: settings.cookie_domain, + cookieHttponly: settings.cookie_httponly, + cookieMaxAge: settings.cookie_max_age, + cookiePath: settings.cookie_path, + rememberMeDuration: settings.remember_me_duration, + enableCaptcha: settings.enable_captcha, + captchaSiteKey: settings.captcha_site_key, + oauthStateMaxAge: settings.oauth_state_max_age, + oauthRequireVerifiedEmail: settings.oauth_require_verified_email, + oauthErrorRedirectPath: settings.oauth_error_redirect_path, + }); } catch (error) { log.error('[app-settings-auth] Failed to get settings:', error); res.status(500).json({ error: 'Failed to get settings' }); @@ -110,17 +185,57 @@ export function createAppSettingsAuthRouter(): Router { if (!(await requireAppMember(ctx, res))) return; - const body = req.body as UpdateAuthSettingsInput; + const body = req.body as UpdateAuthSettingsBody; try { - const result = await updateAuthSettings(ctx, body); - if (result === 'not_configured') { + const table = await discoverAuthSettingsTable(ctx); + if (!table) { return res.status(404).json({ error: 'Auth settings module not configured' }); } - if (result === 'no_fields') { + + const fieldMap: Record = { + allowIdentitySignIn: 'allow_identity_sign_in', + allowIdentitySignUp: 'allow_identity_sign_up', + cookieSecure: 'cookie_secure', + cookieSamesite: 'cookie_samesite', + cookieDomain: 'cookie_domain', + cookieHttponly: 'cookie_httponly', + cookieMaxAge: 'cookie_max_age', + cookiePath: 'cookie_path', + rememberMeDuration: 'remember_me_duration', + enableCaptcha: 'enable_captcha', + captchaSiteKey: 'captcha_site_key', + oauthStateMaxAge: 'oauth_state_max_age', + oauthRequireVerifiedEmail: 'oauth_require_verified_email', + oauthErrorRedirectPath: 'oauth_error_redirect_path', + }; + + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + for (const [camelKey, snakeKey] of Object.entries(fieldMap)) { + if (camelKey in body) { + const value = (body as Record)[camelKey]; + if (snakeKey.includes('_age') || snakeKey.includes('_duration')) { + setClauses.push(`${snakeKey} = $${paramIndex++}::interval`); + } else { + setClauses.push(`${snakeKey} = $${paramIndex++}`); + } + values.push(value); + } + } + + if (setClauses.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } + const sql = ` + UPDATE ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} + SET ${setClauses.join(', ')} + `; + await ctx.pool.query(sql, values); + log.info('[app-settings-auth] Updated settings'); res.json({ success: true }); } catch (error) { diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 826a4567fc..c712631b9e 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -58,8 +58,6 @@ export type { PgInterval, PubkeyChallengeSettings, RlsModule, - UpdateAuthSettingsInput, - UpdateAuthSettingsResult, UserAuthModuleConfig, WebauthnSettings, WithPgClient, @@ -105,9 +103,8 @@ export { inferenceLogLoader, pubkeyLoader, rlsLoader, - updateAuthSettings, - llmLoader, userAuthModuleLoader, + llmLoader, webauthnLoader, } from './loaders'; diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index fa2baf4878..62e4fb4d15 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -11,15 +11,8 @@ */ import { QuoteUtils } from '@pgsql/quotes'; -import type { Pool } from 'pg'; -import type { - AuthSettings, - AuthSettingsRow, - ConstructiveContext, - UpdateAuthSettingsInput, - UpdateAuthSettingsResult, -} from '../types'; +import type { AuthSettings, AuthSettingsRow } from '../types'; import { createModuleLoader } from './create-loader'; import type { LoaderContext, ModuleLoader } from './types'; @@ -33,50 +26,6 @@ const AUTH_SETTINGS_DISCOVERY_SQL = ` LIMIT 1 `; -interface AuthSettingsTableRef { - schemaName: string; - tableName: string; -} - -const UPDATE_FIELD_MAP: Record< - keyof UpdateAuthSettingsInput, - { column: string; castInterval?: boolean } -> = { - allowIdentitySignIn: { column: 'allow_identity_sign_in' }, - allowIdentitySignUp: { column: 'allow_identity_sign_up' }, - cookieSecure: { column: 'cookie_secure' }, - cookieSamesite: { column: 'cookie_samesite' }, - cookieDomain: { column: 'cookie_domain' }, - cookieHttponly: { column: 'cookie_httponly' }, - cookieMaxAge: { column: 'cookie_max_age', castInterval: true }, - cookiePath: { column: 'cookie_path' }, - rememberMeDuration: { column: 'remember_me_duration', castInterval: true }, - enableCaptcha: { column: 'enable_captcha' }, - captchaSiteKey: { column: 'captcha_site_key' }, - oauthStateMaxAge: { column: 'oauth_state_max_age', castInterval: true }, - oauthRequireVerifiedEmail: { column: 'oauth_require_verified_email' }, - oauthErrorRedirectPath: { column: 'oauth_error_redirect_path' }, -}; - -async function discoverAuthSettingsTable( - pool: Pool, - databaseId: string | null | undefined, -): Promise { - if (!databaseId) return null; - - const discovery = await pool.query<{ schema_name: string; table_name: string }>( - AUTH_SETTINGS_DISCOVERY_SQL, - [databaseId], - ); - const resolved = discovery.rows[0]; - if (!resolved) return null; - - return { - schemaName: resolved.schema_name, - tableName: resolved.table_name, - }; -} - function buildAuthSettingsQuery(schemaName: string, tableName: string): string { const authSettingsTable = QuoteUtils.quoteQualifiedIdentifier( schemaName, @@ -85,8 +34,6 @@ function buildAuthSettingsQuery(schemaName: string, tableName: string): string { return ` SELECT - allow_identity_sign_in, - allow_identity_sign_up, cookie_secure, cookie_samesite, cookie_domain, @@ -104,42 +51,6 @@ function buildAuthSettingsQuery(schemaName: string, tableName: string): string { `; } -function buildUpdateAuthSettingsQuery( - schemaName: string, - tableName: string, - patch: UpdateAuthSettingsInput, -): { sql: string; values: unknown[] } | null { - const authSettingsTable = QuoteUtils.quoteQualifiedIdentifier( - schemaName, - tableName, - ); - const setClauses: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - for (const [field, config] of Object.entries(UPDATE_FIELD_MAP) as Array< - [keyof UpdateAuthSettingsInput, { column: string; castInterval?: boolean }] - >) { - if (field in patch) { - const cast = config.castInterval ? '::interval' : ''; - setClauses.push( - `${QuoteUtils.quoteIdentifier(config.column)} = $${paramIndex++}${cast}`, - ); - values.push(patch[field]); - } - } - - if (setClauses.length === 0) return null; - - return { - sql: ` - UPDATE ${authSettingsTable} - SET ${setClauses.join(', ')} - `, - values, - }; -} - // ─── Loader ───────────────────────────────────────────────────────────────── export const authSettingsLoader: ModuleLoader = createModuleLoader({ @@ -148,18 +59,22 @@ export const authSettingsLoader: ModuleLoader = createModuleLoader async resolve(ctx: LoaderContext) { const { tenantPool, databaseId } = ctx; - const resolved = await discoverAuthSettingsTable(tenantPool, databaseId); + // Step 1: Discover schema + table from sessions_module + const discovery = await tenantPool.query<{ schema_name: string; table_name: string }>( + AUTH_SETTINGS_DISCOVERY_SQL, + [databaseId], + ); + const resolved = discovery.rows[0]; if (!resolved) return undefined; + // Step 2: Query the actual auth settings table const result = await tenantPool.query( - buildAuthSettingsQuery(resolved.schemaName, resolved.tableName), + buildAuthSettingsQuery(resolved.schema_name, resolved.table_name), ); const row = result.rows[0]; if (!row) return undefined; return { - allowIdentitySignIn: row.allow_identity_sign_in, - allowIdentitySignUp: row.allow_identity_sign_up, cookieSecure: row.cookie_secure, cookieSamesite: row.cookie_samesite, cookieDomain: row.cookie_domain, @@ -175,25 +90,3 @@ export const authSettingsLoader: ModuleLoader = createModuleLoader }; } }); - -export async function updateAuthSettings( - ctx: ConstructiveContext, - patch: UpdateAuthSettingsInput, -): Promise { - const table = await discoverAuthSettingsTable(ctx.pool, ctx.databaseId); - if (!table) return 'not_configured'; - - const update = buildUpdateAuthSettingsQuery( - table.schemaName, - table.tableName, - patch, - ); - if (!update) return 'no_fields'; - - await ctx.pool.query(update.sql, update.values); - if (ctx.databaseId) { - authSettingsLoader.invalidate(ctx.databaseId); - } - - return 'updated'; -} diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index ce0c7dbaca..a38609f5ef 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -42,7 +42,7 @@ export { createLoaderRegistry } from './registry'; // Built-in loaders export { agentChatLoader } from './agent-chat'; -export { authSettingsLoader, updateAuthSettings } from './auth-settings'; +export { authSettingsLoader } from './auth-settings'; export { billingLoader } from './billing'; export { computeLoader } from './compute'; export { connectedAccountsModuleLoader } from './connected-accounts-module'; diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 6e90ad6deb..fd177db4a6 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -77,8 +77,6 @@ export interface PgInterval { } export interface AuthSettings { - allowIdentitySignIn?: boolean; - allowIdentitySignUp?: boolean; cookieSecure?: boolean; cookieSamesite?: string; cookieDomain?: string | null; @@ -93,28 +91,6 @@ export interface AuthSettings { oauthErrorRedirectPath?: string | null; } -export interface UpdateAuthSettingsInput { - allowIdentitySignIn?: boolean; - allowIdentitySignUp?: boolean; - cookieSecure?: boolean; - cookieSamesite?: string; - cookieDomain?: string | null; - cookieHttponly?: boolean; - cookieMaxAge?: string | null; - cookiePath?: string; - rememberMeDuration?: string | null; - enableCaptcha?: boolean; - captchaSiteKey?: string | null; - oauthStateMaxAge?: string | null; - oauthRequireVerifiedEmail?: boolean; - oauthErrorRedirectPath?: string | null; -} - -export type UpdateAuthSettingsResult = - | 'updated' - | 'not_configured' - | 'no_fields'; - export interface ApiStructure { apiId?: string; dbname: string; @@ -233,8 +209,6 @@ export interface UserAuthModuleRow { } export interface AuthSettingsRow { - allow_identity_sign_in: boolean; - allow_identity_sign_up: boolean; cookie_secure: boolean; cookie_samesite: string; cookie_domain: string | null; From db41a420b828039123c5398ef249343a28f0db4f Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 16 Jun 2026 12:42:00 +0800 Subject: [PATCH 11/32] update followup --- .../oauth/oauth-implementation-followup.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/plan/oauth/oauth-implementation-followup.md b/docs/plan/oauth/oauth-implementation-followup.md index 41fa917bc1..4939076909 100644 --- a/docs/plan/oauth/oauth-implementation-followup.md +++ b/docs/plan/oauth/oauth-implementation-followup.md @@ -12,9 +12,23 @@ - Fix cookie/session duration parsing after the OAuth loader work lands. `app_settings_auth.cookie_max_age`, `remember_me_duration`, and `oauth_state_max_age` are PostgreSQL `interval` columns; `pg` returns them as `PostgresInterval` objects such as `{ days: 14 }` or `{ minutes: 10 }`. The current GraphQL server cookie helper still parses these values as second strings with `parseInt`, so loader-backed auth settings can silently fall back to defaults. This is an existing cookie auth settings compatibility issue exposed by the OAuth/auth settings loader path and should be handled in a follow-up PR. -## Loader Cache TTL Semantics +## App Settings Auth Loader Refactor -- Revisit `createModuleLoader` cache expiration semantics. The current implementation uses `updateAgeOnGet: true`, so each cache hit refreshes the TTL and frequently accessed config may not expire while traffic continues. The reference branch changed the default to `false`, but the desired behavior needs further design discussion, including whether `updateAgeOnGet` should remain global or become a per-loader option. +- Revisit the reverted `f77d301d1c780f348c0e9243633e34167ae42735` change that moved `graphql/server/src/middleware/app-settings-auth.ts` onto the `authSettings` loader and an `updateAuthSettings()` helper. The `app-settings-auth` REST API came from the reviewed admin identity providers branch, but this loader-backed rewrite was an extra `feat/oauth-reorg` refactor and has been reverted out of the migration scope. If reintroduced, it should be done as a dedicated cleanup PR that preserves the admin branch API behavior, keeps module-not-configured vs settings-not-found error semantics clear, includes `allowIdentitySignIn` and `allowIdentitySignUp` in both reads and writes, and defines post-write invalidation together with the broader loader cache policy below. + +## Loader Cache Invalidation and TTL Semantics + +- Revisit `createModuleLoader` cache expiration and invalidation semantics. The current implementation uses `updateAgeOnGet: true`, so each cache hit refreshes the TTL and frequently accessed config may not expire while traffic continues. The reference branch changed the default to `false`, but the desired behavior should be decided together with explicit invalidation for writes performed by our own systems. + +- Known changes made through this process, such as admin APIs updating auth settings or identity providers, should invalidate the relevant loader cache after the database write succeeds. The current identity provider admin writes do not yet invalidate the cached OAuth provider config, and any future app-settings-auth loader-backed write path should invalidate `authSettingsLoader` as part of the same design. Recommended shape: loaders own read, transform, cache, and invalidate; services or repositories own validate, authorize, write, audit, and post-write invalidate. For example, `identityProvidersService.updateProvider(ctx, input)` writes the provider config and then calls a targeted invalidation such as `registry.invalidate(ctx.databaseId, "identityProviders")` or the equivalent loader-specific invalidation API. + +- Unknown external changes, such as manual SQL, migrations, or another service updating module configuration, cannot be invalidated precisely by this process. Baseline approach: keep `updateAgeOnGet: false` so TTL remains a bounded staleness window, then reload from the database on the next read after TTL expiry. If stronger freshness is needed later, add an optional lightweight fingerprint probe near loader resolution, for example `getFingerprint(ctx)` using `updated_at`, version, or checksum plus `revalidateAfterMs` as the minimum interval between probes. This lets loaders detect external changes faster than full TTL expiry without fully reloading config on every request. + +- Recommendation: set `updateAgeOnGet` to `false`, add explicit post-write invalidation for known admin writes, and rely on TTL as the fallback for unknown external changes. Add fingerprint probing only if TTL-based staleness becomes too slow in practice. + +## API Service Cache Loader Snapshots + +- Revisit `graphql/server/src/middleware/api.ts` storing resolved loader values inside the cached `ApiStructure`. The API resolver currently resolves mutable module settings such as `authSettings`, `corsOrigins`, `databaseSettings`, `pubkeyChallengeSettings`, and `webauthnSettings`, then stores the whole API structure in `svcCache`, whose TTL is effectively long-lived. This can bypass each loader's own TTL and invalidation path; for example, a future loader-backed auth settings update path could invalidate `authSettingsLoader`, while middleware that reads `req.api.authSettings` could still see the old value from `svcCache`. Consider narrowing `svcCache` to stable API routing fields only, resolving mutable module settings through `ctx.useModule(...)` at use sites, or adding coordinated `svcCache` invalidation whenever loader-backed settings are updated. ## Env Config Consolidation From 3c4399492e3979fb2ee003bea3c6a92cd38124d6 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 16 Jun 2026 12:46:06 +0800 Subject: [PATCH 12/32] Reapply "apply loaders to app setting auth middleware" This reverts commit 1395ace5ab6222441c9572435ea647306f62bfa5. --- .../src/middleware/app-settings-auth.ts | 175 +++--------------- packages/express-context/src/index.ts | 3 + .../src/loaders/auth-settings.ts | 125 ++++++++++++- packages/express-context/src/loaders/index.ts | 2 +- packages/express-context/src/types.ts | 26 +++ 5 files changed, 176 insertions(+), 155 deletions(-) diff --git a/graphql/server/src/middleware/app-settings-auth.ts b/graphql/server/src/middleware/app-settings-auth.ts index 16be195175..6727774082 100644 --- a/graphql/server/src/middleware/app-settings-auth.ts +++ b/graphql/server/src/middleware/app-settings-auth.ts @@ -3,7 +3,7 @@ * * Express router for managing auth settings (cookie config, captcha, OAuth settings). * Requires administrator role. Reads/writes to app_settings_auth table via - * the authSettings loader discovery. + * the authSettings loader. * * Routes: * GET /app-settings-auth → get current settings @@ -12,65 +12,23 @@ import express, { Router, Request, Response } from 'express'; import { Logger } from '@pgpmjs/logger'; -import { QuoteUtils } from '@pgsql/quotes'; -import type { ConstructiveContext } from '@constructive-io/express-context'; +import { + updateAuthSettings, + type AuthSettings, + type ConstructiveContext, + type UpdateAuthSettingsInput, +} from '@constructive-io/express-context'; import './types'; const log = new Logger('app-settings-auth'); -// ─── SQL ──────────────────────────────────────────────────────────────────── - -const AUTH_SETTINGS_DISCOVERY_SQL = ` - SELECT s.schema_name, sm.auth_settings_table AS table_name - FROM metaschema_modules_public.sessions_module sm - JOIN metaschema_public.schema s ON s.id = sm.schema_id - LIMIT 1 -`; - -// ─── Types ────────────────────────────────────────────────────────────────── - -interface AuthSettingsRow { - allow_identity_sign_in: boolean; - allow_identity_sign_up: boolean; - cookie_secure: boolean; - cookie_samesite: string; - cookie_domain: string | null; - cookie_httponly: boolean; - cookie_max_age: string | null; - cookie_path: string; - remember_me_duration: string | null; - enable_captcha: boolean; - captcha_site_key: string | null; - oauth_state_max_age: string | null; - oauth_require_verified_email: boolean; - oauth_error_redirect_path: string | null; -} - -interface UpdateAuthSettingsBody { - allowIdentitySignIn?: boolean; - allowIdentitySignUp?: boolean; - cookieSecure?: boolean; - cookieSamesite?: string; - cookieDomain?: string | null; - cookieHttponly?: boolean; - cookieMaxAge?: string | null; - cookiePath?: string; - rememberMeDuration?: string | null; - enableCaptcha?: boolean; - captchaSiteKey?: string | null; - oauthStateMaxAge?: string | null; - oauthRequireVerifiedEmail?: boolean; - oauthErrorRedirectPath?: string | null; -} - // ─── Helpers ──────────────────────────────────────────────────────────────── async function isAppMember(ctx: ConstructiveContext): Promise { const userId = ctx.userId; if (!userId) return false; - // Check if user is an app member (has a record in app_memberships_sprt) const sql = ` SELECT 1 FROM constructive_memberships_private.app_memberships_sprt WHERE actor_id = $1 @@ -88,15 +46,23 @@ async function requireAppMember(ctx: ConstructiveContext, res: Response): Promis return true; } -async function discoverAuthSettingsTable( - ctx: ConstructiveContext, -): Promise<{ schemaName: string; tableName: string } | null> { - const result = await ctx.pool.query<{ schema_name: string; table_name: string }>( - AUTH_SETTINGS_DISCOVERY_SQL, - ); - const row = result.rows[0]; - if (!row) return null; - return { schemaName: row.schema_name, tableName: row.table_name }; +function sendAuthSettings(res: Response, settings: AuthSettings): void { + res.json({ + allowIdentitySignIn: settings.allowIdentitySignIn, + allowIdentitySignUp: settings.allowIdentitySignUp, + cookieSecure: settings.cookieSecure, + cookieSamesite: settings.cookieSamesite, + cookieDomain: settings.cookieDomain, + cookieHttponly: settings.cookieHttponly, + cookieMaxAge: settings.cookieMaxAge, + cookiePath: settings.cookiePath, + rememberMeDuration: settings.rememberMeDuration, + enableCaptcha: settings.enableCaptcha, + captchaSiteKey: settings.captchaSiteKey, + oauthStateMaxAge: settings.oauthStateMaxAge, + oauthRequireVerifiedEmail: settings.oauthRequireVerifiedEmail, + oauthErrorRedirectPath: settings.oauthErrorRedirectPath, + }); } // ─── Router ───────────────────────────────────────────────────────────────── @@ -120,53 +86,12 @@ export function createAppSettingsAuthRouter(): Router { if (!(await requireAppMember(ctx, res))) return; try { - const table = await discoverAuthSettingsTable(ctx); - if (!table) { - return res.status(404).json({ error: 'Auth settings module not configured' }); - } - - const sql = ` - SELECT - allow_identity_sign_in, - allow_identity_sign_up, - cookie_secure, - cookie_samesite, - cookie_domain, - cookie_httponly, - cookie_max_age::text, - cookie_path, - remember_me_duration::text, - enable_captcha, - captcha_site_key, - oauth_state_max_age::text, - oauth_require_verified_email, - oauth_error_redirect_path - FROM ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} - LIMIT 1 - `; - const result = await ctx.pool.query(sql); - const settings = result.rows[0]; - + const settings = await ctx.useModule('authSettings'); if (!settings) { return res.status(404).json({ error: 'Auth settings not found' }); } - res.json({ - allowIdentitySignIn: settings.allow_identity_sign_in, - allowIdentitySignUp: settings.allow_identity_sign_up, - cookieSecure: settings.cookie_secure, - cookieSamesite: settings.cookie_samesite, - cookieDomain: settings.cookie_domain, - cookieHttponly: settings.cookie_httponly, - cookieMaxAge: settings.cookie_max_age, - cookiePath: settings.cookie_path, - rememberMeDuration: settings.remember_me_duration, - enableCaptcha: settings.enable_captcha, - captchaSiteKey: settings.captcha_site_key, - oauthStateMaxAge: settings.oauth_state_max_age, - oauthRequireVerifiedEmail: settings.oauth_require_verified_email, - oauthErrorRedirectPath: settings.oauth_error_redirect_path, - }); + sendAuthSettings(res, settings); } catch (error) { log.error('[app-settings-auth] Failed to get settings:', error); res.status(500).json({ error: 'Failed to get settings' }); @@ -185,57 +110,17 @@ export function createAppSettingsAuthRouter(): Router { if (!(await requireAppMember(ctx, res))) return; - const body = req.body as UpdateAuthSettingsBody; + const body = req.body as UpdateAuthSettingsInput; try { - const table = await discoverAuthSettingsTable(ctx); - if (!table) { + const result = await updateAuthSettings(ctx, body); + if (result === 'not_configured') { return res.status(404).json({ error: 'Auth settings module not configured' }); } - - const fieldMap: Record = { - allowIdentitySignIn: 'allow_identity_sign_in', - allowIdentitySignUp: 'allow_identity_sign_up', - cookieSecure: 'cookie_secure', - cookieSamesite: 'cookie_samesite', - cookieDomain: 'cookie_domain', - cookieHttponly: 'cookie_httponly', - cookieMaxAge: 'cookie_max_age', - cookiePath: 'cookie_path', - rememberMeDuration: 'remember_me_duration', - enableCaptcha: 'enable_captcha', - captchaSiteKey: 'captcha_site_key', - oauthStateMaxAge: 'oauth_state_max_age', - oauthRequireVerifiedEmail: 'oauth_require_verified_email', - oauthErrorRedirectPath: 'oauth_error_redirect_path', - }; - - const setClauses: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - for (const [camelKey, snakeKey] of Object.entries(fieldMap)) { - if (camelKey in body) { - const value = (body as Record)[camelKey]; - if (snakeKey.includes('_age') || snakeKey.includes('_duration')) { - setClauses.push(`${snakeKey} = $${paramIndex++}::interval`); - } else { - setClauses.push(`${snakeKey} = $${paramIndex++}`); - } - values.push(value); - } - } - - if (setClauses.length === 0) { + if (result === 'no_fields') { return res.status(400).json({ error: 'No fields to update' }); } - const sql = ` - UPDATE ${QuoteUtils.quoteQualifiedIdentifier(table.schemaName, table.tableName)} - SET ${setClauses.join(', ')} - `; - await ctx.pool.query(sql, values); - log.info('[app-settings-auth] Updated settings'); res.json({ success: true }); } catch (error) { diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index c712631b9e..6b9903af77 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -58,6 +58,8 @@ export type { PgInterval, PubkeyChallengeSettings, RlsModule, + UpdateAuthSettingsInput, + UpdateAuthSettingsResult, UserAuthModuleConfig, WebauthnSettings, WithPgClient, @@ -103,6 +105,7 @@ export { inferenceLogLoader, pubkeyLoader, rlsLoader, + updateAuthSettings, userAuthModuleLoader, llmLoader, webauthnLoader, diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index 62e4fb4d15..fa2baf4878 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -11,8 +11,15 @@ */ import { QuoteUtils } from '@pgsql/quotes'; +import type { Pool } from 'pg'; -import type { AuthSettings, AuthSettingsRow } from '../types'; +import type { + AuthSettings, + AuthSettingsRow, + ConstructiveContext, + UpdateAuthSettingsInput, + UpdateAuthSettingsResult, +} from '../types'; import { createModuleLoader } from './create-loader'; import type { LoaderContext, ModuleLoader } from './types'; @@ -26,6 +33,50 @@ const AUTH_SETTINGS_DISCOVERY_SQL = ` LIMIT 1 `; +interface AuthSettingsTableRef { + schemaName: string; + tableName: string; +} + +const UPDATE_FIELD_MAP: Record< + keyof UpdateAuthSettingsInput, + { column: string; castInterval?: boolean } +> = { + allowIdentitySignIn: { column: 'allow_identity_sign_in' }, + allowIdentitySignUp: { column: 'allow_identity_sign_up' }, + cookieSecure: { column: 'cookie_secure' }, + cookieSamesite: { column: 'cookie_samesite' }, + cookieDomain: { column: 'cookie_domain' }, + cookieHttponly: { column: 'cookie_httponly' }, + cookieMaxAge: { column: 'cookie_max_age', castInterval: true }, + cookiePath: { column: 'cookie_path' }, + rememberMeDuration: { column: 'remember_me_duration', castInterval: true }, + enableCaptcha: { column: 'enable_captcha' }, + captchaSiteKey: { column: 'captcha_site_key' }, + oauthStateMaxAge: { column: 'oauth_state_max_age', castInterval: true }, + oauthRequireVerifiedEmail: { column: 'oauth_require_verified_email' }, + oauthErrorRedirectPath: { column: 'oauth_error_redirect_path' }, +}; + +async function discoverAuthSettingsTable( + pool: Pool, + databaseId: string | null | undefined, +): Promise { + if (!databaseId) return null; + + const discovery = await pool.query<{ schema_name: string; table_name: string }>( + AUTH_SETTINGS_DISCOVERY_SQL, + [databaseId], + ); + const resolved = discovery.rows[0]; + if (!resolved) return null; + + return { + schemaName: resolved.schema_name, + tableName: resolved.table_name, + }; +} + function buildAuthSettingsQuery(schemaName: string, tableName: string): string { const authSettingsTable = QuoteUtils.quoteQualifiedIdentifier( schemaName, @@ -34,6 +85,8 @@ function buildAuthSettingsQuery(schemaName: string, tableName: string): string { return ` SELECT + allow_identity_sign_in, + allow_identity_sign_up, cookie_secure, cookie_samesite, cookie_domain, @@ -51,6 +104,42 @@ function buildAuthSettingsQuery(schemaName: string, tableName: string): string { `; } +function buildUpdateAuthSettingsQuery( + schemaName: string, + tableName: string, + patch: UpdateAuthSettingsInput, +): { sql: string; values: unknown[] } | null { + const authSettingsTable = QuoteUtils.quoteQualifiedIdentifier( + schemaName, + tableName, + ); + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + for (const [field, config] of Object.entries(UPDATE_FIELD_MAP) as Array< + [keyof UpdateAuthSettingsInput, { column: string; castInterval?: boolean }] + >) { + if (field in patch) { + const cast = config.castInterval ? '::interval' : ''; + setClauses.push( + `${QuoteUtils.quoteIdentifier(config.column)} = $${paramIndex++}${cast}`, + ); + values.push(patch[field]); + } + } + + if (setClauses.length === 0) return null; + + return { + sql: ` + UPDATE ${authSettingsTable} + SET ${setClauses.join(', ')} + `, + values, + }; +} + // ─── Loader ───────────────────────────────────────────────────────────────── export const authSettingsLoader: ModuleLoader = createModuleLoader({ @@ -59,22 +148,18 @@ export const authSettingsLoader: ModuleLoader = createModuleLoader async resolve(ctx: LoaderContext) { const { tenantPool, databaseId } = ctx; - // Step 1: Discover schema + table from sessions_module - const discovery = await tenantPool.query<{ schema_name: string; table_name: string }>( - AUTH_SETTINGS_DISCOVERY_SQL, - [databaseId], - ); - const resolved = discovery.rows[0]; + const resolved = await discoverAuthSettingsTable(tenantPool, databaseId); if (!resolved) return undefined; - // Step 2: Query the actual auth settings table const result = await tenantPool.query( - buildAuthSettingsQuery(resolved.schema_name, resolved.table_name), + buildAuthSettingsQuery(resolved.schemaName, resolved.tableName), ); const row = result.rows[0]; if (!row) return undefined; return { + allowIdentitySignIn: row.allow_identity_sign_in, + allowIdentitySignUp: row.allow_identity_sign_up, cookieSecure: row.cookie_secure, cookieSamesite: row.cookie_samesite, cookieDomain: row.cookie_domain, @@ -90,3 +175,25 @@ export const authSettingsLoader: ModuleLoader = createModuleLoader }; } }); + +export async function updateAuthSettings( + ctx: ConstructiveContext, + patch: UpdateAuthSettingsInput, +): Promise { + const table = await discoverAuthSettingsTable(ctx.pool, ctx.databaseId); + if (!table) return 'not_configured'; + + const update = buildUpdateAuthSettingsQuery( + table.schemaName, + table.tableName, + patch, + ); + if (!update) return 'no_fields'; + + await ctx.pool.query(update.sql, update.values); + if (ctx.databaseId) { + authSettingsLoader.invalidate(ctx.databaseId); + } + + return 'updated'; +} diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index a38609f5ef..ce0c7dbaca 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -42,7 +42,7 @@ export { createLoaderRegistry } from './registry'; // Built-in loaders export { agentChatLoader } from './agent-chat'; -export { authSettingsLoader } from './auth-settings'; +export { authSettingsLoader, updateAuthSettings } from './auth-settings'; export { billingLoader } from './billing'; export { computeLoader } from './compute'; export { connectedAccountsModuleLoader } from './connected-accounts-module'; diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index fd177db4a6..6e90ad6deb 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -77,6 +77,8 @@ export interface PgInterval { } export interface AuthSettings { + allowIdentitySignIn?: boolean; + allowIdentitySignUp?: boolean; cookieSecure?: boolean; cookieSamesite?: string; cookieDomain?: string | null; @@ -91,6 +93,28 @@ export interface AuthSettings { oauthErrorRedirectPath?: string | null; } +export interface UpdateAuthSettingsInput { + allowIdentitySignIn?: boolean; + allowIdentitySignUp?: boolean; + cookieSecure?: boolean; + cookieSamesite?: string; + cookieDomain?: string | null; + cookieHttponly?: boolean; + cookieMaxAge?: string | null; + cookiePath?: string; + rememberMeDuration?: string | null; + enableCaptcha?: boolean; + captchaSiteKey?: string | null; + oauthStateMaxAge?: string | null; + oauthRequireVerifiedEmail?: boolean; + oauthErrorRedirectPath?: string | null; +} + +export type UpdateAuthSettingsResult = + | 'updated' + | 'not_configured' + | 'no_fields'; + export interface ApiStructure { apiId?: string; dbname: string; @@ -209,6 +233,8 @@ export interface UserAuthModuleRow { } export interface AuthSettingsRow { + allow_identity_sign_in: boolean; + allow_identity_sign_up: boolean; cookie_secure: boolean; cookie_samesite: string; cookie_domain: string | null; From e610c855d0001c83766e940632b33e347fa3a349 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 16 Jun 2026 12:46:44 +0800 Subject: [PATCH 13/32] update oauth followup after app settings loader reapply --- docs/plan/oauth/oauth-implementation-followup.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/plan/oauth/oauth-implementation-followup.md b/docs/plan/oauth/oauth-implementation-followup.md index 4939076909..62b873c6b7 100644 --- a/docs/plan/oauth/oauth-implementation-followup.md +++ b/docs/plan/oauth/oauth-implementation-followup.md @@ -12,15 +12,11 @@ - Fix cookie/session duration parsing after the OAuth loader work lands. `app_settings_auth.cookie_max_age`, `remember_me_duration`, and `oauth_state_max_age` are PostgreSQL `interval` columns; `pg` returns them as `PostgresInterval` objects such as `{ days: 14 }` or `{ minutes: 10 }`. The current GraphQL server cookie helper still parses these values as second strings with `parseInt`, so loader-backed auth settings can silently fall back to defaults. This is an existing cookie auth settings compatibility issue exposed by the OAuth/auth settings loader path and should be handled in a follow-up PR. -## App Settings Auth Loader Refactor - -- Revisit the reverted `f77d301d1c780f348c0e9243633e34167ae42735` change that moved `graphql/server/src/middleware/app-settings-auth.ts` onto the `authSettings` loader and an `updateAuthSettings()` helper. The `app-settings-auth` REST API came from the reviewed admin identity providers branch, but this loader-backed rewrite was an extra `feat/oauth-reorg` refactor and has been reverted out of the migration scope. If reintroduced, it should be done as a dedicated cleanup PR that preserves the admin branch API behavior, keeps module-not-configured vs settings-not-found error semantics clear, includes `allowIdentitySignIn` and `allowIdentitySignUp` in both reads and writes, and defines post-write invalidation together with the broader loader cache policy below. - ## Loader Cache Invalidation and TTL Semantics - Revisit `createModuleLoader` cache expiration and invalidation semantics. The current implementation uses `updateAgeOnGet: true`, so each cache hit refreshes the TTL and frequently accessed config may not expire while traffic continues. The reference branch changed the default to `false`, but the desired behavior should be decided together with explicit invalidation for writes performed by our own systems. -- Known changes made through this process, such as admin APIs updating auth settings or identity providers, should invalidate the relevant loader cache after the database write succeeds. The current identity provider admin writes do not yet invalidate the cached OAuth provider config, and any future app-settings-auth loader-backed write path should invalidate `authSettingsLoader` as part of the same design. Recommended shape: loaders own read, transform, cache, and invalidate; services or repositories own validate, authorize, write, audit, and post-write invalidate. For example, `identityProvidersService.updateProvider(ctx, input)` writes the provider config and then calls a targeted invalidation such as `registry.invalidate(ctx.databaseId, "identityProviders")` or the equivalent loader-specific invalidation API. +- Known changes made through this process, such as admin APIs updating auth settings or identity providers, should invalidate the relevant loader cache after the database write succeeds. The current auth settings update path invalidates `authSettingsLoader`, but identity provider admin writes do not yet invalidate the cached OAuth provider config. Recommended shape: loaders own read, transform, cache, and invalidate; services or repositories own validate, authorize, write, audit, and post-write invalidate. For example, `identityProvidersService.updateProvider(ctx, input)` writes the provider config and then calls a targeted invalidation such as `registry.invalidate(ctx.databaseId, "identityProviders")` or the equivalent loader-specific invalidation API. - Unknown external changes, such as manual SQL, migrations, or another service updating module configuration, cannot be invalidated precisely by this process. Baseline approach: keep `updateAgeOnGet: false` so TTL remains a bounded staleness window, then reload from the database on the next read after TTL expiry. If stronger freshness is needed later, add an optional lightweight fingerprint probe near loader resolution, for example `getFingerprint(ctx)` using `updated_at`, version, or checksum plus `revalidateAfterMs` as the minimum interval between probes. This lets loaders detect external changes faster than full TTL expiry without fully reloading config on every request. @@ -28,7 +24,7 @@ ## API Service Cache Loader Snapshots -- Revisit `graphql/server/src/middleware/api.ts` storing resolved loader values inside the cached `ApiStructure`. The API resolver currently resolves mutable module settings such as `authSettings`, `corsOrigins`, `databaseSettings`, `pubkeyChallengeSettings`, and `webauthnSettings`, then stores the whole API structure in `svcCache`, whose TTL is effectively long-lived. This can bypass each loader's own TTL and invalidation path; for example, a future loader-backed auth settings update path could invalidate `authSettingsLoader`, while middleware that reads `req.api.authSettings` could still see the old value from `svcCache`. Consider narrowing `svcCache` to stable API routing fields only, resolving mutable module settings through `ctx.useModule(...)` at use sites, or adding coordinated `svcCache` invalidation whenever loader-backed settings are updated. +- Revisit `graphql/server/src/middleware/api.ts` storing resolved loader values inside the cached `ApiStructure`. The API resolver currently resolves mutable module settings such as `authSettings`, `corsOrigins`, `databaseSettings`, `pubkeyChallengeSettings`, and `webauthnSettings`, then stores the whole API structure in `svcCache`, whose TTL is effectively long-lived. This can bypass each loader's own TTL and invalidation path; for example, `updateAuthSettings()` invalidates `authSettingsLoader`, but middleware that reads `req.api.authSettings` could still see the old value from `svcCache`. Consider narrowing `svcCache` to stable API routing fields only, resolving mutable module settings through `ctx.useModule(...)` at use sites, or adding coordinated `svcCache` invalidation whenever loader-backed settings are updated. ## Env Config Consolidation From d34c90a8fb9218c3b2d216d6c354aa66280674dd Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 16 Jun 2026 12:53:04 +0800 Subject: [PATCH 14/32] handle app setting update in middleware --- .../src/middleware/app-settings-auth.ts | 139 +++++++++++++++++- packages/express-context/src/index.ts | 3 - .../src/loaders/auth-settings.ts | 81 ---------- packages/express-context/src/loaders/index.ts | 2 +- packages/express-context/src/types.ts | 22 --- 5 files changed, 138 insertions(+), 109 deletions(-) diff --git a/graphql/server/src/middleware/app-settings-auth.ts b/graphql/server/src/middleware/app-settings-auth.ts index 6727774082..649333eb05 100644 --- a/graphql/server/src/middleware/app-settings-auth.ts +++ b/graphql/server/src/middleware/app-settings-auth.ts @@ -12,17 +12,76 @@ import express, { Router, Request, Response } from 'express'; import { Logger } from '@pgpmjs/logger'; +import { QuoteUtils } from '@pgsql/quotes'; import { - updateAuthSettings, + authSettingsLoader, type AuthSettings, type ConstructiveContext, - type UpdateAuthSettingsInput, } from '@constructive-io/express-context'; import './types'; const log = new Logger('app-settings-auth'); +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const AUTH_SETTINGS_DISCOVERY_SQL = ` + SELECT s.schema_name, sm.auth_settings_table AS table_name + FROM metaschema_modules_public.sessions_module sm + JOIN metaschema_public.schema s ON s.id = sm.schema_id + WHERE sm.database_id = $1 + LIMIT 1 +`; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface AuthSettingsTableRef { + schemaName: string; + tableName: string; +} + +interface UpdateAuthSettingsInput { + allowIdentitySignIn?: boolean; + allowIdentitySignUp?: boolean; + cookieSecure?: boolean; + cookieSamesite?: string; + cookieDomain?: string | null; + cookieHttponly?: boolean; + cookieMaxAge?: string | null; + cookiePath?: string; + rememberMeDuration?: string | null; + enableCaptcha?: boolean; + captchaSiteKey?: string | null; + oauthStateMaxAge?: string | null; + oauthRequireVerifiedEmail?: boolean; + oauthErrorRedirectPath?: string | null; +} + +type UpdateAuthSettingsResult = + | 'updated' + | 'not_configured' + | 'no_fields'; + +const UPDATE_FIELD_MAP: Record< + keyof UpdateAuthSettingsInput, + { column: string; castInterval?: boolean } +> = { + allowIdentitySignIn: { column: 'allow_identity_sign_in' }, + allowIdentitySignUp: { column: 'allow_identity_sign_up' }, + cookieSecure: { column: 'cookie_secure' }, + cookieSamesite: { column: 'cookie_samesite' }, + cookieDomain: { column: 'cookie_domain' }, + cookieHttponly: { column: 'cookie_httponly' }, + cookieMaxAge: { column: 'cookie_max_age', castInterval: true }, + cookiePath: { column: 'cookie_path' }, + rememberMeDuration: { column: 'remember_me_duration', castInterval: true }, + enableCaptcha: { column: 'enable_captcha' }, + captchaSiteKey: { column: 'captcha_site_key' }, + oauthStateMaxAge: { column: 'oauth_state_max_age', castInterval: true }, + oauthRequireVerifiedEmail: { column: 'oauth_require_verified_email' }, + oauthErrorRedirectPath: { column: 'oauth_error_redirect_path' }, +}; + // ─── Helpers ──────────────────────────────────────────────────────────────── async function isAppMember(ctx: ConstructiveContext): Promise { @@ -65,6 +124,82 @@ function sendAuthSettings(res: Response, settings: AuthSettings): void { }); } +async function discoverAuthSettingsTable( + ctx: ConstructiveContext, +): Promise { + if (!ctx.databaseId) return null; + + const discovery = await ctx.pool.query<{ schema_name: string; table_name: string }>( + AUTH_SETTINGS_DISCOVERY_SQL, + [ctx.databaseId], + ); + const resolved = discovery.rows[0]; + if (!resolved) return null; + + return { + schemaName: resolved.schema_name, + tableName: resolved.table_name, + }; +} + +function buildUpdateAuthSettingsQuery( + schemaName: string, + tableName: string, + patch: UpdateAuthSettingsInput, +): { sql: string; values: unknown[] } | null { + const authSettingsTable = QuoteUtils.quoteQualifiedIdentifier( + schemaName, + tableName, + ); + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + for (const [field, config] of Object.entries(UPDATE_FIELD_MAP) as Array< + [keyof UpdateAuthSettingsInput, { column: string; castInterval?: boolean }] + >) { + if (field in patch) { + const cast = config.castInterval ? '::interval' : ''; + setClauses.push( + `${QuoteUtils.quoteIdentifier(config.column)} = $${paramIndex++}${cast}`, + ); + values.push(patch[field]); + } + } + + if (setClauses.length === 0) return null; + + return { + sql: ` + UPDATE ${authSettingsTable} + SET ${setClauses.join(', ')} + `, + values, + }; +} + +async function updateAuthSettings( + ctx: ConstructiveContext, + patch: UpdateAuthSettingsInput, +): Promise { + const table = await discoverAuthSettingsTable(ctx); + if (!table) return 'not_configured'; + + const update = buildUpdateAuthSettingsQuery( + table.schemaName, + table.tableName, + patch, + ); + if (!update) return 'no_fields'; + + await ctx.pool.query(update.sql, update.values); + if (ctx.databaseId) { + authSettingsLoader.invalidate(ctx.databaseId); + } + + return 'updated'; +} + // ─── Router ───────────────────────────────────────────────────────────────── export function createAppSettingsAuthRouter(): Router { diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 6b9903af77..c712631b9e 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -58,8 +58,6 @@ export type { PgInterval, PubkeyChallengeSettings, RlsModule, - UpdateAuthSettingsInput, - UpdateAuthSettingsResult, UserAuthModuleConfig, WebauthnSettings, WithPgClient, @@ -105,7 +103,6 @@ export { inferenceLogLoader, pubkeyLoader, rlsLoader, - updateAuthSettings, userAuthModuleLoader, llmLoader, webauthnLoader, diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index fa2baf4878..def42f1c53 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -16,9 +16,6 @@ import type { Pool } from 'pg'; import type { AuthSettings, AuthSettingsRow, - ConstructiveContext, - UpdateAuthSettingsInput, - UpdateAuthSettingsResult, } from '../types'; import { createModuleLoader } from './create-loader'; import type { LoaderContext, ModuleLoader } from './types'; @@ -38,26 +35,6 @@ interface AuthSettingsTableRef { tableName: string; } -const UPDATE_FIELD_MAP: Record< - keyof UpdateAuthSettingsInput, - { column: string; castInterval?: boolean } -> = { - allowIdentitySignIn: { column: 'allow_identity_sign_in' }, - allowIdentitySignUp: { column: 'allow_identity_sign_up' }, - cookieSecure: { column: 'cookie_secure' }, - cookieSamesite: { column: 'cookie_samesite' }, - cookieDomain: { column: 'cookie_domain' }, - cookieHttponly: { column: 'cookie_httponly' }, - cookieMaxAge: { column: 'cookie_max_age', castInterval: true }, - cookiePath: { column: 'cookie_path' }, - rememberMeDuration: { column: 'remember_me_duration', castInterval: true }, - enableCaptcha: { column: 'enable_captcha' }, - captchaSiteKey: { column: 'captcha_site_key' }, - oauthStateMaxAge: { column: 'oauth_state_max_age', castInterval: true }, - oauthRequireVerifiedEmail: { column: 'oauth_require_verified_email' }, - oauthErrorRedirectPath: { column: 'oauth_error_redirect_path' }, -}; - async function discoverAuthSettingsTable( pool: Pool, databaseId: string | null | undefined, @@ -104,42 +81,6 @@ function buildAuthSettingsQuery(schemaName: string, tableName: string): string { `; } -function buildUpdateAuthSettingsQuery( - schemaName: string, - tableName: string, - patch: UpdateAuthSettingsInput, -): { sql: string; values: unknown[] } | null { - const authSettingsTable = QuoteUtils.quoteQualifiedIdentifier( - schemaName, - tableName, - ); - const setClauses: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - for (const [field, config] of Object.entries(UPDATE_FIELD_MAP) as Array< - [keyof UpdateAuthSettingsInput, { column: string; castInterval?: boolean }] - >) { - if (field in patch) { - const cast = config.castInterval ? '::interval' : ''; - setClauses.push( - `${QuoteUtils.quoteIdentifier(config.column)} = $${paramIndex++}${cast}`, - ); - values.push(patch[field]); - } - } - - if (setClauses.length === 0) return null; - - return { - sql: ` - UPDATE ${authSettingsTable} - SET ${setClauses.join(', ')} - `, - values, - }; -} - // ─── Loader ───────────────────────────────────────────────────────────────── export const authSettingsLoader: ModuleLoader = createModuleLoader({ @@ -175,25 +116,3 @@ export const authSettingsLoader: ModuleLoader = createModuleLoader }; } }); - -export async function updateAuthSettings( - ctx: ConstructiveContext, - patch: UpdateAuthSettingsInput, -): Promise { - const table = await discoverAuthSettingsTable(ctx.pool, ctx.databaseId); - if (!table) return 'not_configured'; - - const update = buildUpdateAuthSettingsQuery( - table.schemaName, - table.tableName, - patch, - ); - if (!update) return 'no_fields'; - - await ctx.pool.query(update.sql, update.values); - if (ctx.databaseId) { - authSettingsLoader.invalidate(ctx.databaseId); - } - - return 'updated'; -} diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index ce0c7dbaca..a38609f5ef 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -42,7 +42,7 @@ export { createLoaderRegistry } from './registry'; // Built-in loaders export { agentChatLoader } from './agent-chat'; -export { authSettingsLoader, updateAuthSettings } from './auth-settings'; +export { authSettingsLoader } from './auth-settings'; export { billingLoader } from './billing'; export { computeLoader } from './compute'; export { connectedAccountsModuleLoader } from './connected-accounts-module'; diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 6e90ad6deb..6f81a0a3bf 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -93,28 +93,6 @@ export interface AuthSettings { oauthErrorRedirectPath?: string | null; } -export interface UpdateAuthSettingsInput { - allowIdentitySignIn?: boolean; - allowIdentitySignUp?: boolean; - cookieSecure?: boolean; - cookieSamesite?: string; - cookieDomain?: string | null; - cookieHttponly?: boolean; - cookieMaxAge?: string | null; - cookiePath?: string; - rememberMeDuration?: string | null; - enableCaptcha?: boolean; - captchaSiteKey?: string | null; - oauthStateMaxAge?: string | null; - oauthRequireVerifiedEmail?: boolean; - oauthErrorRedirectPath?: string | null; -} - -export type UpdateAuthSettingsResult = - | 'updated' - | 'not_configured' - | 'no_fields'; - export interface ApiStructure { apiId?: string; dbname: string; From 8a57dc8a9d9382c78ceaaad1afec1d578f006626 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 16 Jun 2026 13:11:05 +0800 Subject: [PATCH 15/32] regulate JWT claims usage --- graphql/server/src/middleware/oauth.ts | 11 ++++------- packages/express-context/src/context.ts | 11 +++++++++-- packages/express-context/src/types.ts | 2 ++ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index 37db2466b0..5395b3b607 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -479,13 +479,6 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { 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, baseUrl], - ); - const details = { provider: profile.provider, sub: profile.providerId, @@ -533,6 +526,10 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { return signUpResult.rows[0] || {}; } }, + { + 'jwt.claims.user_agent': userAgent, + 'jwt.claims.origin': baseUrl, + }, ); // Handle MFA required diff --git a/packages/express-context/src/context.ts b/packages/express-context/src/context.ts index 82d87de9d3..ac59cb4b99 100644 --- a/packages/express-context/src/context.ts +++ b/packages/express-context/src/context.ts @@ -99,8 +99,15 @@ export function buildContext( }; } - const withPgClient = (fn: (client: any) => Promise) => - withPgClientFn(tenantPool, pgSettings, fn); + const withPgClient = ( + fn: (client: any) => Promise, + pgSettingsOverrides?: Record + ) => + withPgClientFn( + tenantPool, + pgSettingsOverrides ? { ...pgSettings, ...pgSettingsOverrides } : pgSettings, + fn + ); const useModule = createUseModule(opts.loaders, loaderCtx); // Lazy-initialized billing client (cached per request) diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 6f81a0a3bf..3a7f8ad8d5 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -324,6 +324,8 @@ export interface BuiltinModuleMap { */ export type WithPgClient = ( fn: (client: PoolClient) => Promise, + /** Per-call settings merged over the request pgSettings before SET LOCAL. */ + pgSettingsOverrides?: Record, ) => Promise; /** From d47292495a63fad413ca9c0b6c9823846f7562e8 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Sat, 20 Jun 2026 10:48:28 +0800 Subject: [PATCH 16/32] fix env snapshots --- graphql/env/__tests__/__snapshots__/merge.test.ts.snap | 2 -- pgpm/env/__tests__/__snapshots__/merge.test.ts.snap | 2 -- 2 files changed, 4 deletions(-) diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index 8810e65aa1..c061378e7a 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -16,7 +16,6 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "roleName": "env_role", "routingSchema": "routing_public", }, - "captcha": {}, "cdn": { "awsAccessKey": "minioadmin", "awsRegion": "us-east-1", @@ -111,6 +110,5 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "port": 587, "secure": false, }, - "upload": {}, } `; diff --git a/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap b/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap index 63de18bb5f..ebcc5c5d24 100644 --- a/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap @@ -2,7 +2,6 @@ exports[`getEnvOptions merges defaults, config, env, and overrides 1`] = ` { - "captcha": {}, "cdn": { "awsAccessKey": "minioadmin", "awsRegion": "us-east-1", @@ -76,6 +75,5 @@ exports[`getEnvOptions merges defaults, config, env, and overrides 1`] = ` "port": 587, "secure": false, }, - "upload": {}, } `; From 4044128b73fa427669c9af2b297b41c3455999fd Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 2 Jul 2026 10:45:41 +0800 Subject: [PATCH 17/32] feat(oauth): add runtime provider resolver --- packages/oauth/__tests__/oauth-client.test.ts | 92 ++++++++++++++++ packages/oauth/src/index.ts | 7 ++ packages/oauth/src/provider-resolver.ts | 102 ++++++++++++++++++ packages/oauth/src/providers/facebook.ts | 1 + packages/oauth/src/providers/github.ts | 1 + packages/oauth/src/providers/google.ts | 1 + packages/oauth/src/providers/linkedin.ts | 1 + packages/oauth/src/types.ts | 71 +++++++++++- packages/oauth/src/utils/config.ts | 16 +++ 9 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 packages/oauth/src/provider-resolver.ts create mode 100644 packages/oauth/src/utils/config.ts diff --git a/packages/oauth/__tests__/oauth-client.test.ts b/packages/oauth/__tests__/oauth-client.test.ts index ce6f0b3f95..ef578aab9f 100644 --- a/packages/oauth/__tests__/oauth-client.test.ts +++ b/packages/oauth/__tests__/oauth-client.test.ts @@ -1,4 +1,5 @@ import { OAuthClient, createOAuthClient } from '../src/oauth-client'; +import { resolveOAuthProvider } from '../src/provider-resolver'; import { GITHUB_EMAILS_URL, getProvider, getProviderIds } from '../src/providers'; import { generateState, verifyState } from '../src/utils/state'; import { createSignedState, verifySignedState } from '../src/utils/signed-state'; @@ -415,6 +416,97 @@ describe('providers', () => { }); }); +describe('provider resolver', () => { + it('should merge runtime config over static provider defaults', () => { + const resolved = resolveOAuthProvider({ + providerId: 'github', + runtimeConfig: { + slug: 'github', + kind: 'oauth2', + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + authorizationUrl: 'https://github.enterprise.test/login/oauth/authorize', + tokenUrl: 'https://github.enterprise.test/login/oauth/access_token', + userinfoUrl: 'https://github.enterprise.test/api/user', + scopes: ['read:user'], + pkceEnabled: true, + }, + }); + + expect(resolved.providerId).toBe('github'); + expect(resolved.provider.id).toBe('github'); + expect(resolved.config.authorizationUrl).toBe( + 'https://github.enterprise.test/login/oauth/authorize' + ); + expect(resolved.config.tokenUrl).toBe( + 'https://github.enterprise.test/login/oauth/access_token' + ); + expect(resolved.config.userinfoUrl).toBe( + 'https://github.enterprise.test/api/user' + ); + expect(resolved.config.scopes).toEqual(['read:user']); + expect(resolved.config.pkceEnabled).toBe(true); + }); + + it('should use static scopes when runtime scopes are not configured', () => { + const resolved = resolveOAuthProvider({ + providerId: 'github', + runtimeConfig: { + slug: 'github', + kind: 'oauth2', + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + scopes: null, + }, + }); + + expect(resolved.config.scopes).toEqual(['user:email', 'read:user']); + }); + + it('should reject disabled providers', () => { + expect(() => + resolveOAuthProvider({ + providerId: 'github', + runtimeConfig: { + slug: 'github', + kind: 'oauth2', + enabled: false, + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + }, + }) + ).toThrow('Provider github is disabled'); + }); + + it('should reject kind mismatches for known providers', () => { + expect(() => + resolveOAuthProvider({ + providerId: 'github', + runtimeConfig: { + slug: 'github', + kind: 'oidc', + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + }, + }) + ).toThrow('does not match provider kind'); + }); + + it('should reject unknown providers', () => { + expect(() => + resolveOAuthProvider({ + providerId: 'unknown', + runtimeConfig: { + slug: 'unknown', + kind: 'oauth2', + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + }, + }) + ).toThrow('Unknown provider: unknown'); + }); +}); + describe('state utilities', () => { describe('generateState', () => { it('should generate random state of default length', () => { diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index ca8b049304..4d5bde9753 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -1,5 +1,11 @@ export { OAuthProviderConfig, + OAuthProviderKind, + OAuthTokenRequestContentType, + OAuthTokenEndpointAuthMethod, + OAuthProviderRuntimeConfig, + OAuthProviderResolvedConfig, + ResolvedOAuthProvider, OAuthProfile, OAuthCredentials, OAuthClientConfig, @@ -11,6 +17,7 @@ export { } from './types'; export { OAuthClient, createOAuthClient } from './oauth-client'; +export { resolveOAuthProvider } from './provider-resolver'; export { providers, diff --git a/packages/oauth/src/provider-resolver.ts b/packages/oauth/src/provider-resolver.ts new file mode 100644 index 0000000000..a7ab84d2a0 --- /dev/null +++ b/packages/oauth/src/provider-resolver.ts @@ -0,0 +1,102 @@ +import { getProvider } from './providers'; +import { + OAuthProviderConfig, + OAuthProviderResolvedConfig, + OAuthProviderRuntimeConfig, + ResolvedOAuthProvider, + createOAuthError, +} from './types'; +import { requireProviderConfigString } from './utils/config'; + +export function resolveOAuthProvider(ctx: { + providerId: string; + runtimeConfig: OAuthProviderRuntimeConfig; + getProviderConfig?: (providerId: string) => OAuthProviderConfig | undefined; +}): ResolvedOAuthProvider { + const provider = (ctx.getProviderConfig || getProvider)(ctx.providerId); + if (!provider) { + throw createOAuthError( + `Unknown provider: ${ctx.providerId}`, + 'UNKNOWN_PROVIDER', + ctx.providerId + ); + } + + const runtimeConfig = { + ...ctx.runtimeConfig, + slug: ctx.runtimeConfig.slug || ctx.providerId, + }; + + if (runtimeConfig.enabled === false) { + throw createOAuthError( + `Provider ${ctx.providerId} is disabled`, + 'PROVIDER_DISABLED', + ctx.providerId + ); + } + + if (runtimeConfig.kind && runtimeConfig.kind !== provider.kind) { + throw createOAuthError( + `Provider ${ctx.providerId} kind ${runtimeConfig.kind} does not match provider kind ${provider.kind}`, + 'PROVIDER_CONFIG_INVALID', + ctx.providerId + ); + } + + const resolvedConfig: OAuthProviderResolvedConfig = { + slug: runtimeConfig.slug, + kind: runtimeConfig.kind || provider.kind, + displayName: runtimeConfig.displayName || provider.name, + enabled: runtimeConfig.enabled ?? true, + clientId: requireProviderConfigString( + runtimeConfig.clientId, + 'clientId', + ctx.providerId + ), + clientSecret: requireProviderConfigString( + runtimeConfig.clientSecret, + 'clientSecret', + ctx.providerId + ), + redirectUri: runtimeConfig.redirectUri, + authorizationUrl: requireProviderConfigString( + runtimeConfig.authorizationUrl ?? provider.authorizationUrl, + 'authorizationUrl', + ctx.providerId + ), + tokenUrl: requireProviderConfigString( + runtimeConfig.tokenUrl ?? provider.tokenUrl, + 'tokenUrl', + ctx.providerId + ), + userinfoUrl: requireProviderConfigString( + runtimeConfig.userinfoUrl ?? + runtimeConfig.userInfoUrl ?? + provider.userInfoUrl, + 'userinfoUrl', + ctx.providerId + ), + scopes: + runtimeConfig.scopes === undefined || runtimeConfig.scopes === null + ? provider.scopes + : runtimeConfig.scopes, + pkceEnabled: runtimeConfig.pkceEnabled ?? false, + tokenEndpointAuthMethod: + runtimeConfig.tokenEndpointAuthMethod ?? 'client_secret_post', + tokenRequestContentType: + runtimeConfig.tokenRequestContentType ?? + provider.tokenRequestContentType ?? + 'form', + userInfoMethod: + runtimeConfig.userInfoMethod ?? provider.userInfoMethod ?? 'GET', + authorizationParams: runtimeConfig.authorizationParams || {}, + tokenParams: runtimeConfig.tokenParams || {}, + options: runtimeConfig.options || {}, + }; + + return { + providerId: ctx.providerId, + config: resolvedConfig, + provider, + }; +} diff --git a/packages/oauth/src/providers/facebook.ts b/packages/oauth/src/providers/facebook.ts index 3becc005d6..d509909940 100644 --- a/packages/oauth/src/providers/facebook.ts +++ b/packages/oauth/src/providers/facebook.ts @@ -16,6 +16,7 @@ const FACEBOOK_API_VERSION = 'v18.0'; export const facebookProvider: OAuthProviderConfig = { id: 'facebook', name: 'Facebook', + kind: 'oauth2', authorizationUrl: `https://www.facebook.com/${FACEBOOK_API_VERSION}/dialog/oauth`, tokenUrl: `https://graph.facebook.com/${FACEBOOK_API_VERSION}/oauth/access_token`, userInfoUrl: `https://graph.facebook.com/me?fields=id,name,email,picture`, diff --git a/packages/oauth/src/providers/github.ts b/packages/oauth/src/providers/github.ts index 80f33f6254..491c108be9 100644 --- a/packages/oauth/src/providers/github.ts +++ b/packages/oauth/src/providers/github.ts @@ -17,6 +17,7 @@ export interface GitHubEmail { export const githubProvider: OAuthProviderConfig = { id: 'github', name: 'GitHub', + kind: 'oauth2', authorizationUrl: 'https://github.com/login/oauth/authorize', tokenUrl: 'https://github.com/login/oauth/access_token', userInfoUrl: 'https://api.github.com/user', diff --git a/packages/oauth/src/providers/google.ts b/packages/oauth/src/providers/google.ts index f1d17b87fa..58f7c17427 100644 --- a/packages/oauth/src/providers/google.ts +++ b/packages/oauth/src/providers/google.ts @@ -13,6 +13,7 @@ interface GoogleProfile { export const googleProvider: OAuthProviderConfig = { id: 'google', name: 'Google', + kind: 'oidc', authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo', diff --git a/packages/oauth/src/providers/linkedin.ts b/packages/oauth/src/providers/linkedin.ts index ec46c69988..d97c69687a 100644 --- a/packages/oauth/src/providers/linkedin.ts +++ b/packages/oauth/src/providers/linkedin.ts @@ -13,6 +13,7 @@ interface LinkedInProfile { export const linkedinProvider: OAuthProviderConfig = { id: 'linkedin', name: 'LinkedIn', + kind: 'oidc', authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization', tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken', userInfoUrl: 'https://api.linkedin.com/v2/userinfo', diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index 34164812a8..bda0664aa7 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -1,15 +1,84 @@ +export type OAuthProviderKind = 'oauth2' | 'oidc'; + +export type OAuthTokenRequestContentType = 'json' | 'form'; + +export type OAuthTokenEndpointAuthMethod = + | 'client_secret_post' + | 'client_secret_basic' + | 'private_key_jwt' + | 'none'; + export interface OAuthProviderConfig { id: string; name: string; + kind: OAuthProviderKind; authorizationUrl: string; tokenUrl: string; userInfoUrl: string; scopes: string[]; - tokenRequestContentType?: 'json' | 'form'; + tokenRequestContentType?: OAuthTokenRequestContentType; userInfoMethod?: 'GET' | 'POST'; mapProfile: (data: unknown) => OAuthProfile; } +export interface OAuthProviderRuntimeConfig { + slug: string; + kind?: OAuthProviderKind; + displayName?: string; + enabled?: boolean; + + clientId: string; + clientSecret?: string; + clientSecretRef?: string; + redirectUri?: string; + + authorizationUrl?: string | null; + tokenUrl?: string | null; + userinfoUrl?: string | null; + userInfoUrl?: string | null; + + scopes?: string[] | null; + pkceEnabled?: boolean; + tokenEndpointAuthMethod?: OAuthTokenEndpointAuthMethod; + tokenRequestContentType?: OAuthTokenRequestContentType; + userInfoMethod?: 'GET' | 'POST'; + + authorizationParams?: Record; + tokenParams?: Record; + options?: Record; +} + +export interface OAuthProviderResolvedConfig { + slug: string; + kind: OAuthProviderKind; + displayName: string; + enabled: boolean; + + clientId: string; + clientSecret: string; + redirectUri?: string; + + authorizationUrl: string; + tokenUrl: string; + userinfoUrl: string; + + scopes: string[]; + pkceEnabled: boolean; + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; + tokenRequestContentType: OAuthTokenRequestContentType; + userInfoMethod: 'GET' | 'POST'; + + authorizationParams: Record; + tokenParams: Record; + options: Record; +} + +export interface ResolvedOAuthProvider { + providerId: string; + config: OAuthProviderResolvedConfig; + provider: OAuthProviderConfig; +} + export interface OAuthProfile { provider: string; providerId: string; diff --git a/packages/oauth/src/utils/config.ts b/packages/oauth/src/utils/config.ts new file mode 100644 index 0000000000..05083038c5 --- /dev/null +++ b/packages/oauth/src/utils/config.ts @@ -0,0 +1,16 @@ +import { createOAuthError } from '../types'; + +export function requireProviderConfigString( + value: string | null | undefined, + field: string, + providerId: string +): string { + if (!value) { + throw createOAuthError( + `Provider ${providerId} missing required config: ${field}`, + 'PROVIDER_CONFIG_INVALID', + providerId + ); + } + return value; +} From ab93b953442a62636bb7f7c4a3c9baedf24e652c Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 2 Jul 2026 11:01:24 +0800 Subject: [PATCH 18/32] fix(oauth): remove admin REST routes --- graphql/server/src/index.ts | 2 - .../src/middleware/app-settings-auth.ts | 268 ------------ .../src/middleware/identity-providers.ts | 389 ------------------ graphql/server/src/middleware/oauth.ts | 10 +- graphql/server/src/server.ts | 8 - .../__tests__/identity-providers.test.ts | 189 +++++++++ .../src/loaders/identity-providers.ts | 211 +++++++--- packages/express-context/src/types.ts | 14 +- 8 files changed, 353 insertions(+), 738 deletions(-) delete mode 100644 graphql/server/src/middleware/app-settings-auth.ts delete mode 100644 graphql/server/src/middleware/identity-providers.ts create mode 100644 packages/express-context/src/loaders/__tests__/identity-providers.test.ts diff --git a/graphql/server/src/index.ts b/graphql/server/src/index.ts index f53532c457..53dd89cc1e 100644 --- a/graphql/server/src/index.ts +++ b/graphql/server/src/index.ts @@ -3,8 +3,6 @@ export * from './server'; // Export middleware for use in testing packages export { createApiMiddleware, getSubdomain, getApiConfig } from './middleware/api'; export { createAuthenticateMiddleware } from './middleware/auth'; -export { createIdentityProvidersRouter } from './middleware/identity-providers'; -export { createAppSettingsAuthRouter } from './middleware/app-settings-auth'; export { cors } from './middleware/cors'; export { graphile } from './middleware/graphile'; export { flush, flushService } from './middleware/flush'; diff --git a/graphql/server/src/middleware/app-settings-auth.ts b/graphql/server/src/middleware/app-settings-auth.ts deleted file mode 100644 index 649333eb05..0000000000 --- a/graphql/server/src/middleware/app-settings-auth.ts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * App Settings Auth API - * - * Express router for managing auth settings (cookie config, captcha, OAuth settings). - * Requires administrator role. Reads/writes to app_settings_auth table via - * the authSettings loader. - * - * Routes: - * GET /app-settings-auth → get current settings - * PATCH /app-settings-auth → update settings - */ - -import express, { Router, Request, Response } from 'express'; -import { Logger } from '@pgpmjs/logger'; -import { QuoteUtils } from '@pgsql/quotes'; -import { - authSettingsLoader, - type AuthSettings, - type ConstructiveContext, -} from '@constructive-io/express-context'; - -import './types'; - -const log = new Logger('app-settings-auth'); - -// ─── SQL ──────────────────────────────────────────────────────────────────── - -const AUTH_SETTINGS_DISCOVERY_SQL = ` - SELECT s.schema_name, sm.auth_settings_table AS table_name - FROM metaschema_modules_public.sessions_module sm - JOIN metaschema_public.schema s ON s.id = sm.schema_id - WHERE sm.database_id = $1 - LIMIT 1 -`; - -// ─── Types ────────────────────────────────────────────────────────────────── - -interface AuthSettingsTableRef { - schemaName: string; - tableName: string; -} - -interface UpdateAuthSettingsInput { - allowIdentitySignIn?: boolean; - allowIdentitySignUp?: boolean; - cookieSecure?: boolean; - cookieSamesite?: string; - cookieDomain?: string | null; - cookieHttponly?: boolean; - cookieMaxAge?: string | null; - cookiePath?: string; - rememberMeDuration?: string | null; - enableCaptcha?: boolean; - captchaSiteKey?: string | null; - oauthStateMaxAge?: string | null; - oauthRequireVerifiedEmail?: boolean; - oauthErrorRedirectPath?: string | null; -} - -type UpdateAuthSettingsResult = - | 'updated' - | 'not_configured' - | 'no_fields'; - -const UPDATE_FIELD_MAP: Record< - keyof UpdateAuthSettingsInput, - { column: string; castInterval?: boolean } -> = { - allowIdentitySignIn: { column: 'allow_identity_sign_in' }, - allowIdentitySignUp: { column: 'allow_identity_sign_up' }, - cookieSecure: { column: 'cookie_secure' }, - cookieSamesite: { column: 'cookie_samesite' }, - cookieDomain: { column: 'cookie_domain' }, - cookieHttponly: { column: 'cookie_httponly' }, - cookieMaxAge: { column: 'cookie_max_age', castInterval: true }, - cookiePath: { column: 'cookie_path' }, - rememberMeDuration: { column: 'remember_me_duration', castInterval: true }, - enableCaptcha: { column: 'enable_captcha' }, - captchaSiteKey: { column: 'captcha_site_key' }, - oauthStateMaxAge: { column: 'oauth_state_max_age', castInterval: true }, - oauthRequireVerifiedEmail: { column: 'oauth_require_verified_email' }, - oauthErrorRedirectPath: { column: 'oauth_error_redirect_path' }, -}; - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -async function isAppMember(ctx: ConstructiveContext): Promise { - const userId = ctx.userId; - if (!userId) return false; - - const sql = ` - SELECT 1 FROM constructive_memberships_private.app_memberships_sprt - WHERE actor_id = $1 - LIMIT 1 - `; - const result = await ctx.pool.query(sql, [userId]); - return result.rows.length > 0; -} - -async function requireAppMember(ctx: ConstructiveContext, res: Response): Promise { - if (!(await isAppMember(ctx))) { - res.status(403).json({ error: 'MEMBERSHIP_REQUIRED' }); - return false; - } - return true; -} - -function sendAuthSettings(res: Response, settings: AuthSettings): void { - res.json({ - allowIdentitySignIn: settings.allowIdentitySignIn, - allowIdentitySignUp: settings.allowIdentitySignUp, - cookieSecure: settings.cookieSecure, - cookieSamesite: settings.cookieSamesite, - cookieDomain: settings.cookieDomain, - cookieHttponly: settings.cookieHttponly, - cookieMaxAge: settings.cookieMaxAge, - cookiePath: settings.cookiePath, - rememberMeDuration: settings.rememberMeDuration, - enableCaptcha: settings.enableCaptcha, - captchaSiteKey: settings.captchaSiteKey, - oauthStateMaxAge: settings.oauthStateMaxAge, - oauthRequireVerifiedEmail: settings.oauthRequireVerifiedEmail, - oauthErrorRedirectPath: settings.oauthErrorRedirectPath, - }); -} - -async function discoverAuthSettingsTable( - ctx: ConstructiveContext, -): Promise { - if (!ctx.databaseId) return null; - - const discovery = await ctx.pool.query<{ schema_name: string; table_name: string }>( - AUTH_SETTINGS_DISCOVERY_SQL, - [ctx.databaseId], - ); - const resolved = discovery.rows[0]; - if (!resolved) return null; - - return { - schemaName: resolved.schema_name, - tableName: resolved.table_name, - }; -} - -function buildUpdateAuthSettingsQuery( - schemaName: string, - tableName: string, - patch: UpdateAuthSettingsInput, -): { sql: string; values: unknown[] } | null { - const authSettingsTable = QuoteUtils.quoteQualifiedIdentifier( - schemaName, - tableName, - ); - const setClauses: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - for (const [field, config] of Object.entries(UPDATE_FIELD_MAP) as Array< - [keyof UpdateAuthSettingsInput, { column: string; castInterval?: boolean }] - >) { - if (field in patch) { - const cast = config.castInterval ? '::interval' : ''; - setClauses.push( - `${QuoteUtils.quoteIdentifier(config.column)} = $${paramIndex++}${cast}`, - ); - values.push(patch[field]); - } - } - - if (setClauses.length === 0) return null; - - return { - sql: ` - UPDATE ${authSettingsTable} - SET ${setClauses.join(', ')} - `, - values, - }; -} - -async function updateAuthSettings( - ctx: ConstructiveContext, - patch: UpdateAuthSettingsInput, -): Promise { - const table = await discoverAuthSettingsTable(ctx); - if (!table) return 'not_configured'; - - const update = buildUpdateAuthSettingsQuery( - table.schemaName, - table.tableName, - patch, - ); - if (!update) return 'no_fields'; - - await ctx.pool.query(update.sql, update.values); - if (ctx.databaseId) { - authSettingsLoader.invalidate(ctx.databaseId); - } - - return 'updated'; -} - -// ─── Router ───────────────────────────────────────────────────────────────── - -export function createAppSettingsAuthRouter(): Router { - const router = Router(); - - // Parse JSON body for PATCH requests - router.use(express.json()); - - /** - * GET /app-settings-auth - * Get current auth settings - */ - router.get('/app-settings-auth', async (req: Request, res: Response) => { - const ctx = req.constructive; - if (!ctx) { - return res.status(500).json({ error: 'Missing context' }); - } - - if (!(await requireAppMember(ctx, res))) return; - - try { - const settings = await ctx.useModule('authSettings'); - if (!settings) { - return res.status(404).json({ error: 'Auth settings not found' }); - } - - sendAuthSettings(res, settings); - } catch (error) { - log.error('[app-settings-auth] Failed to get settings:', error); - res.status(500).json({ error: 'Failed to get settings' }); - } - }); - - /** - * PATCH /app-settings-auth - * Update auth settings - */ - router.patch('/app-settings-auth', async (req: Request, res: Response) => { - const ctx = req.constructive; - if (!ctx) { - return res.status(500).json({ error: 'Missing context' }); - } - - if (!(await requireAppMember(ctx, res))) return; - - const body = req.body as UpdateAuthSettingsInput; - - try { - const result = await updateAuthSettings(ctx, body); - if (result === 'not_configured') { - return res.status(404).json({ error: 'Auth settings module not configured' }); - } - if (result === 'no_fields') { - return res.status(400).json({ error: 'No fields to update' }); - } - - log.info('[app-settings-auth] Updated settings'); - res.json({ success: true }); - } catch (error) { - log.error('[app-settings-auth] Failed to update settings:', error); - res.status(500).json({ error: 'Failed to update settings' }); - } - }); - - return router; -} diff --git a/graphql/server/src/middleware/identity-providers.ts b/graphql/server/src/middleware/identity-providers.ts deleted file mode 100644 index a9fafdbb2e..0000000000 --- a/graphql/server/src/middleware/identity-providers.ts +++ /dev/null @@ -1,389 +0,0 @@ -/** - * Admin Identity Providers API - * - * Express router for managing OAuth/OIDC identity provider configurations. - * Requires administrator role. Uses module loaders from @constructive-io/express-context - * to discover schemas and function names at runtime. - * - * Routes: - * GET /identity-providers → list all providers - * GET /identity-providers/:slug → get provider details - * PATCH /identity-providers/:slug → update provider config - * POST /identity-providers/:slug/secret → rotate client secret - */ - -import express, { Router, Request, Response } from 'express'; -import { Logger } from '@pgpmjs/logger'; -import { QuoteUtils } from '@pgsql/quotes'; -import type { ConstructiveContext } from '@constructive-io/express-context'; - -import './types'; - -const log = new Logger('admin-identity-providers'); - -// ─── Types ────────────────────────────────────────────────────────────────── - -interface ProviderRow { - id: string; - slug: string; - kind: 'oauth2' | 'oidc'; - display_name: string; - enabled: boolean; - is_built_in: boolean; - client_id: string | null; - client_secret_id: string | null; - authorization_url: string | null; - token_url: string | null; - userinfo_url: string | null; - scopes: string[] | null; - pkce_enabled: boolean | null; -} - -interface UpdateProviderBody { - clientId?: string; - enabled?: boolean; - scopes?: string[]; - authorizationUrl?: string; - tokenUrl?: string; - userinfoUrl?: string; - pkceEnabled?: boolean; -} - -interface RotateSecretBody { - clientSecret: string; -} - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -async function isAppMember(ctx: ConstructiveContext): Promise { - const userId = ctx.userId; - if (!userId) return false; - - // Check if user is an app member (has a record in app_memberships_sprt) - const sql = ` - SELECT 1 FROM constructive_memberships_private.app_memberships_sprt - WHERE actor_id = $1 - LIMIT 1 - `; - const result = await ctx.pool.query(sql, [userId]); - return result.rows.length > 0; -} - -async function requireAppMember(ctx: ConstructiveContext, res: Response): Promise { - if (!(await isAppMember(ctx))) { - res.status(403).json({ error: 'MEMBERSHIP_REQUIRED' }); - return false; - } - return true; -} - -// ─── Router ───────────────────────────────────────────────────────────────── - -export function createIdentityProvidersRouter(): Router { - const router = Router(); - - // Parse JSON body for PATCH/POST requests - router.use(express.json()); - - /** - * GET /identity-providers - * List all identity providers (including disabled ones) - */ - router.get('/identity-providers', async (req: Request, res: Response) => { - const ctx = req.constructive; - if (!ctx) { - return res.status(500).json({ error: 'Missing context' }); - } - - if (!(await requireAppMember(ctx, res))) return; - - try { - const identityProviders = await ctx.useModule('identityProviders'); - if (!identityProviders) { - return res.status(404).json({ error: 'Identity providers module not configured' }); - } - - const { privateSchemaName, tableName } = identityProviders; - - const sql = ` - SELECT - id, slug, kind, display_name, enabled, is_built_in, - client_id, client_secret_id, - authorization_url, token_url, userinfo_url, - scopes, pkce_enabled - FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} - ORDER BY is_built_in DESC, slug ASC - `; - const result = await ctx.pool.query(sql); - const providers = result.rows; - - res.json({ - providers: providers.map((p) => ({ - id: p.id, - slug: p.slug, - kind: p.kind, - displayName: p.display_name, - enabled: p.enabled, - isBuiltIn: p.is_built_in, - clientId: p.client_id, - hasSecret: !!p.client_secret_id, - authorizationUrl: p.authorization_url, - tokenUrl: p.token_url, - userinfoUrl: p.userinfo_url, - scopes: p.scopes || [], - pkceEnabled: p.pkce_enabled ?? true, - })), - }); - } catch (error) { - log.error('[admin-identity-providers] Failed to list providers:', error); - res.status(500).json({ error: 'Failed to list providers' }); - } - }); - - /** - * GET /identity-providers/:slug - * Get a single provider's details - */ - router.get('/identity-providers/:slug', async (req: Request, res: Response) => { - const ctx = req.constructive; - if (!ctx) { - return res.status(500).json({ error: 'Missing context' }); - } - - if (!(await requireAppMember(ctx, res))) return; - - const { slug } = req.params; - - try { - const identityProviders = await ctx.useModule('identityProviders'); - if (!identityProviders) { - return res.status(404).json({ error: 'Identity providers module not configured' }); - } - - const { privateSchemaName, tableName } = identityProviders; - - const sql = ` - SELECT - id, slug, kind, display_name, enabled, is_built_in, - client_id, client_secret_id, - authorization_url, token_url, userinfo_url, - scopes, pkce_enabled - FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} - WHERE slug = $1 - `; - const result = await ctx.pool.query(sql, [slug]); - const provider = result.rows[0]; - - if (!provider) { - return res.status(404).json({ error: 'Provider not found' }); - } - - res.json({ - id: provider.id, - slug: provider.slug, - kind: provider.kind, - displayName: provider.display_name, - enabled: provider.enabled, - isBuiltIn: provider.is_built_in, - clientId: provider.client_id, - hasSecret: !!provider.client_secret_id, - authorizationUrl: provider.authorization_url, - tokenUrl: provider.token_url, - userinfoUrl: provider.userinfo_url, - scopes: provider.scopes || [], - pkceEnabled: provider.pkce_enabled ?? true, - }); - } catch (error) { - log.error(`[admin-identity-providers] Failed to get provider ${slug}:`, error); - res.status(500).json({ error: 'Failed to get provider' }); - } - }); - - /** - * PATCH /identity-providers/:slug - * Update provider configuration (client_id, enabled, scopes, urls) - */ - router.patch('/identity-providers/:slug', async (req: Request, res: Response) => { - const ctx = req.constructive; - if (!ctx) { - return res.status(500).json({ error: 'Missing context' }); - } - - if (!(await requireAppMember(ctx, res))) return; - - const { slug } = req.params; - const body = req.body as UpdateProviderBody; - - try { - const identityProviders = await ctx.useModule('identityProviders'); - if (!identityProviders) { - return res.status(404).json({ error: 'Identity providers module not configured' }); - } - - const { privateSchemaName, tableName } = identityProviders; - - const setClauses: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - if (body.clientId !== undefined) { - setClauses.push(`client_id = $${paramIndex++}`); - values.push(body.clientId); - } - if (body.enabled !== undefined) { - setClauses.push(`enabled = $${paramIndex++}`); - values.push(body.enabled); - } - if (body.scopes !== undefined) { - setClauses.push(`scopes = $${paramIndex++}`); - values.push(body.scopes); - } - if (body.authorizationUrl !== undefined) { - setClauses.push(`authorization_url = $${paramIndex++}`); - values.push(body.authorizationUrl); - } - if (body.tokenUrl !== undefined) { - setClauses.push(`token_url = $${paramIndex++}`); - values.push(body.tokenUrl); - } - if (body.userinfoUrl !== undefined) { - setClauses.push(`userinfo_url = $${paramIndex++}`); - values.push(body.userinfoUrl); - } - if (body.pkceEnabled !== undefined) { - setClauses.push(`pkce_enabled = $${paramIndex++}`); - values.push(body.pkceEnabled); - } - - if (setClauses.length === 0) { - return res.status(400).json({ error: 'No fields to update' }); - } - - values.push(slug); - - const sql = ` - UPDATE ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} - SET ${setClauses.join(', ')} - WHERE slug = $${paramIndex} - `; - const result = await ctx.pool.query(sql, values); - if (result.rowCount === 0) { - return res.status(404).json({ error: 'Provider not found' }); - } - - log.info(`[admin-identity-providers] Updated provider ${slug}`); - res.json({ success: true }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - if (message === 'PROVIDER_NOT_FOUND') { - return res.status(404).json({ error: 'Provider not found' }); - } - log.error(`[admin-identity-providers] Failed to update provider ${slug}:`, error); - res.status(500).json({ error: 'Failed to update provider' }); - } - }); - - /** - * POST /identity-providers/:slug/secret - * Set or rotate the client secret for a provider - */ - router.post('/identity-providers/:slug/secret', async (req: Request, res: Response) => { - const ctx = req.constructive; - if (!ctx) { - return res.status(500).json({ error: 'Missing context' }); - } - - if (!(await requireAppMember(ctx, res))) return; - - const { slug } = req.params; - const body = req.body as RotateSecretBody; - - if (!body.clientSecret) { - return res.status(400).json({ error: 'clientSecret is required' }); - } - - try { - const identityProviders = await ctx.useModule('identityProviders'); - if (!identityProviders) { - return res.status(404).json({ error: 'Identity providers module not configured' }); - } - - const { privateSchemaName, tableName } = identityProviders; - const databaseId = ctx.databaseId; - if (!databaseId) { - return res.status(500).json({ error: 'Database context not available' }); - } - - // Get provider info - const lookupSql = ` - SELECT id, client_secret_id FROM ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} - WHERE slug = $1 - `; - const lookupResult = await ctx.pool.query<{ id: string; client_secret_id: string | null }>(lookupSql, [slug]); - if (lookupResult.rows.length === 0) { - return res.status(404).json({ error: 'Provider not found' }); - } - - const provider = lookupResult.rows[0]; - - // Ensure default namespace exists - const namespaceSql = ` - INSERT INTO constructive_infra_public.platform_namespaces (database_id, name) - VALUES ($1, 'default') - ON CONFLICT (database_id, name) DO UPDATE SET name = EXCLUDED.name - RETURNING id - `; - const namespaceResult = await ctx.pool.query<{ id: string }>(namespaceSql, [databaseId]); - const namespaceId = namespaceResult.rows[0].id; - - let secretId = provider.client_secret_id; - - if (secretId) { - // Update existing secret - const updateSecretSql = ` - UPDATE constructive_store_private.platform_secrets - SET value = $1::bytea, algo = 'plain', updated_at = now() - WHERE id = $2 - `; - await ctx.pool.query(updateSecretSql, [body.clientSecret, secretId]); - } else { - // Insert new secret - const insertSecretSql = ` - INSERT INTO constructive_store_private.platform_secrets (database_id, namespace_id, name, value, algo) - VALUES ($1, $2, $3, $4::bytea, 'plain') - RETURNING id - `; - const secretResult = await ctx.pool.query<{ id: string }>(insertSecretSql, [ - databaseId, - namespaceId, - `${slug}/client-secret`, - body.clientSecret, - ]); - secretId = secretResult.rows[0].id; - - // Link secret to provider - const linkSql = ` - UPDATE ${QuoteUtils.quoteQualifiedIdentifier(privateSchemaName, tableName)} - SET client_secret_id = $1 - WHERE id = $2 - `; - await ctx.pool.query(linkSql, [secretId, provider.id]); - } - - log.info(`[admin-identity-providers] Set secret for provider ${slug}`); - res.json({ success: true }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - if (message === 'PROVIDER_NOT_FOUND') { - return res.status(404).json({ error: 'Provider not found' }); - } - if (message.includes('IDENTITY_PROVIDER_NOT_FOUND')) { - return res.status(404).json({ error: 'Provider not found' }); - } - log.error(`[admin-identity-providers] Failed to rotate secret for ${slug}:`, error); - res.status(500).json({ error: 'Failed to rotate secret' }); - } - }); - - return router; -} diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index 5395b3b607..29c157ac5c 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -57,12 +57,18 @@ interface OAuthStatePayload { provider: string; } +interface OAuthEnvConfig { + oauth?: { + secret?: string; + }; +} + function getStateSecret(): string | undefined { - return getEnvVars().oauth?.secret; + return (getEnvVars() as OAuthEnvConfig).oauth?.secret; } function requireStateSecret(): string { - const secret = getEnvVars().oauth?.secret; + const secret = getStateSecret(); if (!secret) { throw new Error('OAUTH_SECRET environment variable is required'); } diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 1c602b2331..6d9b2e7e8b 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -43,8 +43,6 @@ import { localObservabilityOnly } from './middleware/observability/guard'; import { createRequestLogger } from './middleware/observability/request-logger'; import { getRoutingSchema } from './middleware/routing'; import { createOAuthRoutes } from './middleware/oauth'; -import { createIdentityProvidersRouter } from './middleware/identity-providers'; -import { createAppSettingsAuthRouter } from './middleware/app-settings-auth'; const log = new Logger('server'); @@ -206,12 +204,6 @@ class Server { // are handled without going through PostGraphile app.use('/auth', createOAuthRoutes(effectiveOpts)); - // Identity Providers API — mounted before graphile - app.use(createIdentityProvidersRouter()); - - // App Settings Auth API — mounted before graphile - app.use(createAppSettingsAuthRouter()); - // LLM Agent REST API — mounted before graphile so SSE streaming // routes are handled without going through PostGraphile app.use(createAgenticRouter()); diff --git a/packages/express-context/src/loaders/__tests__/identity-providers.test.ts b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts new file mode 100644 index 0000000000..be0be0d957 --- /dev/null +++ b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts @@ -0,0 +1,189 @@ +import type { LoaderContext } from '../types'; +import { + buildProvidersSql, + resolveIdentityProvidersConfig, +} from '../identity-providers'; +import { userAuthModuleLoader } from '../user-auth-module'; + +type QueryResult = { rows: unknown[] }; +type QueryHandler = (sql: string, values?: unknown[]) => QueryResult; + +function createMockPool(handlers: QueryHandler[]) { + const queries: Array<{ sql: string; values?: unknown[] }> = []; + const query = jest.fn(async (sql: string, values?: unknown[]) => { + queries.push({ sql, values }); + const handler = handlers.shift(); + if (!handler) { + throw new Error(`Unexpected query: ${sql}`); + } + return handler(sql, values); + }); + + return { + pool: { query }, + queries, + }; +} + +function createContext( + servicesPool: ReturnType['pool'], + tenantPool: ReturnType['pool'], + databaseId = 'tenant-db', +): LoaderContext { + return { + servicesPool, + tenantPool, + databaseId, + dbname: 'constructive-test', + } as unknown as LoaderContext; +} + +function identityProvidersModuleRow(databaseId: string, scope: string) { + return { + database_id: databaseId, + schema_name: 'auth_public', + private_schema_name: 'auth_private', + table_name: 'identity_providers', + scope, + prefix: scope, + }; +} + +describe('identityProvidersLoader metadata resolution', () => { + it('resolves provider secrets table through config_secrets_module schema metadata', async () => { + const tenant = createMockPool([ + () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), + ]); + const services = createMockPool([ + () => ({ rows: [{ database_id: 'platform-db' }] }), + () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), + () => ({ rows: [{ table_id: 'secrets-table-id' }] }), + () => ({ + rows: [ + { + schema_name: 'secret_private', + table_name: 'resolved_secrets', + }, + ], + }), + () => ({ + rows: [ + { + slug: 'github', + kind: 'oauth2', + display_name: 'GitHub', + enabled: true, + client_id: 'dummy-client-id', + client_secret: 'dummy-client-secret', + authorization_url: 'https://github.example/authorize', + token_url: 'https://github.example/token', + userinfo_url: 'https://github.example/user', + scopes: ['read:user'], + pkce_enabled: true, + }, + ], + }), + ]); + + const config = await resolveIdentityProvidersConfig( + createContext(services.pool, tenant.pool), + ); + + expect(config?.providers.get('github')).toMatchObject({ + clientId: 'dummy-client-id', + clientSecret: 'dummy-client-secret', + authorizationUrl: 'https://github.example/authorize', + }); + expect(services.queries[2].sql).toContain('config_secrets_module'); + expect(services.queries[2].values).toEqual(['platform-db', 'platform']); + expect(services.queries[4].sql).toContain('secret_private.resolved_secrets'); + expect(services.queries[4].sql).not.toContain( + 'constructive_store_private', + ); + expect(services.queries[4].sql).not.toContain('platform_secrets'); + }); + + it('builds provider SQL without the old platform secrets hardcode', () => { + const sql = buildProvidersSql( + 'auth_private', + 'identity_providers', + 'secret_private', + 'resolved_secrets', + ); + + expect(sql).toContain('auth_private.identity_providers'); + expect(sql).toContain('secret_private.resolved_secrets'); + expect(sql).not.toContain('constructive_store_private'); + expect(sql).not.toContain('platform_secrets'); + }); + + it('throws a clear error when config_secrets_module is missing for scope', async () => { + const tenant = createMockPool([ + () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), + ]); + const services = createMockPool([ + () => ({ rows: [{ database_id: 'platform-db' }] }), + () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), + () => ({ rows: [] }), + ]); + + await expect( + resolveIdentityProvidersConfig(createContext(services.pool, tenant.pool)), + ).rejects.toThrow( + 'config_secrets_module missing for scope platform on database platform-db', + ); + }); + + it('throws a clear error when config_secrets_module table resolution fails', async () => { + const tenant = createMockPool([ + () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), + ]); + const services = createMockPool([ + () => ({ rows: [{ database_id: 'platform-db' }] }), + () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), + () => ({ rows: [{ table_id: 'missing-table-id' }] }), + () => { + throw new Error('NOT_FOUND'); + }, + ]); + + await expect( + resolveIdentityProvidersConfig(createContext(services.pool, tenant.pool)), + ).rejects.toThrow( + 'schema/table resolution missing for config_secrets_module scope platform on database platform-db', + ); + }); +}); + +describe('userAuthModuleLoader', () => { + it('continues resolving identity auth function constants', async () => { + const tenant = createMockPool([ + () => ({ + rows: [ + { + schema_name: 'auth_private', + session_credentials_schema_name: 'session_private', + sign_in_function: 'sign_in', + sign_up_function: 'sign_up', + sign_out_function: 'sign_out', + sign_in_cross_origin_function: null, + request_cross_origin_token_function: null, + extend_token_expires: '1 hour', + }, + ], + }), + ]); + const services = createMockPool([]); + + const config = await userAuthModuleLoader.resolve( + createContext(services.pool, tenant.pool, 'user-auth-db'), + ); + + expect(config).toMatchObject({ + schemaName: 'auth_private', + sessionCredentialsSchemaName: 'session_private', + signInIdentityFunction: 'sign_in_identity', + signUpIdentityFunction: 'sign_up_identity', + }); + }); +}); diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts index 9cc66ff658..bdad548cf8 100644 --- a/packages/express-context/src/loaders/identity-providers.ts +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -8,11 +8,13 @@ import { QuoteUtils } from '@pgsql/quotes'; import type { + ConfigSecretsModuleRow, IdentityProviderConfigMap, IdentityProvidersConfig, IdentityProvidersModuleRow, PlatformDatabaseRow, ProviderRow, + SchemaAndTableRow, } from '../types'; import type { LoaderContext, ModuleLoader } from './types'; import { createModuleLoader } from './create-loader'; @@ -21,9 +23,11 @@ import { createModuleLoader } from './create-loader'; const IDENTITY_PROVIDERS_MODULE_SQL = ` SELECT + ipm.database_id, s.schema_name, ps.schema_name AS private_schema_name, ipm.table_name, + ipm.scope, ipm.prefix FROM metaschema_modules_public.identity_providers_module ipm JOIN metaschema_public.schema s ON s.id = ipm.schema_id @@ -32,6 +36,19 @@ const IDENTITY_PROVIDERS_MODULE_SQL = ` LIMIT 1 `; +const CONFIG_SECRETS_MODULE_SQL = ` + SELECT csm.table_id + FROM metaschema_modules_public.config_secrets_module csm + WHERE csm.database_id = $1 + AND csm.scope = $2 + LIMIT 1 +`; + +const SCHEMA_AND_TABLE_SQL = ` + SELECT schema_name, table_name + FROM metaschema.schema_and_table($1) +`; + const PLATFORM_DATABASE_SQL = ` SELECT id AS database_id FROM metaschema_public.database @@ -43,8 +60,14 @@ const PLATFORM_DATABASE_SQL = ` function buildProvidersSql( ipSchema: string, ipTable: string, + secretsSchema: string, + secretsTable: string, ): string { const providersTable = QuoteUtils.quoteQualifiedIdentifier(ipSchema, ipTable); + const secretsTableName = QuoteUtils.quoteQualifiedIdentifier( + secretsSchema, + secretsTable, + ); return ` SELECT @@ -67,9 +90,8 @@ function buildProvidersSql( ip.scopes, ip.pkce_enabled FROM ${providersTable} ip - LEFT JOIN "constructive_store_private"."platform_secrets" secrets + LEFT JOIN ${secretsTableName} secrets ON secrets.id = ip.client_secret_id - AND secrets.database_id = $1 WHERE ip.enabled = true AND ip.client_id IS NOT NULL AND ip.client_secret_id IS NOT NULL @@ -78,74 +100,127 @@ function buildProvidersSql( // ─── Loader ───────────────────────────────────────────────────────────────── +async function resolveSecretsTable( + ctx: LoaderContext, + databaseId: string, + scope: string, +): Promise { + const secretsModuleResult = + await ctx.servicesPool.query( + CONFIG_SECRETS_MODULE_SQL, + [databaseId, scope], + ); + const secretsModuleRow = secretsModuleResult.rows[0]; + if (!secretsModuleRow) { + throw new Error( + `config_secrets_module missing for scope ${scope} on database ${databaseId}`, + ); + } + + try { + const schemaResult = await ctx.servicesPool.query( + SCHEMA_AND_TABLE_SQL, + [secretsModuleRow.table_id], + ); + const schemaRow = schemaResult.rows[0]; + if (schemaRow) return schemaRow; + } catch { + // Re-throw a module-specific error instead of leaking metaschema internals. + } + + throw new Error( + `schema/table resolution missing for config_secrets_module scope ${scope} on database ${databaseId}`, + ); +} + +export async function resolveIdentityProvidersConfig( + ctx: LoaderContext, +): Promise { + const { servicesPool, tenantPool, databaseId } = ctx; + + const moduleResult = await tenantPool.query( + IDENTITY_PROVIDERS_MODULE_SQL, + [databaseId], + ); + const moduleRow = moduleResult.rows[0]; + if (!moduleRow) { + throw new Error(`identity_providers_module missing for database ${databaseId}`); + } + const functionPrefix = moduleRow.prefix || moduleRow.scope || 'platform'; + + // Provider credentials are platform-managed; auth functions remain scoped + // to the current request database. + const platformDatabaseResult = + await servicesPool.query(PLATFORM_DATABASE_SQL); + const platformDatabaseId = platformDatabaseResult.rows[0]?.database_id; + if (!platformDatabaseId) return undefined; + + const providerModuleRow = + platformDatabaseId === databaseId + ? moduleRow + : ( + await servicesPool.query( + IDENTITY_PROVIDERS_MODULE_SQL, + [platformDatabaseId], + ) + ).rows[0]; + if (!providerModuleRow) { + throw new Error( + `identity_providers_module missing for database ${platformDatabaseId}`, + ); + } + + const secretsTable = await resolveSecretsTable( + ctx, + providerModuleRow.database_id, + providerModuleRow.scope, + ); + + const providersResult = await servicesPool.query( + buildProvidersSql( + providerModuleRow.private_schema_name, + providerModuleRow.table_name, + secretsTable.schema_name, + secretsTable.table_name, + ), + ); + + const providers: IdentityProviderConfigMap = new Map(); + for (const row of providersResult.rows) { + if (!row.client_id || !row.client_secret) { + continue; + } + providers.set(row.slug, { + slug: row.slug, + kind: row.kind, + displayName: row.display_name, + enabled: row.enabled, + clientId: row.client_id, + clientSecret: row.client_secret, + authorizationUrl: row.authorization_url, + tokenUrl: row.token_url, + userinfoUrl: row.userinfo_url, + scopes: row.scopes || [], + pkceEnabled: row.pkce_enabled ?? true, + }); + } + + return { + schemaName: moduleRow.schema_name, + privateSchemaName: moduleRow.private_schema_name, + tableName: moduleRow.table_name, + scope: moduleRow.scope, + prefix: functionPrefix, + rotateSecretFunction: `rotate_identity_provider_${functionPrefix}_secret`, + providers, + }; +} + export const identityProvidersLoader: ModuleLoader = createModuleLoader({ name: 'identityProviders', ttlMs: 5 * 60_000, - async resolve(ctx: LoaderContext) { - const { servicesPool, tenantPool, databaseId } = ctx; - - const moduleResult = await tenantPool.query( - IDENTITY_PROVIDERS_MODULE_SQL, - [databaseId], - ); - const moduleRow = moduleResult.rows[0]; - if (!moduleRow) return undefined; - const functionPrefix = moduleRow.prefix || 'platform'; - - // Provider credentials are platform-managed; auth functions remain scoped - // to the current request database. - const platformDatabaseResult = - await servicesPool.query(PLATFORM_DATABASE_SQL); - const platformDatabaseId = platformDatabaseResult.rows[0]?.database_id; - if (!platformDatabaseId) return undefined; - - const providerModuleRow = - platformDatabaseId === databaseId - ? moduleRow - : ( - await servicesPool.query( - IDENTITY_PROVIDERS_MODULE_SQL, - [platformDatabaseId], - ) - ).rows[0]; - if (!providerModuleRow) return undefined; - - const providersResult = await servicesPool.query( - buildProvidersSql( - providerModuleRow.private_schema_name, - providerModuleRow.table_name, - ), - [platformDatabaseId], - ); - - const providers: IdentityProviderConfigMap = new Map(); - for (const row of providersResult.rows) { - if (!row.client_id || !row.client_secret) { - continue; - } - providers.set(row.slug, { - slug: row.slug, - kind: row.kind, - displayName: row.display_name, - enabled: row.enabled, - clientId: row.client_id, - clientSecret: row.client_secret, - authorizationUrl: row.authorization_url, - tokenUrl: row.token_url, - userinfoUrl: row.userinfo_url, - scopes: row.scopes || [], - pkceEnabled: row.pkce_enabled ?? true, - }); - } - - return { - schemaName: moduleRow.schema_name, - privateSchemaName: moduleRow.private_schema_name, - tableName: moduleRow.table_name, - prefix: functionPrefix, - rotateSecretFunction: `rotate_identity_provider_${functionPrefix}_secret`, - providers, - }; - }, + resolve: resolveIdentityProvidersConfig, }); + +export { buildProvidersSql }; diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 3a7f8ad8d5..d013a9cba8 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -247,16 +247,28 @@ export interface IdentityProvidersConfig { schemaName: string; privateSchemaName: string; tableName: string; + scope: string; prefix: string; rotateSecretFunction: string; providers: IdentityProviderConfigMap; } export interface IdentityProvidersModuleRow { + database_id: string; schema_name: string; private_schema_name: string; table_name: string; - prefix: string; + scope: string; + prefix: string | null; +} + +export interface ConfigSecretsModuleRow { + table_id: string; +} + +export interface SchemaAndTableRow { + schema_name: string; + table_name: string; } export interface PlatformDatabaseRow { From 71be2b775e0b572fcfd630cdaaaaec23a3afb08d Mon Sep 17 00:00:00 2001 From: Zhi Zhen Date: Thu, 2 Jul 2026 13:55:05 +0800 Subject: [PATCH 19/32] feat(oauth): use runtime provider config --- graphql/server/src/middleware/oauth.ts | 10 ++ .../__tests__/identity-providers.test.ts | 2 + .../src/loaders/identity-providers.ts | 17 +- packages/express-context/src/types.ts | 4 +- packages/oauth/__tests__/oauth-client.test.ts | 151 ++++++++++++++++++ packages/oauth/src/index.ts | 1 + packages/oauth/src/oauth-client.ts | 127 ++++++++++----- packages/oauth/src/types.ts | 6 +- 8 files changed, 270 insertions(+), 48 deletions(-) diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index 29c157ac5c..1ba0013877 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -124,8 +124,18 @@ function createOAuthClientForProvider( return new OAuthClient({ providers: { [providerConfig.slug]: { + slug: providerConfig.slug, + kind: providerConfig.kind, + displayName: providerConfig.displayName, + enabled: providerConfig.enabled, clientId: providerConfig.clientId, clientSecret: providerConfig.clientSecret, + authorizationUrl: providerConfig.authorizationUrl, + tokenUrl: providerConfig.tokenUrl, + userinfoUrl: providerConfig.userinfoUrl, + scopes: providerConfig.scopes, + authorizationParams: providerConfig.authorizationParams, + pkceEnabled: providerConfig.pkceEnabled, }, }, baseUrl, diff --git a/packages/express-context/src/loaders/__tests__/identity-providers.test.ts b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts index be0be0d957..48096cfede 100644 --- a/packages/express-context/src/loaders/__tests__/identity-providers.test.ts +++ b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts @@ -79,6 +79,7 @@ describe('identityProvidersLoader metadata resolution', () => { token_url: 'https://github.example/token', userinfo_url: 'https://github.example/user', scopes: ['read:user'], + extra_authorization_params: { prompt: 'select_account' }, pkce_enabled: true, }, ], @@ -93,6 +94,7 @@ describe('identityProvidersLoader metadata resolution', () => { clientId: 'dummy-client-id', clientSecret: 'dummy-client-secret', authorizationUrl: 'https://github.example/authorize', + authorizationParams: { prompt: 'select_account' }, }); expect(services.queries[2].sql).toContain('config_secrets_module'); expect(services.queries[2].values).toEqual(['platform-db', 'platform']); diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts index bdad548cf8..52795e13bb 100644 --- a/packages/express-context/src/loaders/identity-providers.ts +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -88,6 +88,7 @@ function buildProvidersSql( ip.token_url, ip.userinfo_url, ip.scopes, + ip.extra_authorization_params, ip.pkce_enabled FROM ${providersTable} ip LEFT JOIN ${secretsTableName} secrets @@ -98,6 +99,19 @@ function buildProvidersSql( `; } +function normalizeStringParams( + params: Record | null, +): Record { + if (!params) return {}; + const normalized: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'string') { + normalized[key] = value; + } + } + return normalized; +} + // ─── Loader ───────────────────────────────────────────────────────────────── async function resolveSecretsTable( @@ -200,7 +214,8 @@ export async function resolveIdentityProvidersConfig( authorizationUrl: row.authorization_url, tokenUrl: row.token_url, userinfoUrl: row.userinfo_url, - scopes: row.scopes || [], + scopes: row.scopes, + authorizationParams: normalizeStringParams(row.extra_authorization_params), pkceEnabled: row.pkce_enabled ?? true, }); } diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index d013a9cba8..37014eb7cf 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -237,7 +237,8 @@ export interface IdentityProviderFullConfig { authorizationUrl: string | null; tokenUrl: string | null; userinfoUrl: string | null; - scopes: string[]; + scopes: string[] | null; + authorizationParams: Record; pkceEnabled: boolean; } @@ -286,6 +287,7 @@ export interface ProviderRow { token_url: string | null; userinfo_url: string | null; scopes: string[] | null; + extra_authorization_params: Record | null; pkce_enabled: boolean | null; } diff --git a/packages/oauth/__tests__/oauth-client.test.ts b/packages/oauth/__tests__/oauth-client.test.ts index ef578aab9f..de556da1f2 100644 --- a/packages/oauth/__tests__/oauth-client.test.ts +++ b/packages/oauth/__tests__/oauth-client.test.ts @@ -93,6 +93,41 @@ describe('OAuthClient', () => { expect(url).not.toContain('profile'); }); + it('should use runtime authorization URL, scopes, and extra params', () => { + const client = createOAuthClient({ + providers: { + github: { + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + authorizationUrl: 'https://github.enterprise.test/login/oauth/authorize', + tokenUrl: 'https://github.enterprise.test/login/oauth/access_token', + userinfoUrl: 'https://github.enterprise.test/api/user', + scopes: ['read:user'], + authorizationParams: { + prompt: 'select_account', + client_id: 'ignored-client-id', + }, + }, + }, + baseUrl: 'https://api.example.com', + }); + + const { url, state } = client.getAuthorizationUrl({ + provider: 'github', + state: 'runtime-state', + }); + const parsed = new URL(url); + + expect(`${parsed.origin}${parsed.pathname}`).toBe( + 'https://github.enterprise.test/login/oauth/authorize' + ); + expect(parsed.searchParams.get('client_id')).toBe('runtime-client-id'); + expect(parsed.searchParams.get('scope')).toBe('read:user'); + expect(parsed.searchParams.get('prompt')).toBe('select_account'); + expect(parsed.searchParams.get('state')).toBe('runtime-state'); + expect(state).toBe('runtime-state'); + }); + it('should throw error for unknown provider', () => { const client = createOAuthClient(config); @@ -110,6 +145,87 @@ describe('OAuthClient', () => { }); }); + describe('exchangeCode', () => { + it('should exchange legacy credential-only config with static provider defaults', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + access_token: 'legacy-token', + token_type: 'bearer', + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient(config); + const tokens = await client.exchangeCode({ + provider: 'google', + code: 'legacy-code', + }); + + expect(tokens.access_token).toBe('legacy-token'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://oauth2.googleapis.com/token', + expect.objectContaining({ method: 'POST' }) + ); + const request = fetchMock.mock.calls[0][1] as RequestInit; + expect(request.headers).toMatchObject({ + 'Content-Type': 'application/x-www-form-urlencoded', + }); + const body = new URLSearchParams(request.body as string); + expect(body.get('client_id')).toBe('test-google-client-id'); + expect(body.get('client_secret')).toBe('test-google-client-secret'); + expect(body.get('code')).toBe('legacy-code'); + }); + + it('should use runtime token URL, content type, and extra token params', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + access_token: 'runtime-token', + token_type: 'bearer', + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient({ + providers: { + github: { + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + authorizationUrl: 'https://github.enterprise.test/login/oauth/authorize', + tokenUrl: 'https://github.enterprise.test/login/oauth/access_token', + userinfoUrl: 'https://github.enterprise.test/api/user', + scopes: ['read:user'], + tokenRequestContentType: 'form', + tokenParams: { + audience: 'constructive-api', + client_secret: 'ignored-client-secret', + }, + }, + }, + baseUrl: 'https://api.example.com', + }); + + const tokens = await client.exchangeCode({ + provider: 'github', + code: 'runtime-code', + }); + + expect(tokens.access_token).toBe('runtime-token'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://github.enterprise.test/login/oauth/access_token', + expect.objectContaining({ method: 'POST' }) + ); + const request = fetchMock.mock.calls[0][1] as RequestInit; + expect(request.headers).toMatchObject({ + 'Content-Type': 'application/x-www-form-urlencoded', + }); + const body = new URLSearchParams(request.body as string); + expect(body.get('client_id')).toBe('runtime-client-id'); + expect(body.get('client_secret')).toBe('runtime-client-secret'); + expect(body.get('audience')).toBe('constructive-api'); + expect(body.get('code')).toBe('runtime-code'); + }); + }); + describe('getConfig', () => { it('should return config with defaults', () => { const client = createOAuthClient(config); @@ -390,6 +506,41 @@ describe('OAuthClient', () => { expect(profile.emailVerified).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it('should use runtime userinfo URL and method', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + sub: 'runtime-google-id', + email: 'runtime@gmail.com', + email_verified: true, + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient({ + providers: { + google: { + clientId: 'runtime-google-client-id', + clientSecret: 'runtime-google-client-secret', + userinfoUrl: 'https://idp.example.test/oauth/userinfo', + userInfoMethod: 'POST', + }, + }, + baseUrl: 'https://api.example.com', + }); + const profile = await client.getUserProfile('google', 'runtime-token'); + + expect(profile.email).toBe('runtime@gmail.com'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://idp.example.test/oauth/userinfo', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer runtime-token', + }), + }) + ); + }); }); }); diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 4d5bde9753..82a23710e6 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -8,6 +8,7 @@ export { ResolvedOAuthProvider, OAuthProfile, OAuthCredentials, + OAuthClientProviderConfig, OAuthClientConfig, TokenResponse, AuthorizationUrlParams, diff --git a/packages/oauth/src/oauth-client.ts b/packages/oauth/src/oauth-client.ts index 378c2952da..ff2fbd39e5 100644 --- a/packages/oauth/src/oauth-client.ts +++ b/packages/oauth/src/oauth-client.ts @@ -1,15 +1,51 @@ import { OAuthClientConfig, - OAuthCredentials, OAuthProfile, + ResolvedOAuthProvider, TokenResponse, AuthorizationUrlParams, CallbackParams, createOAuthError, } from './types'; import { getProvider, GITHUB_EMAILS_URL, selectGitHubEmail } from './providers'; +import { resolveOAuthProvider } from './provider-resolver'; import { generateState } from './utils/state'; +const AUTHORIZATION_PARAM_RESERVED_KEYS = new Set([ + 'client_id', + 'redirect_uri', + 'response_type', + 'scope', + 'state', + 'nonce', + 'code_challenge', + 'code_challenge_method', +]); + +const TOKEN_PARAM_RESERVED_KEYS = new Set([ + 'client_id', + 'client_secret', + 'code', + 'redirect_uri', + 'grant_type', + 'code_verifier', +]); + +function applyAdditionalParams( + target: URLSearchParams | Record, + params: Record, + reservedKeys: Set, +): void { + for (const [key, value] of Object.entries(params)) { + if (!key || reservedKeys.has(key.toLowerCase())) continue; + if (target instanceof URLSearchParams) { + target.set(key, value); + } else { + target[key] = value; + } + } +} + export class OAuthClient { private config: OAuthClientConfig; @@ -24,31 +60,23 @@ export class OAuthClient { getAuthorizationUrl(params: AuthorizationUrlParams): { url: string; state: string } { const { provider: providerId, state: customState, redirectUri, scopes } = params; - - const provider = getProvider(providerId); - if (!provider) { - throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId); - } - - const credentials = this.config.providers[providerId]; - if (!credentials) { - throw createOAuthError( - `No credentials configured for provider: ${providerId}`, - 'MISSING_CREDENTIALS', - providerId - ); - } + const { config } = this.resolveProvider(providerId); const state = customState || generateState(); - const callbackUrl = this.getCallbackUrl(providerId, redirectUri || credentials.redirectUri); - const effectiveScopes = scopes || provider.scopes; + const callbackUrl = this.getCallbackUrl(providerId, redirectUri || config.redirectUri); + const effectiveScopes = scopes || config.scopes; - const url = new URL(provider.authorizationUrl); - url.searchParams.set('client_id', credentials.clientId); + const url = new URL(config.authorizationUrl); + url.searchParams.set('client_id', config.clientId); url.searchParams.set('redirect_uri', callbackUrl); url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', effectiveScopes.join(' ')); url.searchParams.set('state', state); + applyAdditionalParams( + url.searchParams, + config.authorizationParams, + AUTHORIZATION_PARAM_RESERVED_KEYS, + ); return { url: url.toString(), state }; } @@ -56,36 +84,24 @@ export class OAuthClient { async exchangeCode(params: CallbackParams): Promise { const { provider: providerId, code, redirectUri } = params; - const provider = getProvider(providerId); - if (!provider) { - throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId); - } - - const credentials = this.config.providers[providerId]; - if (!credentials) { - throw createOAuthError( - `No credentials configured for provider: ${providerId}`, - 'MISSING_CREDENTIALS', - providerId - ); - } - - const callbackUrl = this.getCallbackUrl(providerId, redirectUri || credentials.redirectUri); + const { config } = this.resolveProvider(providerId); + const callbackUrl = this.getCallbackUrl(providerId, redirectUri || config.redirectUri); const body: Record = { - client_id: credentials.clientId, - client_secret: credentials.clientSecret, + client_id: config.clientId, + client_secret: config.clientSecret, code, redirect_uri: callbackUrl, grant_type: 'authorization_code', }; + applyAdditionalParams(body, config.tokenParams, TOKEN_PARAM_RESERVED_KEYS); const headers: Record = { Accept: 'application/json', }; let requestBody: string; - if (provider.tokenRequestContentType === 'json') { + if (config.tokenRequestContentType === 'json') { headers['Content-Type'] = 'application/json'; requestBody = JSON.stringify(body); } else { @@ -93,7 +109,7 @@ export class OAuthClient { requestBody = new URLSearchParams(body).toString(); } - const response = await fetch(provider.tokenUrl, { + const response = await fetch(config.tokenUrl, { method: 'POST', headers, body: requestBody, @@ -123,10 +139,7 @@ export class OAuthClient { } async getUserProfile(providerId: string, accessToken: string): Promise { - const provider = getProvider(providerId); - if (!provider) { - throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId); - } + const { config, provider } = this.resolveProvider(providerId); const headers: Record = { Authorization: `Bearer ${accessToken}`, @@ -137,8 +150,8 @@ export class OAuthClient { headers['User-Agent'] = 'Constructive-OAuth'; } - const response = await fetch(provider.userInfoUrl, { - method: provider.userInfoMethod || 'GET', + const response = await fetch(config.userinfoUrl, { + method: config.userInfoMethod, headers, }); @@ -167,6 +180,32 @@ export class OAuthClient { return this.getUserProfile(params.provider, tokens.access_token); } + private resolveProvider(providerId: string): ResolvedOAuthProvider { + const runtimeConfig = this.config.providers[providerId]; + if (!runtimeConfig) { + if (!getProvider(providerId)) { + throw createOAuthError( + `Unknown provider: ${providerId}`, + 'UNKNOWN_PROVIDER', + providerId, + ); + } + throw createOAuthError( + `No credentials configured for provider: ${providerId}`, + 'MISSING_CREDENTIALS', + providerId, + ); + } + + return resolveOAuthProvider({ + providerId, + runtimeConfig: { + slug: providerId, + ...runtimeConfig, + }, + }); + } + private async fetchGitHubEmail( accessToken: string, profile: OAuthProfile diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index bda0664aa7..007752bba4 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -22,7 +22,7 @@ export interface OAuthProviderConfig { } export interface OAuthProviderRuntimeConfig { - slug: string; + slug?: string; kind?: OAuthProviderKind; displayName?: string; enabled?: boolean; @@ -96,8 +96,10 @@ export interface OAuthCredentials { redirectUri?: string; } +export type OAuthClientProviderConfig = OAuthProviderRuntimeConfig; + export interface OAuthClientConfig { - providers: Record; + providers: Record; baseUrl: string; callbackPath?: string; stateCookieName?: string; From 61f195f3ab9b02133184048ff419ea9b93bd44db Mon Sep 17 00:00:00 2001 From: Zhi Zhen Date: Thu, 2 Jul 2026 15:49:33 +0800 Subject: [PATCH 20/32] feat(oauth): support pkce and token auth methods --- graphql/server/src/middleware/oauth.ts | 52 +++- packages/oauth/__tests__/oauth-client.test.ts | 234 ++++++++++++++++++ packages/oauth/src/index.ts | 3 + packages/oauth/src/oauth-client.ts | 86 ++++++- packages/oauth/src/provider-resolver.ts | 21 +- packages/oauth/src/types.ts | 28 ++- packages/oauth/src/utils/pkce.ts | 17 ++ 7 files changed, 419 insertions(+), 22 deletions(-) create mode 100644 packages/oauth/src/utils/pkce.ts diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index 1ba0013877..e8f8032266 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -49,6 +49,7 @@ import { pgIntervalToMilliseconds } from '../utils/pg-interval'; const log = new Logger('oauth'); const OAUTH_STATE_COOKIE = 'oauth_state'; +const OAUTH_PKCE_COOKIE = 'oauth_pkce'; const DEFAULT_OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes const DEFAULT_ERROR_REDIRECT_PATH = '/auth/error'; @@ -57,6 +58,12 @@ interface OAuthStatePayload { provider: string; } +interface OAuthPkcePayload { + state: string; + provider: string; + code_verifier: string; +} + interface OAuthEnvConfig { oauth?: { secret?: string; @@ -308,15 +315,27 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { }, ); - res.cookie(OAUTH_STATE_COOKIE, state, { + const oauthCookieOptions = { httpOnly: authSettings?.cookieHttponly ?? true, secure: authSettings?.cookieSecure ?? isProduction, maxAge: stateMaxAge, sameSite: (authSettings?.cookieSamesite as 'lax' | 'strict' | 'none') ?? 'lax', - }); + }; + + res.cookie(OAUTH_STATE_COOKIE, state, oauthCookieOptions); const client = createOAuthClientForProvider(providerConfig, baseUrl); - const { url } = client.getAuthorizationUrl({ provider, state }); + const { url, codeVerifier } = client.getAuthorizationUrl({ provider, state }); + if (codeVerifier) { + const pkceState = createSignedState( + { state, provider, code_verifier: codeVerifier }, + { + secret: requireStateSecret(), + maxAgeMs: stateMaxAge, + }, + ); + res.cookie(OAUTH_PKCE_COOKIE, pkceState, oauthCookieOptions); + } log.info(`[oauth] Initiating OAuth flow for provider: ${provider}`); res.redirect(url); } catch (error) { @@ -345,7 +364,9 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { const baseUrl = getBaseUrl(req); const storedState = parseCookieValue(req, OAUTH_STATE_COOKIE); + const storedPkce = parseCookieValue(req, OAUTH_PKCE_COOKIE); res.clearCookie(OAUTH_STATE_COOKIE); + res.clearCookie(OAUTH_PKCE_COOKIE); // Handle OAuth provider errors if (oauthError) { @@ -450,10 +471,35 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { ); } + let codeVerifier: string | undefined; + if (providerConfig.pkceEnabled) { + const pkcePayload = verifySignedState( + storedPkce, + { secret: getStateSecret() }, + ); + if ( + !pkcePayload || + pkcePayload.state !== storedState || + pkcePayload.provider !== provider || + !pkcePayload.code_verifier + ) { + log.warn(`[oauth] Invalid PKCE verifier state for ${provider}`); + return redirectToError( + res, + baseUrl, + errorRedirectPath, + 'INVALID_PKCE', + provider, + ); + } + codeVerifier = pkcePayload.code_verifier; + } + const client = createOAuthClientForProvider(providerConfig, baseUrl); const profile = await client.handleCallback({ provider, code: code as string, + codeVerifier, }); log.info(`[oauth] Got profile for ${provider}: ${profile.email}`); diff --git a/packages/oauth/__tests__/oauth-client.test.ts b/packages/oauth/__tests__/oauth-client.test.ts index de556da1f2..6aff788391 100644 --- a/packages/oauth/__tests__/oauth-client.test.ts +++ b/packages/oauth/__tests__/oauth-client.test.ts @@ -3,6 +3,7 @@ import { resolveOAuthProvider } from '../src/provider-resolver'; import { GITHUB_EMAILS_URL, getProvider, getProviderIds } from '../src/providers'; import { generateState, verifyState } from '../src/utils/state'; import { createSignedState, verifySignedState } from '../src/utils/signed-state'; +import { deriveCodeChallenge } from '../src/utils/pkce'; const originalFetch = global.fetch; @@ -128,6 +129,32 @@ describe('OAuthClient', () => { expect(state).toBe('runtime-state'); }); + it('should add a PKCE challenge when runtime config enables PKCE', () => { + const client = createOAuthClient({ + providers: { + google: { + clientId: 'runtime-google-client-id', + clientSecret: 'runtime-google-client-secret', + pkceEnabled: true, + }, + }, + baseUrl: 'https://api.example.com', + }); + + const { url, codeVerifier } = client.getAuthorizationUrl({ + provider: 'google', + state: 'pkce-state', + }); + const parsed = new URL(url); + + expect(codeVerifier).toBeDefined(); + expect(codeVerifier).toHaveLength(43); + expect(parsed.searchParams.get('code_challenge')).toBe( + deriveCodeChallenge(codeVerifier!) + ); + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + }); + it('should throw error for unknown provider', () => { const client = createOAuthClient(config); @@ -224,6 +251,136 @@ describe('OAuthClient', () => { expect(body.get('audience')).toBe('constructive-api'); expect(body.get('code')).toBe('runtime-code'); }); + + it('should use client_secret_basic without leaking client_secret into the body', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + access_token: 'basic-token', + token_type: 'bearer', + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient({ + providers: { + google: { + clientId: 'basic-client-id', + clientSecret: 'basic-client-secret', + tokenEndpointAuthMethod: 'client_secret_basic', + }, + }, + baseUrl: 'https://api.example.com', + }); + + await client.exchangeCode({ provider: 'google', code: 'basic-code' }); + + const request = fetchMock.mock.calls[0][1] as RequestInit; + expect(request.headers).toMatchObject({ + Authorization: `Basic ${Buffer.from('basic-client-id:basic-client-secret').toString('base64')}`, + }); + const body = new URLSearchParams(request.body as string); + expect(body.get('client_secret')).toBeNull(); + expect(body.get('code')).toBe('basic-code'); + }); + + it('should allow token endpoint auth method none without a client secret', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + access_token: 'public-token', + token_type: 'bearer', + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient({ + providers: { + google: { + clientId: 'public-client-id', + tokenEndpointAuthMethod: 'none', + }, + }, + baseUrl: 'https://api.example.com', + }); + + const tokens = await client.exchangeCode({ + provider: 'google', + code: 'public-code', + }); + + expect(tokens.access_token).toBe('public-token'); + const request = fetchMock.mock.calls[0][1] as RequestInit; + const body = new URLSearchParams(request.body as string); + expect(body.get('client_id')).toBe('public-client-id'); + expect(body.get('client_secret')).toBeNull(); + }); + + it('should reject unsupported private_key_jwt token endpoint auth', async () => { + const client = createOAuthClient({ + providers: { + google: { + clientId: 'jwt-client-id', + tokenEndpointAuthMethod: 'private_key_jwt', + }, + }, + baseUrl: 'https://api.example.com', + }); + + await expect( + client.exchangeCode({ provider: 'google', code: 'jwt-code' }) + ).rejects.toMatchObject({ + code: 'TOKEN_AUTH_UNSUPPORTED', + }); + }); + + it('should send PKCE code_verifier during token exchange', async () => { + const fetchMock = jest.fn().mockResolvedValueOnce( + jsonResponse({ + access_token: 'pkce-token', + token_type: 'bearer', + }) + ); + global.fetch = fetchMock; + + const client = createOAuthClient({ + providers: { + google: { + clientId: 'pkce-client-id', + clientSecret: 'pkce-client-secret', + pkceEnabled: true, + }, + }, + baseUrl: 'https://api.example.com', + }); + + await client.exchangeCode({ + provider: 'google', + code: 'pkce-code', + codeVerifier: 'test-code-verifier', + }); + + const request = fetchMock.mock.calls[0][1] as RequestInit; + const body = new URLSearchParams(request.body as string); + expect(body.get('code_verifier')).toBe('test-code-verifier'); + }); + + it('should require a PKCE code_verifier during token exchange when PKCE is enabled', async () => { + const client = createOAuthClient({ + providers: { + google: { + clientId: 'pkce-client-id', + clientSecret: 'pkce-client-secret', + pkceEnabled: true, + }, + }, + baseUrl: 'https://api.example.com', + }); + + await expect( + client.exchangeCode({ provider: 'google', code: 'pkce-code' }) + ).rejects.toMatchObject({ + code: 'PKCE_VERIFIER_REQUIRED', + }); + }); }); describe('getConfig', () => { @@ -541,6 +698,55 @@ describe('OAuthClient', () => { }) ); }); + + it('should derive the GitHub emails URL from runtime userinfo URL', async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + id: 12345, + login: 'octocat', + email: null, + }) + ) + .mockResolvedValueOnce( + jsonResponse([ + { + email: 'enterprise@example.com', + primary: true, + verified: true, + }, + ]) + ); + global.fetch = fetchMock; + + const client = createOAuthClient({ + providers: { + github: { + clientId: 'runtime-github-client-id', + clientSecret: 'runtime-github-client-secret', + userinfoUrl: 'https://github.enterprise.test/api/v3/user', + }, + }, + baseUrl: 'https://api.example.com', + }); + const profile = await client.getUserProfile('github', 'runtime-github-token'); + + expect(profile.email).toBe('enterprise@example.com'); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://github.enterprise.test/api/v3/user/emails', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer runtime-github-token', + }), + }) + ); + expect(fetchMock).not.toHaveBeenCalledWith( + GITHUB_EMAILS_URL, + expect.anything() + ); + }); }); }); @@ -614,6 +820,34 @@ describe('provider resolver', () => { expect(resolved.config.scopes).toEqual(['user:email', 'read:user']); }); + it('should allow public client config without a client secret', () => { + const resolved = resolveOAuthProvider({ + providerId: 'google', + runtimeConfig: { + slug: 'google', + kind: 'oidc', + clientId: 'public-client-id', + tokenEndpointAuthMethod: 'none', + }, + }); + + expect(resolved.config.tokenEndpointAuthMethod).toBe('none'); + expect(resolved.config.clientSecret).toBeUndefined(); + }); + + it('should require a client secret for default confidential clients', () => { + expect(() => + resolveOAuthProvider({ + providerId: 'google', + runtimeConfig: { + slug: 'google', + kind: 'oidc', + clientId: 'confidential-client-id', + }, + }) + ).toThrow('missing required config: clientSecret'); + }); + it('should reject disabled providers', () => { expect(() => resolveOAuthProvider({ diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 82a23710e6..f4245855de 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -12,6 +12,7 @@ export { OAuthClientConfig, TokenResponse, AuthorizationUrlParams, + AuthorizationUrlResult, CallbackParams, OAuthError, createOAuthError, @@ -37,3 +38,5 @@ export { createSignedState, verifySignedState, } from './utils/signed-state'; + +export { generateCodeVerifier, deriveCodeChallenge } from './utils/pkce'; diff --git a/packages/oauth/src/oauth-client.ts b/packages/oauth/src/oauth-client.ts index ff2fbd39e5..bbb074aa38 100644 --- a/packages/oauth/src/oauth-client.ts +++ b/packages/oauth/src/oauth-client.ts @@ -4,12 +4,14 @@ import { ResolvedOAuthProvider, TokenResponse, AuthorizationUrlParams, + AuthorizationUrlResult, CallbackParams, createOAuthError, } from './types'; -import { getProvider, GITHUB_EMAILS_URL, selectGitHubEmail } from './providers'; +import { getProvider, selectGitHubEmail } from './providers'; import { resolveOAuthProvider } from './provider-resolver'; import { generateState } from './utils/state'; +import { deriveCodeChallenge, generateCodeVerifier } from './utils/pkce'; const AUTHORIZATION_PARAM_RESERVED_KEYS = new Set([ 'client_id', @@ -46,6 +48,34 @@ function applyAdditionalParams( } } +function requireClientSecret( + clientSecret: string | undefined, + providerId: string, +): string { + if (!clientSecret) { + throw createOAuthError( + `Provider ${providerId} missing required config: clientSecret`, + 'PROVIDER_CONFIG_INVALID', + providerId, + ); + } + return clientSecret; +} + +function createBasicAuthorizationHeader( + clientId: string, + clientSecret: string, +): string { + return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; +} + +function deriveGitHubEmailsUrl(userinfoUrl: string): string { + const url = new URL(userinfoUrl); + const pathname = url.pathname.replace(/\/$/, ''); + url.pathname = `${pathname}/emails`; + return url.toString(); +} + export class OAuthClient { private config: OAuthClientConfig; @@ -58,13 +88,14 @@ export class OAuthClient { }; } - getAuthorizationUrl(params: AuthorizationUrlParams): { url: string; state: string } { + getAuthorizationUrl(params: AuthorizationUrlParams): AuthorizationUrlResult { const { provider: providerId, state: customState, redirectUri, scopes } = params; const { config } = this.resolveProvider(providerId); const state = customState || generateState(); const callbackUrl = this.getCallbackUrl(providerId, redirectUri || config.redirectUri); const effectiveScopes = scopes || config.scopes; + const codeVerifier = config.pkceEnabled ? generateCodeVerifier() : undefined; const url = new URL(config.authorizationUrl); url.searchParams.set('client_id', config.clientId); @@ -72,34 +103,68 @@ export class OAuthClient { url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', effectiveScopes.join(' ')); url.searchParams.set('state', state); + if (codeVerifier) { + url.searchParams.set('code_challenge', deriveCodeChallenge(codeVerifier)); + url.searchParams.set('code_challenge_method', 'S256'); + } applyAdditionalParams( url.searchParams, config.authorizationParams, AUTHORIZATION_PARAM_RESERVED_KEYS, ); - return { url: url.toString(), state }; + return { url: url.toString(), state, codeVerifier }; } async exchangeCode(params: CallbackParams): Promise { - const { provider: providerId, code, redirectUri } = params; + const { provider: providerId, code, redirectUri, codeVerifier } = params; const { config } = this.resolveProvider(providerId); + if (config.tokenEndpointAuthMethod === 'private_key_jwt') { + throw createOAuthError( + 'Token endpoint auth method private_key_jwt is not supported', + 'TOKEN_AUTH_UNSUPPORTED', + providerId, + ); + } + + if (config.pkceEnabled && !codeVerifier) { + throw createOAuthError( + `PKCE code verifier is required for provider: ${providerId}`, + 'PKCE_VERIFIER_REQUIRED', + providerId, + ); + } + const callbackUrl = this.getCallbackUrl(providerId, redirectUri || config.redirectUri); const body: Record = { - client_id: config.clientId, - client_secret: config.clientSecret, code, redirect_uri: callbackUrl, grant_type: 'authorization_code', }; - applyAdditionalParams(body, config.tokenParams, TOKEN_PARAM_RESERVED_KEYS); const headers: Record = { Accept: 'application/json', }; + if (config.tokenEndpointAuthMethod === 'client_secret_basic') { + headers.Authorization = createBasicAuthorizationHeader( + config.clientId, + requireClientSecret(config.clientSecret, providerId), + ); + } else { + body.client_id = config.clientId; + if (config.tokenEndpointAuthMethod === 'client_secret_post') { + body.client_secret = requireClientSecret(config.clientSecret, providerId); + } + } + + if (config.pkceEnabled && codeVerifier) { + body.code_verifier = codeVerifier; + } + applyAdditionalParams(body, config.tokenParams, TOKEN_PARAM_RESERVED_KEYS); + let requestBody: string; if (config.tokenRequestContentType === 'json') { headers['Content-Type'] = 'application/json'; @@ -169,7 +234,7 @@ export class OAuthClient { const profile = provider.mapProfile(data); if (providerId === 'github') { - return this.fetchGitHubEmail(accessToken, profile); + return this.fetchGitHubEmail(accessToken, profile, config.userinfoUrl); } return profile; @@ -208,10 +273,11 @@ export class OAuthClient { private async fetchGitHubEmail( accessToken: string, - profile: OAuthProfile + profile: OAuthProfile, + userinfoUrl: string, ): Promise { try { - const response = await fetch(GITHUB_EMAILS_URL, { + const response = await fetch(deriveGitHubEmailsUrl(userinfoUrl), { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', diff --git a/packages/oauth/src/provider-resolver.ts b/packages/oauth/src/provider-resolver.ts index a7ab84d2a0..46771ff721 100644 --- a/packages/oauth/src/provider-resolver.ts +++ b/packages/oauth/src/provider-resolver.ts @@ -43,6 +43,18 @@ export function resolveOAuthProvider(ctx: { ); } + const tokenEndpointAuthMethod = + runtimeConfig.tokenEndpointAuthMethod ?? 'client_secret_post'; + const clientSecret = + tokenEndpointAuthMethod === 'client_secret_post' || + tokenEndpointAuthMethod === 'client_secret_basic' + ? requireProviderConfigString( + runtimeConfig.clientSecret, + 'clientSecret', + ctx.providerId + ) + : runtimeConfig.clientSecret || undefined; + const resolvedConfig: OAuthProviderResolvedConfig = { slug: runtimeConfig.slug, kind: runtimeConfig.kind || provider.kind, @@ -53,11 +65,7 @@ export function resolveOAuthProvider(ctx: { 'clientId', ctx.providerId ), - clientSecret: requireProviderConfigString( - runtimeConfig.clientSecret, - 'clientSecret', - ctx.providerId - ), + clientSecret, redirectUri: runtimeConfig.redirectUri, authorizationUrl: requireProviderConfigString( runtimeConfig.authorizationUrl ?? provider.authorizationUrl, @@ -81,8 +89,7 @@ export function resolveOAuthProvider(ctx: { ? provider.scopes : runtimeConfig.scopes, pkceEnabled: runtimeConfig.pkceEnabled ?? false, - tokenEndpointAuthMethod: - runtimeConfig.tokenEndpointAuthMethod ?? 'client_secret_post', + tokenEndpointAuthMethod, tokenRequestContentType: runtimeConfig.tokenRequestContentType ?? provider.tokenRequestContentType ?? diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index 007752bba4..1286097fb5 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -55,7 +55,7 @@ export interface OAuthProviderResolvedConfig { enabled: boolean; clientId: string; - clientSecret: string; + clientSecret?: string; redirectUri?: string; authorizationUrl: string; @@ -96,7 +96,24 @@ export interface OAuthCredentials { redirectUri?: string; } -export type OAuthClientProviderConfig = OAuthProviderRuntimeConfig; +type OAuthClientProviderConfigBase = Omit< + OAuthProviderRuntimeConfig, + 'clientSecret' | 'tokenEndpointAuthMethod' +>; + +export type OAuthClientProviderConfig = + | (OAuthClientProviderConfigBase & { + tokenEndpointAuthMethod?: 'client_secret_post' | 'client_secret_basic'; + clientSecret: string; + }) + | (OAuthClientProviderConfigBase & { + tokenEndpointAuthMethod: 'none'; + clientSecret?: string; + }) + | (OAuthClientProviderConfigBase & { + tokenEndpointAuthMethod: 'private_key_jwt'; + clientSecret?: string; + }); export interface OAuthClientConfig { providers: Record; @@ -121,10 +138,17 @@ export interface AuthorizationUrlParams { scopes?: string[]; } +export interface AuthorizationUrlResult { + url: string; + state: string; + codeVerifier?: string; +} + export interface CallbackParams { provider: string; code: string; redirectUri?: string; + codeVerifier?: string; } export interface OAuthError extends Error { diff --git a/packages/oauth/src/utils/pkce.ts b/packages/oauth/src/utils/pkce.ts new file mode 100644 index 0000000000..0f98bbfb08 --- /dev/null +++ b/packages/oauth/src/utils/pkce.ts @@ -0,0 +1,17 @@ +import { createHash, randomBytes } from 'crypto'; + +const DEFAULT_CODE_VERIFIER_BYTES = 32; + +export function generateCodeVerifier( + byteLength = DEFAULT_CODE_VERIFIER_BYTES, +): string { + return randomBytes(byteLength).toString('base64url'); +} + +export function deriveCodeChallenge(codeVerifier: string): string { + if (!codeVerifier) { + throw new Error('PKCE code verifier is required'); + } + + return createHash('sha256').update(codeVerifier).digest('base64url'); +} From d6ab87b0a0eaddb6d1c0be71214981abf027a00a Mon Sep 17 00:00:00 2001 From: zetazzz Date: Thu, 2 Jul 2026 16:31:50 +0800 Subject: [PATCH 21/32] fix(oauth): harden pkce identity runtime --- .../src/middleware/__tests__/oauth.test.ts | 335 ++++++++++++++++++ graphql/server/src/middleware/oauth.ts | 2 +- .../__tests__/identity-providers.test.ts | 47 ++- .../src/loaders/user-auth-module.ts | 22 ++ packages/express-context/src/types.ts | 1 + packages/oauth/__tests__/oauth-client.test.ts | 8 +- packages/oauth/src/oauth-client.ts | 9 +- 7 files changed, 417 insertions(+), 7 deletions(-) create mode 100644 graphql/server/src/middleware/__tests__/oauth.test.ts diff --git a/graphql/server/src/middleware/__tests__/oauth.test.ts b/graphql/server/src/middleware/__tests__/oauth.test.ts new file mode 100644 index 0000000000..7bf81bf575 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/oauth.test.ts @@ -0,0 +1,335 @@ +import express from 'express'; +import http from 'http'; +import type { AddressInfo } from 'net'; +import { + createSignedState, + deriveCodeChallenge, + verifySignedState, +} from '@constructive-io/oauth'; + +import { createOAuthRoutes } from '../oauth'; + +const OAUTH_SECRET = 'test-oauth-state-secret'; +const originalFetch = global.fetch; +const authQueryMock = jest.fn(); + +jest.mock('@pgpmjs/env', () => ({ + getNodeEnv: jest.fn(() => 'test'), + getEnvVars: jest.fn(() => ({ + oauth: { + secret: OAUTH_SECRET, + }, + })), +})); + +jest.mock('@pgpmjs/logger', () => ({ + Logger: jest.fn(() => ({ + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + })), +})); + +interface TestHttpResponse { + statusCode: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +interface OAuthStatePayload { + redirect_uri: string; + provider: string; +} + +interface OAuthPkcePayload { + state: string; + provider: string; + code_verifier: string; +} + +const providerConfig = { + slug: 'github', + kind: 'oauth2' as const, + displayName: 'GitHub', + enabled: true, + clientId: 'github-client-id', + clientSecret: 'github-client-secret', + authorizationUrl: 'https://github.example.test/login/oauth/authorize', + tokenUrl: 'https://github.example.test/login/oauth/access_token', + userinfoUrl: 'https://github.example.test/api/v3/user', + scopes: ['read:user', 'user:email'], + authorizationParams: { + prompt: 'select_account', + }, + pkceEnabled: true, +}; + +afterEach(() => { + global.fetch = originalFetch; + authQueryMock.mockReset(); +}); + +function createConstructiveContext() { + return { + withPgClient: jest.fn(async (fn: (client: { query: typeof authQueryMock }) => Promise) => + fn({ query: authQueryMock }), + ), + useModule: jest.fn(async (name: string) => { + if (name === 'identityProviders') { + return { + providers: new Map([[providerConfig.slug, providerConfig]]), + }; + } + if (name === 'userAuthModule') { + return { + schemaName: 'constructive_auth_public', + identityFunctionSchemaName: 'constructive_auth_private', + signInIdentityFunction: 'sign_in_identity', + signUpIdentityFunction: 'sign_up_identity', + }; + } + if (name === 'authSettings') { + return { + cookieHttponly: true, + cookieSecure: false, + cookieSamesite: 'lax', + }; + } + if (name === 'connectedAccountsModule') { + return undefined; + } + return undefined; + }), + }; +} + +async function withOAuthServer( + run: (baseUrl: string) => Promise, +): Promise { + const app = express(); + app.use((req, _res, next) => { + (req as any).constructive = createConstructiveContext(); + next(); + }); + app.use('/auth', createOAuthRoutes({} as any)); + + const server = await new Promise((resolve) => { + const listening = app.listen(0, '127.0.0.1', () => resolve(listening)); + }); + + try { + const { port } = server.address() as AddressInfo; + return await run(`http://127.0.0.1:${port}`); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +async function request( + url: string, + headers: Record = {}, +): Promise { + return new Promise((resolve, reject) => { + const req = http.request(url, { method: 'GET', headers }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + res.on('end', () => { + resolve({ + statusCode: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + }); + req.on('error', reject); + req.end(); + }); +} + +function getSetCookieValues(headers: http.IncomingHttpHeaders): string[] { + const setCookie = headers['set-cookie']; + if (!setCookie) return []; + return Array.isArray(setCookie) ? setCookie : [setCookie]; +} + +function readCookie(setCookies: string[], name: string): string { + const cookie = setCookies.find((value) => value.startsWith(`${name}=`)); + if (!cookie) { + throw new Error(`Missing ${name} cookie`); + } + const value = cookie.split(';')[0].slice(name.length + 1); + return decodeURIComponent(value); +} + +describe('OAuth routes', () => { + it('binds PKCE verifier to the signed state cookie without exposing it in the redirect URL', async () => { + await withOAuthServer(async (baseUrl) => { + const response = await request(`${baseUrl}/auth/github?redirect_uri=%2Fdashboard`); + + expect(response.statusCode).toBe(302); + const location = response.headers.location; + expect(location).toBeDefined(); + + const redirect = new URL(location!); + const setCookies = getSetCookieValues(response.headers); + const stateCookie = readCookie(setCookies, 'oauth_state'); + const pkceCookie = readCookie(setCookies, 'oauth_pkce'); + + expect(redirect.origin).toBe('https://github.example.test'); + expect(redirect.pathname).toBe('/login/oauth/authorize'); + expect(redirect.searchParams.get('state')).toBe(stateCookie); + expect(redirect.searchParams.get('code_challenge_method')).toBe('S256'); + expect(redirect.searchParams.get('prompt')).toBe('select_account'); + expect(location).not.toContain('code_verifier'); + + const statePayload = verifySignedState(stateCookie, { + secret: OAUTH_SECRET, + }); + expect(statePayload).toMatchObject({ + redirect_uri: '/dashboard', + provider: 'github', + }); + + const pkcePayload = verifySignedState(pkceCookie, { + secret: OAUTH_SECRET, + }); + expect(pkcePayload).toMatchObject({ + state: stateCookie, + provider: 'github', + }); + expect(pkcePayload!.code_verifier).toHaveLength(43); + expect(redirect.searchParams.get('code_challenge')).toBe( + deriveCodeChallenge(pkcePayload!.code_verifier), + ); + + expect( + setCookies.find((value) => value.startsWith('oauth_state=')), + ).toContain('HttpOnly'); + expect( + setCookies.find((value) => value.startsWith('oauth_pkce=')), + ).toContain('HttpOnly'); + }); + }); + + it('rejects callback requests when the PKCE verifier is not bound to the returned state', async () => { + await withOAuthServer(async (baseUrl) => { + const stateCookie = createSignedState( + { redirect_uri: '/dashboard', provider: 'github' }, + { secret: OAUTH_SECRET, maxAgeMs: 60_000 }, + ); + const pkceCookie = createSignedState( + { + state: 'different-state', + provider: 'github', + code_verifier: 'test-code-verifier', + }, + { secret: OAUTH_SECRET, maxAgeMs: 60_000 }, + ); + const callbackUrl = new URL('/auth/github/callback', baseUrl); + callbackUrl.searchParams.set('code', 'callback-code'); + callbackUrl.searchParams.set('state', stateCookie); + + const response = await request(callbackUrl.toString(), { + Cookie: [ + `oauth_state=${encodeURIComponent(stateCookie)}`, + `oauth_pkce=${encodeURIComponent(pkceCookie)}`, + ].join('; '), + }); + + expect(response.statusCode).toBe(302); + const redirect = new URL(response.headers.location!); + expect(redirect.pathname).toBe('/auth/error'); + expect(redirect.searchParams.get('error')).toBe('INVALID_PKCE'); + expect(redirect.searchParams.get('provider')).toBe('github'); + }); + }); + + it('uses the identity function schema for successful sign-up callbacks', async () => { + await withOAuthServer(async (baseUrl) => { + const beginResponse = await request(`${baseUrl}/auth/github?redirect_uri=%2Fdashboard`); + const setCookies = getSetCookieValues(beginResponse.headers); + const stateCookie = readCookie(setCookies, 'oauth_state'); + const pkceCookie = readCookie(setCookies, 'oauth_pkce'); + const pkcePayload = verifySignedState(pkceCookie, { + secret: OAUTH_SECRET, + }); + expect(pkcePayload).toBeTruthy(); + + global.fetch = jest.fn(async (url: string | URL, init?: RequestInit) => { + const urlString = url.toString(); + if (urlString === 'https://github.example.test/login/oauth/access_token') { + const body = JSON.parse(init?.body as string); + expect(body.code_verifier).toBe(pkcePayload!.code_verifier); + return { + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ + access_token: 'provider-access-token', + token_type: 'bearer', + }), + text: jest.fn(), + } as unknown as Response; + } + if (urlString === 'https://github.example.test/api/v3/user') { + return { + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ + id: 12345, + login: 'octocat', + email: 'octocat@example.test', + name: 'Octo Cat', + }), + text: jest.fn(), + } as unknown as Response; + } + if (urlString === 'https://github.example.test/api/v3/user/emails') { + return { + ok: true, + status: 200, + json: jest.fn().mockResolvedValue([ + { + email: 'octocat@example.test', + primary: true, + verified: true, + }, + ]), + text: jest.fn(), + } as unknown as Response; + } + throw new Error(`Unexpected fetch URL: ${urlString}`); + }) as unknown as typeof fetch; + authQueryMock.mockResolvedValueOnce({ + rows: [ + { + access_token: 'constructive-session-token', + }, + ], + }); + + const callbackUrl = new URL('/auth/github/callback', baseUrl); + callbackUrl.searchParams.set('code', 'callback-code'); + callbackUrl.searchParams.set('state', stateCookie); + const callbackResponse = await request(callbackUrl.toString(), { + Cookie: [ + `oauth_state=${encodeURIComponent(stateCookie)}`, + `oauth_pkce=${encodeURIComponent(pkceCookie)}`, + ].join('; '), + }); + + expect(callbackResponse.statusCode).toBe(302); + expect(callbackResponse.headers.location).toBe('/dashboard'); + expect(authQueryMock).toHaveBeenCalledTimes(1); + expect(authQueryMock.mock.calls[0][0]).toContain( + 'constructive_auth_private.sign_up_identity', + ); + expect( + getSetCookieValues(callbackResponse.headers).some((cookie) => + cookie.startsWith('constructive_session='), + ), + ).toBe(true); + }); + }); +}); diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index e8f8032266..d14e01c37e 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -508,7 +508,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { const userAgent = req.get('user-agent') || ''; const { connectedAccountsModule, userAuthModule } = modules; - const authPrivateSchema = userAuthModule.schemaName; + const authPrivateSchema = userAuthModule.identityFunctionSchemaName; const signInFn = userAuthModule.signInIdentityFunction; const signUpFn = userAuthModule.signUpIdentityFunction; const emailVerified = isEmailVerified(profile); diff --git a/packages/express-context/src/loaders/__tests__/identity-providers.test.ts b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts index 48096cfede..87d3be1302 100644 --- a/packages/express-context/src/loaders/__tests__/identity-providers.test.ts +++ b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts @@ -158,12 +158,16 @@ describe('identityProvidersLoader metadata resolution', () => { }); describe('userAuthModuleLoader', () => { + afterEach(() => { + userAuthModuleLoader.invalidate(); + }); + it('continues resolving identity auth function constants', async () => { const tenant = createMockPool([ () => ({ rows: [ { - schema_name: 'auth_private', + schema_name: 'auth_public', session_credentials_schema_name: 'session_private', sign_in_function: 'sign_in', sign_up_function: 'sign_up', @@ -174,6 +178,13 @@ describe('userAuthModuleLoader', () => { }, ], }), + () => ({ + rows: [ + { + schema_name: 'auth_private', + }, + ], + }), ]); const services = createMockPool([]); @@ -182,10 +193,42 @@ describe('userAuthModuleLoader', () => { ); expect(config).toMatchObject({ - schemaName: 'auth_private', + schemaName: 'auth_public', + identityFunctionSchemaName: 'auth_private', sessionCredentialsSchemaName: 'session_private', signInIdentityFunction: 'sign_in_identity', signUpIdentityFunction: 'sign_up_identity', }); }); + + it('falls back to the public auth schema when identity function schema is not discoverable', async () => { + const tenant = createMockPool([ + () => ({ + rows: [ + { + schema_name: 'auth_public', + session_credentials_schema_name: null, + sign_in_function: 'sign_in', + sign_up_function: 'sign_up', + sign_out_function: 'sign_out', + sign_in_cross_origin_function: null, + request_cross_origin_token_function: null, + extend_token_expires: '1 hour', + }, + ], + }), + () => ({ rows: [] }), + ]); + const services = createMockPool([]); + + const config = await userAuthModuleLoader.resolve( + createContext(services.pool, tenant.pool, 'fallback-user-auth-db'), + ); + + expect(config).toMatchObject({ + schemaName: 'auth_public', + identityFunctionSchemaName: 'auth_public', + sessionCredentialsSchemaName: 'auth_public', + }); + }); }); diff --git a/packages/express-context/src/loaders/user-auth-module.ts b/packages/express-context/src/loaders/user-auth-module.ts index f1f7d73dd6..18d284ef19 100644 --- a/packages/express-context/src/loaders/user-auth-module.ts +++ b/packages/express-context/src/loaders/user-auth-module.ts @@ -30,9 +30,23 @@ const USER_AUTH_MODULE_SQL = ` LIMIT 1 `; +const IDENTITY_FUNCTION_SCHEMA_SQL = ` + SELECT n.nspname AS schema_name + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE p.proname = $1 + AND p.pronargs = 7 + ORDER BY CASE WHEN n.nspname = $2 THEN 0 ELSE 1 END, n.nspname + LIMIT 1 +`; + const SIGN_IN_IDENTITY_FUNCTION = 'sign_in_identity'; const SIGN_UP_IDENTITY_FUNCTION = 'sign_up_identity'; +interface FunctionSchemaRow { + schema_name: string; +} + // ─── Loader ───────────────────────────────────────────────────────────────── export const userAuthModuleLoader: ModuleLoader = @@ -49,8 +63,16 @@ export const userAuthModuleLoader: ModuleLoader = const row = result.rows[0]; if (!row) return undefined; + const identitySchemaResult = await tenantPool.query( + IDENTITY_FUNCTION_SCHEMA_SQL, + [SIGN_IN_IDENTITY_FUNCTION, row.schema_name], + ); + const identityFunctionSchemaName = + identitySchemaResult.rows[0]?.schema_name || row.schema_name; + return { schemaName: row.schema_name, + identityFunctionSchemaName, sessionCredentialsSchemaName: row.session_credentials_schema_name || row.schema_name, signInFunction: row.sign_in_function, diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 37014eb7cf..0536662ea0 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -188,6 +188,7 @@ export interface LlmConfig { export interface UserAuthModuleConfig { schemaName: string; + identityFunctionSchemaName: string; sessionCredentialsSchemaName: string; signInFunction: string; signUpFunction: string; diff --git a/packages/oauth/__tests__/oauth-client.test.ts b/packages/oauth/__tests__/oauth-client.test.ts index 6aff788391..e0ec4c23e8 100644 --- a/packages/oauth/__tests__/oauth-client.test.ts +++ b/packages/oauth/__tests__/oauth-client.test.ts @@ -264,8 +264,8 @@ describe('OAuthClient', () => { const client = createOAuthClient({ providers: { google: { - clientId: 'basic-client-id', - clientSecret: 'basic-client-secret', + clientId: 'basic client/id:+', + clientSecret: 'basic secret:/+@', tokenEndpointAuthMethod: 'client_secret_basic', }, }, @@ -276,9 +276,11 @@ describe('OAuthClient', () => { const request = fetchMock.mock.calls[0][1] as RequestInit; expect(request.headers).toMatchObject({ - Authorization: `Basic ${Buffer.from('basic-client-id:basic-client-secret').toString('base64')}`, + Authorization: + 'Basic YmFzaWMrY2xpZW50JTJGaWQlM0ElMkI6YmFzaWMrc2VjcmV0JTNBJTJGJTJCJTQw', }); const body = new URLSearchParams(request.body as string); + expect(body.get('client_id')).toBeNull(); expect(body.get('client_secret')).toBeNull(); expect(body.get('code')).toBe('basic-code'); }); diff --git a/packages/oauth/src/oauth-client.ts b/packages/oauth/src/oauth-client.ts index bbb074aa38..b333c6ac96 100644 --- a/packages/oauth/src/oauth-client.ts +++ b/packages/oauth/src/oauth-client.ts @@ -62,11 +62,18 @@ function requireClientSecret( return clientSecret; } +function formEncodeCredential(value: string): string { + const encoded = new URLSearchParams([['value', value]]).toString(); + return encoded.slice('value='.length); +} + function createBasicAuthorizationHeader( clientId: string, clientSecret: string, ): string { - return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; + const encodedClientId = formEncodeCredential(clientId); + const encodedClientSecret = formEncodeCredential(clientSecret); + return `Basic ${Buffer.from(`${encodedClientId}:${encodedClientSecret}`).toString('base64')}`; } function deriveGitHubEmailsUrl(userinfoUrl: string): string { From 6e76eadd1c1a590960be2eb8a1940f367bc36963 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 6 Jul 2026 17:20:12 +0800 Subject: [PATCH 22/32] fix(oauth): validate callback state provider --- .../src/middleware/__tests__/oauth.test.ts | 26 +++++++++++++++++++ graphql/server/src/middleware/oauth.ts | 11 ++++++++ 2 files changed, 37 insertions(+) diff --git a/graphql/server/src/middleware/__tests__/oauth.test.ts b/graphql/server/src/middleware/__tests__/oauth.test.ts index 7bf81bf575..82bfec26bf 100644 --- a/graphql/server/src/middleware/__tests__/oauth.test.ts +++ b/graphql/server/src/middleware/__tests__/oauth.test.ts @@ -246,6 +246,32 @@ describe('OAuth routes', () => { }); }); + it('rejects callback requests when signed state belongs to another provider', async () => { + await withOAuthServer(async (baseUrl) => { + const fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + const stateCookie = createSignedState( + { redirect_uri: '/dashboard', provider: 'github' }, + { secret: OAUTH_SECRET, maxAgeMs: 60_000 }, + ); + const callbackUrl = new URL('/auth/google/callback', baseUrl); + callbackUrl.searchParams.set('code', 'callback-code'); + callbackUrl.searchParams.set('state', stateCookie); + + const response = await request(callbackUrl.toString(), { + Cookie: `oauth_state=${encodeURIComponent(stateCookie)}`, + }); + + expect(response.statusCode).toBe(302); + const redirect = new URL(response.headers.location!); + expect(redirect.pathname).toBe('/auth/error'); + expect(redirect.searchParams.get('error')).toBe('INVALID_STATE'); + expect(redirect.searchParams.get('provider')).toBe('google'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(authQueryMock).not.toHaveBeenCalled(); + }); + }); + it('uses the identity function schema for successful sign-up callbacks', async () => { await withOAuthServer(async (baseUrl) => { const beginResponse = await request(`${baseUrl}/auth/github?redirect_uri=%2Fdashboard`); diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index d14e01c37e..e2404d6647 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -408,6 +408,17 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { ); } + if (statePayload.provider !== provider) { + log.warn(`[oauth] State provider mismatch for ${provider}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'INVALID_STATE', + provider, + ); + } + const { redirect_uri: redirectUriFromState } = statePayload; const ctx = req.constructive; From 580679e7e6066528acf6f491a30e169ac2b794ab Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 6 Jul 2026 19:16:17 +0800 Subject: [PATCH 23/32] refactor(oauth): clarify state secret config --- .../src/middleware/__tests__/oauth.test.ts | 25 ++++++++--------- graphql/server/src/middleware/oauth.ts | 28 ++++++++----------- .../__snapshots__/merge.test.ts.snap | 4 ++- pgpm/env/__tests__/merge.test.ts | 3 +- pgpm/env/src/env.ts | 4 +-- pgpm/types/src/pgpm.ts | 4 +-- 6 files changed, 32 insertions(+), 36 deletions(-) diff --git a/graphql/server/src/middleware/__tests__/oauth.test.ts b/graphql/server/src/middleware/__tests__/oauth.test.ts index 82bfec26bf..2a48af4e3a 100644 --- a/graphql/server/src/middleware/__tests__/oauth.test.ts +++ b/graphql/server/src/middleware/__tests__/oauth.test.ts @@ -9,17 +9,12 @@ import { import { createOAuthRoutes } from '../oauth'; -const OAUTH_SECRET = 'test-oauth-state-secret'; +const OAUTH_STATE_SECRET = 'test-oauth-state-secret'; const originalFetch = global.fetch; const authQueryMock = jest.fn(); jest.mock('@pgpmjs/env', () => ({ getNodeEnv: jest.fn(() => 'test'), - getEnvVars: jest.fn(() => ({ - oauth: { - secret: OAUTH_SECRET, - }, - })), })); jest.mock('@pgpmjs/logger', () => ({ @@ -111,7 +106,11 @@ async function withOAuthServer( (req as any).constructive = createConstructiveContext(); next(); }); - app.use('/auth', createOAuthRoutes({} as any)); + app.use('/auth', createOAuthRoutes({ + oauth: { + stateSecret: OAUTH_STATE_SECRET, + }, + } as any)); const server = await new Promise((resolve) => { const listening = app.listen(0, '127.0.0.1', () => resolve(listening)); @@ -185,7 +184,7 @@ describe('OAuth routes', () => { expect(location).not.toContain('code_verifier'); const statePayload = verifySignedState(stateCookie, { - secret: OAUTH_SECRET, + secret: OAUTH_STATE_SECRET, }); expect(statePayload).toMatchObject({ redirect_uri: '/dashboard', @@ -193,7 +192,7 @@ describe('OAuth routes', () => { }); const pkcePayload = verifySignedState(pkceCookie, { - secret: OAUTH_SECRET, + secret: OAUTH_STATE_SECRET, }); expect(pkcePayload).toMatchObject({ state: stateCookie, @@ -217,7 +216,7 @@ describe('OAuth routes', () => { await withOAuthServer(async (baseUrl) => { const stateCookie = createSignedState( { redirect_uri: '/dashboard', provider: 'github' }, - { secret: OAUTH_SECRET, maxAgeMs: 60_000 }, + { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 }, ); const pkceCookie = createSignedState( { @@ -225,7 +224,7 @@ describe('OAuth routes', () => { provider: 'github', code_verifier: 'test-code-verifier', }, - { secret: OAUTH_SECRET, maxAgeMs: 60_000 }, + { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 }, ); const callbackUrl = new URL('/auth/github/callback', baseUrl); callbackUrl.searchParams.set('code', 'callback-code'); @@ -252,7 +251,7 @@ describe('OAuth routes', () => { global.fetch = fetchMock as unknown as typeof fetch; const stateCookie = createSignedState( { redirect_uri: '/dashboard', provider: 'github' }, - { secret: OAUTH_SECRET, maxAgeMs: 60_000 }, + { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 }, ); const callbackUrl = new URL('/auth/google/callback', baseUrl); callbackUrl.searchParams.set('code', 'callback-code'); @@ -279,7 +278,7 @@ describe('OAuth routes', () => { const stateCookie = readCookie(setCookies, 'oauth_state'); const pkceCookie = readCookie(setCookies, 'oauth_pkce'); const pkcePayload = verifySignedState(pkceCookie, { - secret: OAUTH_SECRET, + secret: OAUTH_STATE_SECRET, }); expect(pkcePayload).toBeTruthy(); diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index e2404d6647..af3186dc9e 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -24,7 +24,7 @@ import { verifySignedState, } from '@constructive-io/oauth'; import { Logger } from '@pgpmjs/logger'; -import { getNodeEnv, getEnvVars } from '@pgpmjs/env'; +import { getNodeEnv } from '@pgpmjs/env'; import { QuoteUtils } from '@pgsql/quotes'; import type { ConstructiveOptions } from '@constructive-io/graphql-types'; import type { @@ -64,20 +64,14 @@ interface OAuthPkcePayload { code_verifier: string; } -interface OAuthEnvConfig { - oauth?: { - secret?: string; - }; -} - -function getStateSecret(): string | undefined { - return (getEnvVars() as OAuthEnvConfig).oauth?.secret; +function getStateSecret(opts: ConstructiveOptions): string | undefined { + return opts.oauth?.stateSecret; } -function requireStateSecret(): string { - const secret = getStateSecret(); +function requireStateSecret(opts: ConstructiveOptions): string { + const secret = getStateSecret(opts); if (!secret) { - throw new Error('OAUTH_SECRET environment variable is required'); + throw new Error('OAUTH_STATE_SECRET environment variable is required'); } return secret; } @@ -211,7 +205,7 @@ function redirectToError( res.redirect(errorUrl.toString()); } -export function createOAuthRoutes(_opts: ConstructiveOptions): Router { +export function createOAuthRoutes(opts: ConstructiveOptions): Router { const router = Router(); const isProduction = getNodeEnv() === 'production'; @@ -310,7 +304,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { const state = createSignedState( { redirect_uri: redirectUri, provider }, { - secret: requireStateSecret(), + secret: requireStateSecret(opts), maxAgeMs: stateMaxAge, }, ); @@ -330,7 +324,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { const pkceState = createSignedState( { state, provider, code_verifier: codeVerifier }, { - secret: requireStateSecret(), + secret: requireStateSecret(opts), maxAgeMs: stateMaxAge, }, ); @@ -395,7 +389,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { const statePayload = verifySignedState( storedState as string, - { secret: getStateSecret() }, + { secret: getStateSecret(opts) }, ); if (!statePayload) { log.warn(`[oauth] Invalid or expired state for ${provider}`); @@ -486,7 +480,7 @@ export function createOAuthRoutes(_opts: ConstructiveOptions): Router { if (providerConfig.pkceEnabled) { const pkcePayload = verifySignedState( storedPkce, - { secret: getStateSecret() }, + { secret: getStateSecret(opts) }, ); if ( !pkcePayload || diff --git a/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap b/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap index ebcc5c5d24..5da2e0078d 100644 --- a/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/pgpm/env/__tests__/__snapshots__/merge.test.ts.snap @@ -54,7 +54,9 @@ exports[`getEnvOptions merges defaults, config, env, and overrides 1`] = ` "useTx": false, }, }, - "oauth": {}, + "oauth": { + "stateSecret": "test-state-secret", + }, "pg": { "database": "config-db", "host": "override-host", diff --git a/pgpm/env/__tests__/merge.test.ts b/pgpm/env/__tests__/merge.test.ts index 8924d0b4ec..7b39234223 100644 --- a/pgpm/env/__tests__/merge.test.ts +++ b/pgpm/env/__tests__/merge.test.ts @@ -153,7 +153,8 @@ describe('getEnvOptions', () => { DB_CONNECTIONS_APP_PASSWORD: 'env-app-pass', DB_CONNECTIONS_ADMIN_USER: 'env-admin-user', PORT: '7777', - DEPLOYMENT_FAST: 'false' + DEPLOYMENT_FAST: 'false', + OAUTH_STATE_SECRET: 'test-state-secret' }; const result = getEnvOptions( diff --git a/pgpm/env/src/env.ts b/pgpm/env/src/env.ts index f5c0b86958..6442741eaf 100644 --- a/pgpm/env/src/env.ts +++ b/pgpm/env/src/env.ts @@ -81,7 +81,7 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => SMTP_DEBUG, // OAuth env vars - OAUTH_SECRET + OAUTH_STATE_SECRET } = env; return { @@ -178,7 +178,7 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions => ...(SMTP_DEBUG && { debug: parseEnvBoolean(SMTP_DEBUG) }), }, oauth: { - ...(OAUTH_SECRET && { secret: OAUTH_SECRET }), + ...(OAUTH_STATE_SECRET && { stateSecret: OAUTH_STATE_SECRET }), } }; }; diff --git a/pgpm/types/src/pgpm.ts b/pgpm/types/src/pgpm.ts index abdb8f4150..f2eaba20d3 100644 --- a/pgpm/types/src/pgpm.ts +++ b/pgpm/types/src/pgpm.ts @@ -135,8 +135,8 @@ export interface CDNOptions { * OAuth configuration options */ export interface OAuthOptions { - /** Secret key for signing OAuth state tokens (HMAC-SHA256) */ - secret?: string; + /** Secret key for signing OAuth state and PKCE cookies (HMAC-SHA256) */ + stateSecret?: string; } /** From 8a97c022f488a4d036ceb12a4e8a64ab9c1a0a3b Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 6 Jul 2026 21:59:46 +0800 Subject: [PATCH 24/32] fix(express-context): resolve platform database by dbname --- .../__tests__/identity-providers.test.ts | 1 + .../src/loaders/identity-providers.ts | 23 ++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/express-context/src/loaders/__tests__/identity-providers.test.ts b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts index 87d3be1302..c47b4cda6e 100644 --- a/packages/express-context/src/loaders/__tests__/identity-providers.test.ts +++ b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts @@ -96,6 +96,7 @@ describe('identityProvidersLoader metadata resolution', () => { authorizationUrl: 'https://github.example/authorize', authorizationParams: { prompt: 'select_account' }, }); + expect(services.queries[0].values).toEqual(['constructive-test']); expect(services.queries[2].sql).toContain('config_secrets_module'); expect(services.queries[2].values).toEqual(['platform-db', 'platform']); expect(services.queries[4].sql).toContain('secret_private.resolved_secrets'); diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts index 52795e13bb..2b13c733a6 100644 --- a/packages/express-context/src/loaders/identity-providers.ts +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -50,10 +50,19 @@ const SCHEMA_AND_TABLE_SQL = ` `; const PLATFORM_DATABASE_SQL = ` - SELECT id AS database_id - FROM metaschema_public.database - WHERE owner_id IS NULL - ORDER BY created_at ASC + SELECT d.id AS database_id + FROM metaschema_public.database d + WHERE d.name = $1 + OR EXISTS ( + SELECT 1 + FROM services_public.apis a + WHERE a.database_id = d.id + AND a.dbname = $1 + ) + ORDER BY + CASE WHEN d.name = $1 THEN 0 ELSE 1 END, + CASE WHEN d.owner_id IS NULL THEN 0 ELSE 1 END, + d.created_at ASC LIMIT 1 `; @@ -150,7 +159,7 @@ async function resolveSecretsTable( export async function resolveIdentityProvidersConfig( ctx: LoaderContext, ): Promise { - const { servicesPool, tenantPool, databaseId } = ctx; + const { servicesPool, tenantPool, databaseId, dbname } = ctx; const moduleResult = await tenantPool.query( IDENTITY_PROVIDERS_MODULE_SQL, @@ -165,7 +174,9 @@ export async function resolveIdentityProvidersConfig( // Provider credentials are platform-managed; auth functions remain scoped // to the current request database. const platformDatabaseResult = - await servicesPool.query(PLATFORM_DATABASE_SQL); + await servicesPool.query(PLATFORM_DATABASE_SQL, [ + dbname, + ]); const platformDatabaseId = platformDatabaseResult.rows[0]?.database_id; if (!platformDatabaseId) return undefined; From fb58f3952bf29b8fe0e2bd468063971c52587cf6 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 7 Jul 2026 09:24:13 +0800 Subject: [PATCH 25/32] docs(oauth): add local e2e test steps --- docs/plan/oauth/oauth-local-e2e.md | 258 +++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/plan/oauth/oauth-local-e2e.md diff --git a/docs/plan/oauth/oauth-local-e2e.md b/docs/plan/oauth/oauth-local-e2e.md new file mode 100644 index 0000000000..c9629a6380 --- /dev/null +++ b/docs/plan/oauth/oauth-local-e2e.md @@ -0,0 +1,258 @@ +# OAuth Local E2E Test Steps + +This is the short local runbook for validating the OAuth runtime on `feat/oauth-reorg`. + +Do not commit real OAuth client secrets, `OAUTH_STATE_SECRET`, non-local database passwords, or reusable tokens. Export provider credentials from a local secret source. + +## 1. Use the Target Branch + +```bash +cd /Users/zeta/Projects/interweb/src/agents/constructive +git fetch origin +git checkout feat/oauth-reorg +git pull --ff-only +git status --short --branch +``` + +## 2. Prepare Local Secrets + +Prepare two groups of environment variables: + +- GraphQL server runtime: `OAUTH_STATE_SECRET`. The server reads this at startup to sign and verify `oauth_state` / `oauth_pkce` cookies. +- Provider initialization SQL: `GITHUB_OAUTH_*` and `GOOGLE_OAUTH_*`. These are passed into the provider setup `psql` scripts below, then written or rotated into the database. The GraphQL server does not read these provider credential env vars directly. + +```bash +# GraphQL server runtime. +export OAUTH_STATE_SECRET="$(openssl rand -hex 32)" + +# Provider initialization scripts only. +export GITHUB_OAUTH_CLIENT_ID="" +export GITHUB_OAUTH_CLIENT_SECRET="" + +export GOOGLE_OAUTH_CLIENT_ID="" +export GOOGLE_OAUTH_CLIENT_SECRET="" +``` + +Provider callback URLs: + +```text +GitHub: http://auth.localhost:3000/auth/github/callback +Google: http://localhost:3000/auth/google/callback +``` + +Google is tested through bare `localhost` because Google may reject `auth.localhost` as a local redirect URI. + +## 3. Rebuild the Local Database + +```bash +cd /Users/zeta/Projects/interweb/src/agents/constructive-db + +pgpm docker start --image docker.io/constructiveio/postgres-plus:18 --recreate +eval "$(pgpm env)" + +pgpm admin-users bootstrap --yes +pgpm admin-users add --test --yes + +dropdb --if-exists constructive +createdb constructive + +pgpm deploy --yes --database constructive --package constructive-local +``` + +## 4. Apply Local OAuth Test Setup + +`PGPASSWORD` in the setup commands is only the local PostgreSQL connection password for `psql`. + +```bash +PGPASSWORD=password psql -h localhost -U postgres -d constructive -v ON_ERROR_STOP=1 <<'SQL' +-- Local HTTP cookies. +UPDATE constructive_auth_private.app_settings_auth +SET allow_identity_sign_in = true, + allow_identity_sign_up = true, + oauth_require_verified_email = false, + cookie_secure = false; + +-- rotate_identity_provider_platform_secret currently expects this namespace. +INSERT INTO constructive_infra_public.platform_namespaces (database_id, name, status) +SELECT database_id, 'default', 'ready' +FROM services_public.apis +WHERE name = 'auth' +LIMIT 1 +ON CONFLICT (database_id, name) DO NOTHING; + +-- Google local callback alias: localhost -> auth API. +DELETE FROM services_public.domains +WHERE domain = 'localhost' + AND subdomain IS NULL; + +INSERT INTO services_public.domains (database_id, api_id, domain, subdomain, annotations) +SELECT database_id, id, 'localhost', NULL, + jsonb_build_object('purpose', 'local-google-oauth-callback') +FROM services_public.apis +WHERE name = 'auth' +LIMIT 1; +SQL +``` + +## 5. Build and Start the Server + +```bash +cd /Users/zeta/Projects/interweb/src/agents/constructive +pnpm install +pnpm build +``` + +```bash +# GraphQL server runtime environment. +PGHOST=localhost \ +PGPORT=5432 \ +PGUSER=postgres \ +PGPASSWORD=password \ +PGDATABASE=constructive \ +NODE_USE_ENV_PROXY=1 \ +NO_PROXY="localhost,127.0.0.1,::1,.localhost" \ +OAUTH_STATE_SECRET="$OAUTH_STATE_SECRET" \ +pnpm --filter @constructive-io/graphql-server dev +``` + +Expected: + +```text +listening at http://localhost:3000 +``` + +`PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, and `PGDATABASE` are the GraphQL server's database connection settings for this local run. `OAUTH_STATE_SECRET` is the OAuth state-cookie signing secret. `NODE_USE_ENV_PROXY` and `NO_PROXY` control Node's outbound HTTP behavior. + +Keep `NODE_USE_ENV_PROXY=1` when the local network needs `HTTP_PROXY` / `HTTPS_PROXY`. Without it, Node's native `fetch` can fail during OAuth token exchange with `CALLBACK_FAILED` and `TypeError: fetch failed`. + +## 6. Configure Providers + +These commands use the provider initialization env vars from step 2. `psql` substitutes them into the setup script, and `rotate_identity_provider_platform_secret(...)` stores the provider client secret in the database. The GraphQL server later reads provider credentials through loaders and database metadata, not from `GITHUB_OAUTH_*` or `GOOGLE_OAUTH_*`. + +GitHub: + +```bash +PGPASSWORD=password psql -h localhost -U postgres -d constructive \ + -v ON_ERROR_STOP=1 \ + -v github_client_id="$GITHUB_OAUTH_CLIENT_ID" \ + -v github_client_secret="$GITHUB_OAUTH_CLIENT_SECRET" <<'SQL' +UPDATE constructive_auth_private.identity_providers +SET enabled = true, + client_id = :'github_client_id', + authorization_url = 'https://github.com/login/oauth/authorize', + token_url = 'https://github.com/login/oauth/access_token', + userinfo_url = 'https://api.github.com/user', + scopes = ARRAY['read:user', 'user:email'], + pkce_enabled = true +WHERE slug = 'github'; + +SELECT constructive_auth_private.rotate_identity_provider_platform_secret( + (SELECT id FROM constructive_auth_private.identity_providers WHERE slug = 'github'), + :'github_client_secret' +); +SQL +``` + +Google: + +```bash +PGPASSWORD=password psql -h localhost -U postgres -d constructive \ + -v ON_ERROR_STOP=1 \ + -v google_client_id="$GOOGLE_OAUTH_CLIENT_ID" \ + -v google_client_secret="$GOOGLE_OAUTH_CLIENT_SECRET" <<'SQL' +UPDATE constructive_auth_private.identity_providers +SET enabled = true, + client_id = :'google_client_id', + authorization_url = 'https://accounts.google.com/o/oauth2/v2/auth', + token_url = 'https://oauth2.googleapis.com/token', + userinfo_url = 'https://openidconnect.googleapis.com/v1/userinfo', + scopes = ARRAY['openid', 'email', 'profile'], + pkce_enabled = true +WHERE slug = 'google'; + +SELECT constructive_auth_private.rotate_identity_provider_platform_secret( + (SELECT id FROM constructive_auth_private.identity_providers WHERE slug = 'google'), + :'google_client_secret' +); +SQL +``` + +Verify without printing secrets: + +```bash +PGPASSWORD=password psql -h localhost -U postgres -d constructive -v ON_ERROR_STOP=1 <<'SQL' +SELECT slug, + enabled, + client_id IS NOT NULL AND client_id <> '' AS has_client_id, + client_secret_id IS NOT NULL AS has_client_secret, + scopes, + pkce_enabled +FROM constructive_auth_private.identity_providers +WHERE slug IN ('github', 'google') +ORDER BY slug; +SQL +``` + +## 7. Smoke Test + +```bash +curl --noproxy '*' http://auth.localhost:3000/auth/providers +curl --noproxy '*' http://localhost:3000/auth/providers +``` + +Expected: + +```json +{"providers":["github","google"]} +``` + +Authorization redirects: + +```bash +curl --noproxy '*' -i 'http://auth.localhost:3000/auth/github?redirect_uri=/auth/providers' +curl --noproxy '*' -i 'http://localhost:3000/auth/google?redirect_uri=/auth/providers' +``` + +Expected: + +- GitHub redirects to `https://github.com/login/oauth/authorize`. +- Google redirects to `https://accounts.google.com/o/oauth2/v2/auth`. +- Both responses set `oauth_state`. +- Both responses set `oauth_pkce` when `pkce_enabled = true`. + +## 8. Browser Test + +GitHub: + +```text +http://auth.localhost:3000/auth/github?redirect_uri=/auth/providers +``` + +Google: + +```text +http://localhost:3000/auth/google?redirect_uri=/auth/providers +``` + +Expected result: + +- Provider authorization completes. +- Browser redirects back to `/auth/providers`. +- The response shows the configured providers. +- Server logs include `Got profile`, `OAuth success`, and a successful cookie authentication. + +Optional DB check: + +```bash +PGPASSWORD=password psql -h localhost -U postgres -d constructive -x -c " +SELECT service, + identifier IS NOT NULL AS has_identifier, + details->>'email' AS email, + is_verified, + created_at +FROM constructive_user_identifiers_private.connected_accounts +WHERE service IN ('github', 'google') +ORDER BY created_at DESC +LIMIT 10; +" +``` From 5b9ebd26d6f13524d7fc944af2bbb99b68449f62 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 27 Jul 2026 08:15:55 +0800 Subject: [PATCH 26/32] fix(oauth): support legacy secrets metadata --- .../__tests__/identity-providers.test.ts | 172 +++++++++++++++--- .../src/loaders/identity-providers.ts | 125 ++++++++++--- packages/express-context/src/types.ts | 15 +- 3 files changed, 264 insertions(+), 48 deletions(-) diff --git a/packages/express-context/src/loaders/__tests__/identity-providers.test.ts b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts index c47b4cda6e..1ef409820a 100644 --- a/packages/express-context/src/loaders/__tests__/identity-providers.test.ts +++ b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts @@ -1,3 +1,5 @@ +import type { Pool } from 'pg'; + import type { LoaderContext } from '../types'; import { buildProvidersSql, @@ -28,14 +30,14 @@ function createMockPool(handlers: QueryHandler[]) { function createContext( servicesPool: ReturnType['pool'], tenantPool: ReturnType['pool'], - databaseId = 'tenant-db', + databaseId = 'tenant-db' ): LoaderContext { return { - servicesPool, - tenantPool, + servicesPool: servicesPool as unknown as Pool, + tenantPool: tenantPool as unknown as Pool, databaseId, dbname: 'constructive-test', - } as unknown as LoaderContext; + }; } function identityProvidersModuleRow(databaseId: string, scope: string) { @@ -57,7 +59,15 @@ describe('identityProvidersLoader metadata resolution', () => { const services = createMockPool([ () => ({ rows: [{ database_id: 'platform-db' }] }), () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), - () => ({ rows: [{ table_id: 'secrets-table-id' }] }), + () => ({ + rows: [ + { + has_internal_secrets_module: true, + has_config_secrets_module: false, + }, + ], + }), + () => ({ rows: [{ internal_secrets_table_id: 'secrets-table-id' }] }), () => ({ rows: [ { @@ -87,7 +97,7 @@ describe('identityProvidersLoader metadata resolution', () => { ]); const config = await resolveIdentityProvidersConfig( - createContext(services.pool, tenant.pool), + createContext(services.pool, tenant.pool) ); expect(config?.providers.get('github')).toMatchObject({ @@ -97,13 +107,92 @@ describe('identityProvidersLoader metadata resolution', () => { authorizationParams: { prompt: 'select_account' }, }); expect(services.queries[0].values).toEqual(['constructive-test']); - expect(services.queries[2].sql).toContain('config_secrets_module'); - expect(services.queries[2].values).toEqual(['platform-db', 'platform']); - expect(services.queries[4].sql).toContain('secret_private.resolved_secrets'); - expect(services.queries[4].sql).not.toContain( - 'constructive_store_private', + expect(services.queries[2].sql).toContain('to_regclass'); + expect(services.queries[3].sql).toContain('internal_secrets_module'); + expect(services.queries[3].values).toEqual(['platform-db', 'platform']); + expect(services.queries[5].sql).toContain( + 'secret_private.resolved_secrets' ); - expect(services.queries[4].sql).not.toContain('platform_secrets'); + expect(services.queries[5].sql).not.toContain('constructive_store_private'); + expect(services.queries[5].sql).not.toContain('platform_secrets'); + }); + + it('supports the Hub DB config_secrets_module contract', async () => { + const tenant = createMockPool([ + () => ({ rows: [identityProvidersModuleRow('tenant-db', 'platform')] }), + ]); + const services = createMockPool([ + () => ({ rows: [{ database_id: 'platform-db' }] }), + () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), + () => ({ + rows: [ + { + has_internal_secrets_module: false, + has_config_secrets_module: true, + }, + ], + }), + () => ({ rows: [{ table_id: 'platform-secrets-table-id' }] }), + () => ({ + rows: [ + { + schema_name: 'constructive_store_private', + table_name: 'platform_secrets', + }, + ], + }), + () => ({ rows: [] }), + ]); + + const config = await resolveIdentityProvidersConfig( + createContext(services.pool, tenant.pool) + ); + + expect(config?.rotateSecretFunction).toBe( + 'rotate_identity_provider_platform_secret' + ); + expect(services.queries[3].sql).toContain('config_secrets_module'); + expect(services.queries[3].values).toEqual(['platform-db', 'platform']); + expect(services.queries[5].sql).toContain( + 'constructive_store_private.platform_secrets' + ); + }); + + it('falls back to config_secrets_module when both relations exist but only the legacy row is provisioned', async () => { + const tenant = createMockPool([ + () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), + ]); + const services = createMockPool([ + () => ({ rows: [{ database_id: 'platform-db' }] }), + () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), + () => ({ + rows: [ + { + has_internal_secrets_module: true, + has_config_secrets_module: true, + }, + ], + }), + () => ({ rows: [] }), + () => ({ rows: [{ table_id: 'legacy-secrets-table-id' }] }), + () => ({ + rows: [ + { + schema_name: 'legacy_private', + table_name: 'legacy_secrets', + }, + ], + }), + () => ({ rows: [] }), + ]); + + await resolveIdentityProvidersConfig( + createContext(services.pool, tenant.pool) + ); + + expect(services.queries[3].sql).toContain('internal_secrets_module'); + expect(services.queries[4].sql).toContain('config_secrets_module'); + expect(services.queries[6].sql).toContain('legacy_private.legacy_secrets'); }); it('builds provider SQL without the old platform secrets hardcode', () => { @@ -111,7 +200,7 @@ describe('identityProvidersLoader metadata resolution', () => { 'auth_private', 'identity_providers', 'secret_private', - 'resolved_secrets', + 'resolved_secrets' ); expect(sql).toContain('auth_private.identity_providers'); @@ -120,20 +209,53 @@ describe('identityProvidersLoader metadata resolution', () => { expect(sql).not.toContain('platform_secrets'); }); - it('throws a clear error when config_secrets_module is missing for scope', async () => { + it('throws a clear error when neither secrets metadata relation exists', async () => { + const tenant = createMockPool([ + () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), + ]); + const services = createMockPool([ + () => ({ rows: [{ database_id: 'platform-db' }] }), + () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), + () => ({ + rows: [ + { + has_internal_secrets_module: false, + has_config_secrets_module: false, + }, + ], + }), + ]); + + await expect( + resolveIdentityProvidersConfig(createContext(services.pool, tenant.pool)) + ).rejects.toThrow( + 'secrets module metadata unavailable for database platform-db' + ); + }); + + it('throws a clear error when neither available module has the requested scope', async () => { const tenant = createMockPool([ () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), ]); const services = createMockPool([ () => ({ rows: [{ database_id: 'platform-db' }] }), () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), + () => ({ + rows: [ + { + has_internal_secrets_module: true, + has_config_secrets_module: true, + }, + ], + }), + () => ({ rows: [] }), () => ({ rows: [] }), ]); await expect( - resolveIdentityProvidersConfig(createContext(services.pool, tenant.pool)), + resolveIdentityProvidersConfig(createContext(services.pool, tenant.pool)) ).rejects.toThrow( - 'config_secrets_module missing for scope platform on database platform-db', + 'internal_secrets_module and config_secrets_module missing for scope platform on database platform-db' ); }); @@ -144,16 +266,24 @@ describe('identityProvidersLoader metadata resolution', () => { const services = createMockPool([ () => ({ rows: [{ database_id: 'platform-db' }] }), () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), - () => ({ rows: [{ table_id: 'missing-table-id' }] }), + () => ({ + rows: [ + { + has_internal_secrets_module: true, + has_config_secrets_module: false, + }, + ], + }), + () => ({ rows: [{ internal_secrets_table_id: 'missing-table-id' }] }), () => { throw new Error('NOT_FOUND'); }, ]); await expect( - resolveIdentityProvidersConfig(createContext(services.pool, tenant.pool)), + resolveIdentityProvidersConfig(createContext(services.pool, tenant.pool)) ).rejects.toThrow( - 'schema/table resolution missing for config_secrets_module scope platform on database platform-db', + 'schema/table resolution missing for internal_secrets_module scope platform on database platform-db' ); }); }); @@ -190,7 +320,7 @@ describe('userAuthModuleLoader', () => { const services = createMockPool([]); const config = await userAuthModuleLoader.resolve( - createContext(services.pool, tenant.pool, 'user-auth-db'), + createContext(services.pool, tenant.pool, 'user-auth-db') ); expect(config).toMatchObject({ @@ -223,7 +353,7 @@ describe('userAuthModuleLoader', () => { const services = createMockPool([]); const config = await userAuthModuleLoader.resolve( - createContext(services.pool, tenant.pool, 'fallback-user-auth-db'), + createContext(services.pool, tenant.pool, 'fallback-user-auth-db') ); expect(config).toMatchObject({ diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts index 2b13c733a6..627de21254 100644 --- a/packages/express-context/src/loaders/identity-providers.ts +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -9,12 +9,14 @@ import { QuoteUtils } from '@pgsql/quotes'; import type { ConfigSecretsModuleRow, + InternalSecretsModuleRow, IdentityProviderConfigMap, IdentityProvidersConfig, IdentityProvidersModuleRow, PlatformDatabaseRow, ProviderRow, SchemaAndTableRow, + SecretsModuleAvailabilityRow, } from '../types'; import type { LoaderContext, ModuleLoader } from './types'; import { createModuleLoader } from './create-loader'; @@ -44,6 +46,24 @@ const CONFIG_SECRETS_MODULE_SQL = ` LIMIT 1 `; +const INTERNAL_SECRETS_MODULE_SQL = ` + SELECT ism.internal_secrets_table_id + FROM metaschema_modules_public.internal_secrets_module ism + WHERE ism.database_id = $1 + AND ism.scope = $2 + LIMIT 1 +`; + +const SECRETS_MODULE_AVAILABILITY_SQL = ` + SELECT + to_regclass( + 'metaschema_modules_public.internal_secrets_module' + ) IS NOT NULL AS has_internal_secrets_module, + to_regclass( + 'metaschema_modules_public.config_secrets_module' + ) IS NOT NULL AS has_config_secrets_module +`; + const SCHEMA_AND_TABLE_SQL = ` SELECT schema_name, table_name FROM metaschema.schema_and_table($1) @@ -70,12 +90,12 @@ function buildProvidersSql( ipSchema: string, ipTable: string, secretsSchema: string, - secretsTable: string, + secretsTable: string ): string { const providersTable = QuoteUtils.quoteQualifiedIdentifier(ipSchema, ipTable); const secretsTableName = QuoteUtils.quoteQualifiedIdentifier( secretsSchema, - secretsTable, + secretsTable ); return ` @@ -109,7 +129,7 @@ function buildProvidersSql( } function normalizeStringParams( - params: Record | null, + params: Record | null ): Record { if (!params) return {}; const normalized: Record = {}; @@ -126,24 +146,75 @@ function normalizeStringParams( async function resolveSecretsTable( ctx: LoaderContext, databaseId: string, - scope: string, + scope: string ): Promise { - const secretsModuleResult = - await ctx.servicesPool.query( + const availabilityResult = + await ctx.servicesPool.query( + SECRETS_MODULE_AVAILABILITY_SQL + ); + const availability = availabilityResult.rows[0]; + + // Some supported DB revisions still expose the pre-split secrets metadata. + // Prefer the new contract while retaining support for the legacy module. + let selectedModule: + | { + name: 'internal_secrets_module' | 'config_secrets_module'; + tableId: string; + } + | undefined; + + if (availability?.has_internal_secrets_module) { + const internalResult = + await ctx.servicesPool.query( + INTERNAL_SECRETS_MODULE_SQL, + [databaseId, scope] + ); + const tableId = internalResult.rows[0]?.internal_secrets_table_id; + if (tableId) { + selectedModule = { + name: 'internal_secrets_module', + tableId, + }; + } + } + + if (!selectedModule && availability?.has_config_secrets_module) { + const configResult = await ctx.servicesPool.query( CONFIG_SECRETS_MODULE_SQL, - [databaseId, scope], + [databaseId, scope] ); - const secretsModuleRow = secretsModuleResult.rows[0]; - if (!secretsModuleRow) { + const tableId = configResult.rows[0]?.table_id; + if (tableId) { + selectedModule = { + name: 'config_secrets_module', + tableId, + }; + } + } + + if (!selectedModule) { + const availableModules = [ + availability?.has_internal_secrets_module && 'internal_secrets_module', + availability?.has_config_secrets_module && 'config_secrets_module', + ].filter(Boolean); + + if (availableModules.length === 0) { + throw new Error( + `secrets module metadata unavailable for database ${databaseId}` + ); + } + throw new Error( - `config_secrets_module missing for scope ${scope} on database ${databaseId}`, + `${availableModules.join( + ' and ' + )} missing for scope ${scope} on database ${databaseId}` ); } try { const schemaResult = await ctx.servicesPool.query( SCHEMA_AND_TABLE_SQL, - [secretsModuleRow.table_id], + [selectedModule.tableId] ); const schemaRow = schemaResult.rows[0]; if (schemaRow) return schemaRow; @@ -152,31 +223,33 @@ async function resolveSecretsTable( } throw new Error( - `schema/table resolution missing for config_secrets_module scope ${scope} on database ${databaseId}`, + `schema/table resolution missing for ${selectedModule.name} scope ${scope} on database ${databaseId}` ); } export async function resolveIdentityProvidersConfig( - ctx: LoaderContext, + ctx: LoaderContext ): Promise { const { servicesPool, tenantPool, databaseId, dbname } = ctx; const moduleResult = await tenantPool.query( IDENTITY_PROVIDERS_MODULE_SQL, - [databaseId], + [databaseId] ); const moduleRow = moduleResult.rows[0]; if (!moduleRow) { - throw new Error(`identity_providers_module missing for database ${databaseId}`); + throw new Error( + `identity_providers_module missing for database ${databaseId}` + ); } const functionPrefix = moduleRow.prefix || moduleRow.scope || 'platform'; // Provider credentials are platform-managed; auth functions remain scoped // to the current request database. - const platformDatabaseResult = - await servicesPool.query(PLATFORM_DATABASE_SQL, [ - dbname, - ]); + const platformDatabaseResult = await servicesPool.query( + PLATFORM_DATABASE_SQL, + [dbname] + ); const platformDatabaseId = platformDatabaseResult.rows[0]?.database_id; if (!platformDatabaseId) return undefined; @@ -186,19 +259,19 @@ export async function resolveIdentityProvidersConfig( : ( await servicesPool.query( IDENTITY_PROVIDERS_MODULE_SQL, - [platformDatabaseId], + [platformDatabaseId] ) ).rows[0]; if (!providerModuleRow) { throw new Error( - `identity_providers_module missing for database ${platformDatabaseId}`, + `identity_providers_module missing for database ${platformDatabaseId}` ); } const secretsTable = await resolveSecretsTable( ctx, providerModuleRow.database_id, - providerModuleRow.scope, + providerModuleRow.scope ); const providersResult = await servicesPool.query( @@ -206,8 +279,8 @@ export async function resolveIdentityProvidersConfig( providerModuleRow.private_schema_name, providerModuleRow.table_name, secretsTable.schema_name, - secretsTable.table_name, - ), + secretsTable.table_name + ) ); const providers: IdentityProviderConfigMap = new Map(); @@ -226,7 +299,9 @@ export async function resolveIdentityProvidersConfig( tokenUrl: row.token_url, userinfoUrl: row.userinfo_url, scopes: row.scopes, - authorizationParams: normalizeStringParams(row.extra_authorization_params), + authorizationParams: normalizeStringParams( + row.extra_authorization_params + ), pkceEnabled: row.pkce_enabled ?? true, }); } diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 0536662ea0..ae1930996b 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -268,6 +268,15 @@ export interface ConfigSecretsModuleRow { table_id: string; } +export interface ConfigSecretsModuleRow { + table_id: string; +} + +export interface SecretsModuleAvailabilityRow { + has_internal_secrets_module: boolean; + has_config_secrets_module: boolean; +} + export interface SchemaAndTableRow { schema_name: string; table_name: string; @@ -340,7 +349,7 @@ export interface BuiltinModuleMap { export type WithPgClient = ( fn: (client: PoolClient) => Promise, /** Per-call settings merged over the request pgSettings before SET LOCAL. */ - pgSettingsOverrides?: Record, + pgSettingsOverrides?: Record ) => Promise; /** @@ -384,7 +393,9 @@ export interface ConstructiveContext { * - The module isn't provisioned for this database */ useModule: { - (name: K): Promise; + ( + name: K + ): Promise; (name: string): Promise; }; From 8df7d66a11a0070e47f0af436d45561cae032f5e Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 27 Jul 2026 08:41:39 +0800 Subject: [PATCH 27/32] fix(oauth): resolve auth settings by table id --- .../loaders/__tests__/auth-settings.test.ts | 76 +++++++++++++++++ .../src/loaders/auth-settings.ts | 84 +++++++++---------- 2 files changed, 118 insertions(+), 42 deletions(-) create mode 100644 packages/express-context/src/loaders/__tests__/auth-settings.test.ts diff --git a/packages/express-context/src/loaders/__tests__/auth-settings.test.ts b/packages/express-context/src/loaders/__tests__/auth-settings.test.ts new file mode 100644 index 0000000000..45527e8941 --- /dev/null +++ b/packages/express-context/src/loaders/__tests__/auth-settings.test.ts @@ -0,0 +1,76 @@ +import type { Pool } from 'pg'; + +import { authSettingsLoader } from '../auth-settings'; +import type { LoaderContext } from '../types'; + +function createContext(query: jest.Mock): LoaderContext { + const tenantPool = { query } as unknown as Pool; + + return { + servicesPool: { query: jest.fn() } as unknown as Pool, + tenantPool, + databaseId: 'hub-database-id', + dbname: 'constructive', + }; +} + +describe('authSettingsLoader', () => { + afterEach(() => { + authSettingsLoader.invalidate(); + }); + + it('discovers auth settings through the stable table id contract', async () => { + const query = jest + .fn() + .mockResolvedValueOnce({ + rows: [ + { + schema_name: 'constructive_auth_private', + table_name: 'app_settings_auth', + }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { + allow_identity_sign_in: true, + allow_identity_sign_up: true, + cookie_secure: false, + cookie_samesite: 'lax', + cookie_domain: null, + cookie_httponly: true, + cookie_max_age: { days: 30 }, + cookie_path: '/', + remember_me_duration: { days: 30 }, + enable_captcha: false, + captcha_site_key: null, + oauth_state_max_age: { minutes: 10 }, + oauth_require_verified_email: true, + oauth_error_redirect_path: '/auth/error', + }, + ], + }); + + const config = await authSettingsLoader.resolve(createContext(query)); + + expect(query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('sm.auth_settings_table_id'), + ['hub-database-id'] + ); + expect(query.mock.calls[0][0]).toContain('metaschema.schema_and_table'); + expect(query.mock.calls[0][0]).not.toContain('sm.auth_settings_table_name'); + expect(query.mock.calls[0][0]).not.toContain('sm.auth_settings_table AS'); + expect(query.mock.calls[1][0]).toContain( + 'constructive_auth_private.app_settings_auth' + ); + expect(config).toMatchObject({ + allowIdentitySignIn: true, + allowIdentitySignUp: true, + cookieSecure: false, + cookieSamesite: 'lax', + oauthRequireVerifiedEmail: true, + oauthErrorRedirectPath: '/auth/error', + }); + }); +}); diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index def42f1c53..0169c179dd 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -13,19 +13,18 @@ import { QuoteUtils } from '@pgsql/quotes'; import type { Pool } from 'pg'; -import type { - AuthSettings, - AuthSettingsRow, -} from '../types'; +import type { AuthSettings, AuthSettingsRow } from '../types'; import { createModuleLoader } from './create-loader'; import type { LoaderContext, ModuleLoader } from './types'; // ─── SQL ──────────────────────────────────────────────────────────────────── const AUTH_SETTINGS_DISCOVERY_SQL = ` - SELECT s.schema_name, sm.auth_settings_table_name AS table_name + SELECT resolved.schema_name, resolved.table_name FROM metaschema_modules_public.sessions_module sm - JOIN metaschema_public.schema s ON s.id = sm.schema_id + CROSS JOIN LATERAL metaschema.schema_and_table( + sm.auth_settings_table_id + ) resolved WHERE sm.database_id = $1 LIMIT 1 `; @@ -37,14 +36,14 @@ interface AuthSettingsTableRef { async function discoverAuthSettingsTable( pool: Pool, - databaseId: string | null | undefined, + databaseId: string | null | undefined ): Promise { if (!databaseId) return null; - const discovery = await pool.query<{ schema_name: string; table_name: string }>( - AUTH_SETTINGS_DISCOVERY_SQL, - [databaseId], - ); + const discovery = await pool.query<{ + schema_name: string; + table_name: string; + }>(AUTH_SETTINGS_DISCOVERY_SQL, [databaseId]); const resolved = discovery.rows[0]; if (!resolved) return null; @@ -57,7 +56,7 @@ async function discoverAuthSettingsTable( function buildAuthSettingsQuery(schemaName: string, tableName: string): string { const authSettingsTable = QuoteUtils.quoteQualifiedIdentifier( schemaName, - tableName, + tableName ); return ` @@ -83,36 +82,37 @@ function buildAuthSettingsQuery(schemaName: string, tableName: string): string { // ─── Loader ───────────────────────────────────────────────────────────────── -export const authSettingsLoader: ModuleLoader = createModuleLoader({ - name: 'authSettings', - ttlMs: 5 * 60_000, - async resolve(ctx: LoaderContext) { - const { tenantPool, databaseId } = ctx; +export const authSettingsLoader: ModuleLoader = + createModuleLoader({ + name: 'authSettings', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; - const resolved = await discoverAuthSettingsTable(tenantPool, databaseId); - if (!resolved) return undefined; + const resolved = await discoverAuthSettingsTable(tenantPool, databaseId); + if (!resolved) return undefined; - const result = await tenantPool.query( - buildAuthSettingsQuery(resolved.schemaName, resolved.tableName), - ); - const row = result.rows[0]; - if (!row) return undefined; + const result = await tenantPool.query( + buildAuthSettingsQuery(resolved.schemaName, resolved.tableName) + ); + const row = result.rows[0]; + if (!row) return undefined; - return { - allowIdentitySignIn: row.allow_identity_sign_in, - allowIdentitySignUp: row.allow_identity_sign_up, - cookieSecure: row.cookie_secure, - cookieSamesite: row.cookie_samesite, - cookieDomain: row.cookie_domain, - cookieHttponly: row.cookie_httponly, - cookieMaxAge: row.cookie_max_age, - cookiePath: row.cookie_path, - 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 - }; - } -}); + return { + allowIdentitySignIn: row.allow_identity_sign_in, + allowIdentitySignUp: row.allow_identity_sign_up, + cookieSecure: row.cookie_secure, + cookieSamesite: row.cookie_samesite, + cookieDomain: row.cookie_domain, + cookieHttponly: row.cookie_httponly, + cookieMaxAge: row.cookie_max_age, + cookiePath: row.cookie_path, + 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, + }; + }, + }); From 0f0f2988c4a97bec9ed20322b49d7ac1131f5ae4 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 27 Jul 2026 09:05:25 +0800 Subject: [PATCH 28/32] fix(context): support legacy compute metadata --- .../src/loaders/__tests__/compute.test.ts | 77 +++++++++++++++++++ .../express-context/src/loaders/compute.ts | 76 ++++++++++-------- 2 files changed, 123 insertions(+), 30 deletions(-) create mode 100644 packages/express-context/src/loaders/__tests__/compute.test.ts diff --git a/packages/express-context/src/loaders/__tests__/compute.test.ts b/packages/express-context/src/loaders/__tests__/compute.test.ts new file mode 100644 index 0000000000..93b63d3f39 --- /dev/null +++ b/packages/express-context/src/loaders/__tests__/compute.test.ts @@ -0,0 +1,77 @@ +import type { Pool } from 'pg'; + +import { computeLoader } from '../compute'; +import type { LoaderContext } from '../types'; + +function createContext(query: jest.Mock): LoaderContext { + const tenantPool = { query } as unknown as Pool; + + return { + servicesPool: { query: jest.fn() } as unknown as Pool, + tenantPool, + databaseId: 'hub-database-id', + dbname: 'constructive', + }; +} + +describe('computeLoader', () => { + afterEach(() => { + computeLoader.invalidate(); + }); + + it('supports compute metadata before bindings and entity names were stored on module rows', async () => { + const query = jest.fn().mockResolvedValue({ + rows: [ + { + functions_schema_name: 'constructive_compute_public', + definitions_table_name: 'function_definitions', + bindings_table_name: 'function_api_bindings', + invocations_schema_name: 'constructive_compute_public', + invocations_table_name: 'function_invocations', + invocations_entity_field: null, + }, + ], + }); + + const config = await computeLoader.resolve(createContext(query)); + const sql = query.mock.calls[0][0] as string; + + expect(sql).toContain("to_jsonb(fm) ->> 'bindings_table_name'"); + expect(sql).toContain('legacy_bindings.name'); + expect(sql).toContain("to_jsonb(ivm) ->> 'entity_field'"); + expect(sql).not.toContain('fm.bindings_table_name'); + expect(sql).not.toContain('ivm.entity_field'); + expect(query).toHaveBeenCalledWith(sql, ['hub-database-id']); + expect(config).toEqual({ + modules: [ + { + schemaName: 'constructive_compute_public', + definitionsTableName: 'function_definitions', + bindingsTableName: 'function_api_bindings', + invocationsSchemaName: 'constructive_compute_public', + invocationsTableName: 'function_invocations', + invocationsEntityField: null, + }, + ], + }); + }); + + it('fails clearly when neither current nor legacy metadata resolves a bindings table', async () => { + const query = jest.fn().mockResolvedValue({ + rows: [ + { + functions_schema_name: 'compute_public', + definitions_table_name: 'function_definitions', + bindings_table_name: null, + invocations_schema_name: 'compute_public', + invocations_table_name: 'function_invocations', + invocations_entity_field: null, + }, + ], + }); + + await expect(computeLoader.resolve(createContext(query))).rejects.toThrow( + 'function bindings table missing for schema compute_public' + ); + }); +}); diff --git a/packages/express-context/src/loaders/compute.ts b/packages/express-context/src/loaders/compute.ts index ec11b7aec8..8c5c2fcaa2 100644 --- a/packages/express-context/src/loaders/compute.ts +++ b/packages/express-context/src/loaders/compute.ts @@ -22,12 +22,22 @@ const COMPUTE_MODULE_SQL = ` SELECT fs.schema_name AS functions_schema_name, fm.definitions_table_name, - fm.bindings_table_name, + COALESCE( + to_jsonb(fm) ->> 'bindings_table_name', + legacy_bindings.name + ) AS bindings_table_name, ivs.schema_name AS invocations_schema_name, ivm.invocations_table_name, - ivm.entity_field AS invocations_entity_field + to_jsonb(ivm) ->> 'entity_field' AS invocations_entity_field FROM metaschema_modules_public.function_module fm JOIN metaschema_public.schema fs ON fs.id = fm.schema_id + LEFT JOIN metaschema_public.table legacy_bindings + ON legacy_bindings.schema_id = fm.schema_id + AND legacy_bindings.name = regexp_replace( + fm.definitions_table_name, + '_definitions$', + '_api_bindings' + ) JOIN metaschema_modules_public.function_invocation_module ivm ON ivm.database_id = fm.database_id AND ivm.scope = fm.scope JOIN metaschema_public.schema ivs ON ivs.id = ivm.schema_id @@ -40,7 +50,7 @@ const COMPUTE_MODULE_SQL = ` interface ComputeModuleRow { functions_schema_name: string; definitions_table_name: string; - bindings_table_name: string; + bindings_table_name: string | null; invocations_schema_name: string; invocations_table_name: string; invocations_entity_field: string | null; @@ -48,32 +58,38 @@ interface ComputeModuleRow { // ─── Loader ───────────────────────────────────────────────────────────────── -export const computeLoader: ModuleLoader = createModuleLoader({ - name: 'compute', - ttlMs: 60_000, - async resolve(ctx: LoaderContext) { - const { tenantPool, databaseId } = ctx; +export const computeLoader: ModuleLoader = + createModuleLoader({ + name: 'compute', + ttlMs: 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; - const result = await tenantPool.query( - COMPUTE_MODULE_SQL, - [databaseId], - ); - if (result.rows.length === 0) return undefined; + const result = await tenantPool.query( + COMPUTE_MODULE_SQL, + [databaseId] + ); + if (result.rows.length === 0) return undefined; - return { - modules: result.rows.map((row) => ({ - schemaName: row.functions_schema_name, - definitionsTableName: row.definitions_table_name, - // Physical bindings table name, recorded by the metaschema generator - // on the function_module config row — read as a fact, never derived - // from the definitions table name. - bindingsTableName: row.bindings_table_name, - invocationsSchemaName: row.invocations_schema_name, - invocationsTableName: row.invocations_table_name, - // Scope-key column of the invocations table (entity_field), or NULL - // for global scopes. Consumers set this column on inserts. - invocationsEntityField: row.invocations_entity_field, - })), - }; - }, -}); + return { + modules: result.rows.map((row) => { + if (!row.bindings_table_name) { + throw new Error( + `function bindings table missing for schema ${row.functions_schema_name}` + ); + } + + return { + schemaName: row.functions_schema_name, + definitionsTableName: row.definitions_table_name, + bindingsTableName: row.bindings_table_name, + invocationsSchemaName: row.invocations_schema_name, + invocationsTableName: row.invocations_table_name, + // Scope-key column of the invocations table (entity_field), or NULL + // for global scopes and DB revisions that predate this metadata. + invocationsEntityField: row.invocations_entity_field, + }; + }), + }; + }, + }); From 1a0a23519cbd971524f35c03ccf03edc19ea3fa0 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 27 Jul 2026 09:19:51 +0800 Subject: [PATCH 29/32] fix(graphile): support legacy function metadata --- .../__tests__/metadata-compatibility.test.ts | 23 +++++++++ .../graphile-function-bindings/src/plugin.ts | 50 +++++++++++-------- 2 files changed, 53 insertions(+), 20 deletions(-) create mode 100644 graphile/graphile-function-bindings/src/__tests__/metadata-compatibility.test.ts diff --git a/graphile/graphile-function-bindings/src/__tests__/metadata-compatibility.test.ts b/graphile/graphile-function-bindings/src/__tests__/metadata-compatibility.test.ts new file mode 100644 index 0000000000..a42c34cbc9 --- /dev/null +++ b/graphile/graphile-function-bindings/src/__tests__/metadata-compatibility.test.ts @@ -0,0 +1,23 @@ +import { buildBindingsQuery } from '../plugin'; + +describe('function bindings metadata compatibility', () => { + it('reads payload metadata without requiring the newer payload_args column', () => { + const { text, values } = buildBindingsQuery( + { + computeSchema: 'compute"public', + bindingsTable: 'function_api_bindings', + definitionsTable: 'function_definitions', + invocationsSchema: 'compute_public', + invocationsTable: 'function_invocations', + invocationsEntityField: null + }, + 'api-id' + ); + + expect(text).toContain("to_jsonb(d) -> 'payload_args'"); + expect(text).toContain("to_jsonb(d) -> 'inputs'"); + expect(text).not.toContain('d.payload_args'); + expect(text).toContain('FROM "compute""public"."function_api_bindings" b'); + expect(values).toEqual(['api-id']); + }); +}); diff --git a/graphile/graphile-function-bindings/src/plugin.ts b/graphile/graphile-function-bindings/src/plugin.ts index 65653698eb..6a431b6522 100644 --- a/graphile/graphile-function-bindings/src/plugin.ts +++ b/graphile/graphile-function-bindings/src/plugin.ts @@ -37,6 +37,7 @@ import { access, context as grafastContext, lambda, object } from 'grafast'; import type { GraphileConfig } from 'graphile-config'; import { isValidNameError } from 'graphql'; import { toCamelCase, toConstantCase, toPascalCase } from 'inflekt'; +import { escapeIdentifier } from 'pg'; import type { DerivedField, DerivedInput } from './derive'; import { buildInvocationPayload, deriveInputFields, isGraphqlEnabled } from './derive'; @@ -65,6 +66,34 @@ interface FunctionBindingsBuildInput { bindings: LoadedBinding[]; } +export function buildBindingsQuery(module: ComputeModuleNames, apiId: string): { text: string; values: SqlValue[] } { + const definitionsTable = `${escapeIdentifier(module.computeSchema)}.${escapeIdentifier(module.definitionsTable)}`; + const bindingsTable = `${escapeIdentifier(module.computeSchema)}.${escapeIdentifier(module.bindingsTable)}`; + + return { + text: ` + SELECT + b.id, + b.alias, + b.config, + b.function_definition_id, + d.task_identifier, + d.description, + COALESCE( + to_jsonb(d) -> 'payload_args', + to_jsonb(d) -> 'inputs', + '[]'::jsonb + ) AS payload_args + FROM ${bindingsTable} b + JOIN ${definitionsTable} d + ON d.id = b.function_definition_id + WHERE b.api_id = $1 + ORDER BY b.alias ASC + `, + values: [apiId] + }; +} + async function loadBindings( pgService: GraphileConfig.PgServiceConfiguration, options: FunctionBindingsPluginOptions @@ -72,26 +101,7 @@ async function loadBindings( return withPgClientFromPgService(pgService, null, async (client) => { const bindings: LoadedBinding[] = []; for (const module of options.modules) { - const { computeSchema, bindingsTable, definitionsTable } = module; - const { text, values } = new QueryBuilder() - .schema(computeSchema) - .table(bindingsTable, 'b') - .select([ - 'b.id', - 'b.alias', - 'b.config', - 'b.function_definition_id', - 'd.task_identifier', - 'd.description', - 'd.payload_args' - ]) - .innerJoin(definitionsTable, 'b.function_definition_id', '=', 'd.id', { - schema: computeSchema, - alias: 'd' - }) - .where({ 'b.api_id': { equalTo: options.apiId } }) - .orderBy('b.alias', 'ASC') - .build(); + const { text, values } = buildBindingsQuery(module, options.apiId); const { rows } = await client.query<{ id: string; alias: string; From dd91f4f16b83a1b91a5bcfb0b91b9865018d7e9c Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 27 Jul 2026 09:58:33 +0800 Subject: [PATCH 30/32] fix(ci): sync OAuth package lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c027015d30..82e707d8e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1986,6 +1986,9 @@ importers: '@pgpmjs/types': specifier: workspace:^ version: link:../../pgpm/types/dist + '@pgsql/quotes': + specifier: ^17.1.0 + version: 17.1.0 agentic-server: specifier: workspace:* version: link:../../agentic/agentic-server/dist From 6edb9a5696db452be8a2de50a7d2b6da437515d1 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 28 Jul 2026 22:51:31 +0800 Subject: [PATCH 31/32] fix(oauth): align runtime with scoped routing --- __fixtures__/seed/scoped/test-data.sql | 25 + .../oauth/oauth-implementation-followup.md | 8 - docs/plan/oauth/oauth-local-e2e.md | 42 +- .../__tests__/metadata-compatibility.test.ts | 23 - .../graphile-function-bindings/src/plugin.ts | 50 +- .../oauth-scoped-routing.integration.test.ts | 103 +++ graphql/server-test/sql/oauth-scoped.sql | 165 +++++ graphql/server/package.json | 1 + .../src/middleware/__tests__/cookie.test.ts | 112 ++-- .../src/middleware/__tests__/oauth.test.ts | 280 ++++++-- graphql/server/src/middleware/cookie.ts | 59 +- graphql/server/src/middleware/oauth.ts | 616 +++++++++--------- .../src/utils/__tests__/pg-interval.test.ts | 27 + graphql/server/src/utils/pg-interval.ts | 44 +- .../express-context/__tests__/context.test.ts | 114 ++++ .../loaders/__tests__/auth-settings.test.ts | 51 +- .../src/loaders/__tests__/compute.test.ts | 77 --- .../__tests__/identity-providers.test.ts | 294 +++------ .../src/loaders/auth-settings.ts | 16 +- .../express-context/src/loaders/compute.ts | 76 +-- .../src/loaders/identity-providers.ts | 169 +---- packages/express-context/src/types.ts | 17 +- pnpm-lock.yaml | 11 + 23 files changed, 1372 insertions(+), 1008 deletions(-) delete mode 100644 graphile/graphile-function-bindings/src/__tests__/metadata-compatibility.test.ts create mode 100644 graphql/server-test/__tests__/oauth-scoped-routing.integration.test.ts create mode 100644 graphql/server-test/sql/oauth-scoped.sql create mode 100644 graphql/server/src/utils/__tests__/pg-interval.test.ts create mode 100644 packages/express-context/__tests__/context.test.ts delete mode 100644 packages/express-context/src/loaders/__tests__/compute.test.ts diff --git a/__fixtures__/seed/scoped/test-data.sql b/__fixtures__/seed/scoped/test-data.sql index dce0a856b4..3734a375f2 100644 --- a/__fixtures__/seed/scoped/test-data.sql +++ b/__fixtures__/seed/scoped/test-data.sql @@ -20,6 +20,31 @@ SET session_replication_role TO replica; +-- Current constructive-db module metadata resolves physical tables by stable +-- table id. The lightweight CNC fixture installs the metadata tables without +-- the full metaschema runtime package, so provide the same lookup contract for +-- scoped server integration tests. +CREATE SCHEMA IF NOT EXISTS metaschema; + +DO $block$ +BEGIN + IF to_regprocedure('metaschema.schema_and_table(uuid)') IS NULL THEN + EXECUTE $function$ + CREATE FUNCTION metaschema.schema_and_table(target_table_id uuid) + RETURNS TABLE(schema_name text, table_name text) + LANGUAGE sql + STABLE + AS $$ + SELECT s.schema_name, t.name + FROM metaschema_public.table t + JOIN metaschema_public.schema s ON s.id = t.schema_id + WHERE t.id = target_table_id + $$ + $function$; + END IF; +END +$block$; + -- ===================================================== -- METASCHEMA DATA -- ===================================================== diff --git a/docs/plan/oauth/oauth-implementation-followup.md b/docs/plan/oauth/oauth-implementation-followup.md index 62b873c6b7..d95bf4019b 100644 --- a/docs/plan/oauth/oauth-implementation-followup.md +++ b/docs/plan/oauth/oauth-implementation-followup.md @@ -1,17 +1,9 @@ # OAuth Implementation Follow-up -## Identity Providers Module Scope - -- Investigate the platform `identity_providers_module` record whose `scope` is currently `app`. For platform-level OAuth configuration, the expected scope appears to be `platform`; keeping it as `app` may be a provisioning or migration artifact and can make platform OAuth semantics ambiguous. - ## Identity Auth Function Metadata - Consider making `sign_in_identity_function` and `sign_up_identity_function` metadata-driven. These functions are generated by the user auth module, but the current express-context config uses hardcoded function names. A future database/schema update could expose these function names through module metadata, similar to other auth functions. -## Auth Settings Interval Parsing - -- Fix cookie/session duration parsing after the OAuth loader work lands. `app_settings_auth.cookie_max_age`, `remember_me_duration`, and `oauth_state_max_age` are PostgreSQL `interval` columns; `pg` returns them as `PostgresInterval` objects such as `{ days: 14 }` or `{ minutes: 10 }`. The current GraphQL server cookie helper still parses these values as second strings with `parseInt`, so loader-backed auth settings can silently fall back to defaults. This is an existing cookie auth settings compatibility issue exposed by the OAuth/auth settings loader path and should be handled in a follow-up PR. - ## Loader Cache Invalidation and TTL Semantics - Revisit `createModuleLoader` cache expiration and invalidation semantics. The current implementation uses `updateAgeOnGet: true`, so each cache hit refreshes the TTL and frequently accessed config may not expire while traffic continues. The reference branch changed the default to `false`, but the desired behavior should be decided together with explicit invalidation for writes performed by our own systems. diff --git a/docs/plan/oauth/oauth-local-e2e.md b/docs/plan/oauth/oauth-local-e2e.md index c9629a6380..c718d8cc1b 100644 --- a/docs/plan/oauth/oauth-local-e2e.md +++ b/docs/plan/oauth/oauth-local-e2e.md @@ -18,7 +18,7 @@ git status --short --branch Prepare two groups of environment variables: -- GraphQL server runtime: `OAUTH_STATE_SECRET`. The server reads this at startup to sign and verify `oauth_state` / `oauth_pkce` cookies. +- GraphQL server runtime: `OAUTH_STATE_SECRET`. The server requires this only when a configured OAuth flow starts, then uses it to sign and verify `oauth_state` / `oauth_pkce` cookies. - Provider initialization SQL: `GITHUB_OAUTH_*` and `GOOGLE_OAUTH_*`. These are passed into the provider setup `psql` scripts below, then written or rotated into the database. The GraphQL server does not read these provider credential env vars directly. ```bash @@ -61,7 +61,7 @@ pgpm deploy --yes --database constructive --package constructive-local ## 4. Apply Local OAuth Test Setup -`PGPASSWORD` in the setup commands is only the local PostgreSQL connection password for `psql`. +`PGPASSWORD` in the setup commands is only the local PostgreSQL connection password for `psql`. The OAuth routes use the same scoped routing plane as every other production route; do not seed `services_public` compatibility tables or rely on a default database. ```bash PGPASSWORD=password psql -h localhost -U postgres -d constructive -v ON_ERROR_STOP=1 <<'SQL' @@ -71,29 +71,23 @@ SET allow_identity_sign_in = true, allow_identity_sign_up = true, oauth_require_verified_email = false, cookie_secure = false; - --- rotate_identity_provider_platform_secret currently expects this namespace. -INSERT INTO constructive_infra_public.platform_namespaces (database_id, name, status) -SELECT database_id, 'default', 'ready' -FROM services_public.apis -WHERE name = 'auth' -LIMIT 1 -ON CONFLICT (database_id, name) DO NOTHING; - --- Google local callback alias: localhost -> auth API. -DELETE FROM services_public.domains -WHERE domain = 'localhost' - AND subdomain IS NULL; - -INSERT INTO services_public.domains (database_id, api_id, domain, subdomain, annotations) -SELECT database_id, id, 'localhost', NULL, - jsonb_build_object('purpose', 'local-google-oauth-callback') -FROM services_public.apis -WHERE name = 'auth' -LIMIT 1; SQL ``` +Before starting the server, ensure the callback hostname is a real scoped +routing binding for the intended API/database. Verify it through the current +routing contract: + +```sql +SELECT * +FROM routing_public.resolve_route('auth.localhost', '/', NULL); +``` + +If Google must use bare `localhost`, create that hostname through the current +routing/domain provisioning workflow and verify it with +`routing_public.resolve_route('localhost', '/', NULL)`. Do not insert a legacy +domain row directly. + ## 5. Build and Start the Server ```bash @@ -127,7 +121,7 @@ Keep `NODE_USE_ENV_PROXY=1` when the local network needs `HTTP_PROXY` / `HTTPS_P ## 6. Configure Providers -These commands use the provider initialization env vars from step 2. `psql` substitutes them into the setup script, and `rotate_identity_provider_platform_secret(...)` stores the provider client secret in the database. The GraphQL server later reads provider credentials through loaders and database metadata, not from `GITHUB_OAUTH_*` or `GOOGLE_OAUTH_*`. +These commands use the provider initialization env vars from step 2. `psql` substitutes them into the setup script, and `rotate_identity_provider_platform_secret(...)` stores the provider client secret in the current database's `internal_secrets_module` table for the matching scope. The GraphQL server later resolves both provider and secret metadata by the routed `database_id`; it does not infer a platform database from `dbname` or read `GITHUB_OAUTH_*` / `GOOGLE_OAUTH_*`. GitHub: @@ -203,7 +197,7 @@ curl --noproxy '*' http://localhost:3000/auth/providers Expected: ```json -{"providers":["github","google"]} +{ "providers": ["github", "google"] } ``` Authorization redirects: diff --git a/graphile/graphile-function-bindings/src/__tests__/metadata-compatibility.test.ts b/graphile/graphile-function-bindings/src/__tests__/metadata-compatibility.test.ts deleted file mode 100644 index a42c34cbc9..0000000000 --- a/graphile/graphile-function-bindings/src/__tests__/metadata-compatibility.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { buildBindingsQuery } from '../plugin'; - -describe('function bindings metadata compatibility', () => { - it('reads payload metadata without requiring the newer payload_args column', () => { - const { text, values } = buildBindingsQuery( - { - computeSchema: 'compute"public', - bindingsTable: 'function_api_bindings', - definitionsTable: 'function_definitions', - invocationsSchema: 'compute_public', - invocationsTable: 'function_invocations', - invocationsEntityField: null - }, - 'api-id' - ); - - expect(text).toContain("to_jsonb(d) -> 'payload_args'"); - expect(text).toContain("to_jsonb(d) -> 'inputs'"); - expect(text).not.toContain('d.payload_args'); - expect(text).toContain('FROM "compute""public"."function_api_bindings" b'); - expect(values).toEqual(['api-id']); - }); -}); diff --git a/graphile/graphile-function-bindings/src/plugin.ts b/graphile/graphile-function-bindings/src/plugin.ts index 6a431b6522..65653698eb 100644 --- a/graphile/graphile-function-bindings/src/plugin.ts +++ b/graphile/graphile-function-bindings/src/plugin.ts @@ -37,7 +37,6 @@ import { access, context as grafastContext, lambda, object } from 'grafast'; import type { GraphileConfig } from 'graphile-config'; import { isValidNameError } from 'graphql'; import { toCamelCase, toConstantCase, toPascalCase } from 'inflekt'; -import { escapeIdentifier } from 'pg'; import type { DerivedField, DerivedInput } from './derive'; import { buildInvocationPayload, deriveInputFields, isGraphqlEnabled } from './derive'; @@ -66,34 +65,6 @@ interface FunctionBindingsBuildInput { bindings: LoadedBinding[]; } -export function buildBindingsQuery(module: ComputeModuleNames, apiId: string): { text: string; values: SqlValue[] } { - const definitionsTable = `${escapeIdentifier(module.computeSchema)}.${escapeIdentifier(module.definitionsTable)}`; - const bindingsTable = `${escapeIdentifier(module.computeSchema)}.${escapeIdentifier(module.bindingsTable)}`; - - return { - text: ` - SELECT - b.id, - b.alias, - b.config, - b.function_definition_id, - d.task_identifier, - d.description, - COALESCE( - to_jsonb(d) -> 'payload_args', - to_jsonb(d) -> 'inputs', - '[]'::jsonb - ) AS payload_args - FROM ${bindingsTable} b - JOIN ${definitionsTable} d - ON d.id = b.function_definition_id - WHERE b.api_id = $1 - ORDER BY b.alias ASC - `, - values: [apiId] - }; -} - async function loadBindings( pgService: GraphileConfig.PgServiceConfiguration, options: FunctionBindingsPluginOptions @@ -101,7 +72,26 @@ async function loadBindings( return withPgClientFromPgService(pgService, null, async (client) => { const bindings: LoadedBinding[] = []; for (const module of options.modules) { - const { text, values } = buildBindingsQuery(module, options.apiId); + const { computeSchema, bindingsTable, definitionsTable } = module; + const { text, values } = new QueryBuilder() + .schema(computeSchema) + .table(bindingsTable, 'b') + .select([ + 'b.id', + 'b.alias', + 'b.config', + 'b.function_definition_id', + 'd.task_identifier', + 'd.description', + 'd.payload_args' + ]) + .innerJoin(definitionsTable, 'b.function_definition_id', '=', 'd.id', { + schema: computeSchema, + alias: 'd' + }) + .where({ 'b.api_id': { equalTo: options.apiId } }) + .orderBy('b.alias', 'ASC') + .build(); const { rows } = await client.query<{ id: string; alias: string; diff --git a/graphql/server-test/__tests__/oauth-scoped-routing.integration.test.ts b/graphql/server-test/__tests__/oauth-scoped-routing.integration.test.ts new file mode 100644 index 0000000000..3f54ef3162 --- /dev/null +++ b/graphql/server-test/__tests__/oauth-scoped-routing.integration.test.ts @@ -0,0 +1,103 @@ +/** + * OAuth route integration on the production scoped-routing server. + * + * The shared fixture deliberately does not provision OAuth modules. These + * assertions prove the route still receives an explicit database/API scope + * from routing_public and that OAUTH_STATE_SECRET is required lazily only + * after a configured provider can start a flow. + */ + +import path from 'path'; +import type supertest from 'supertest'; + +import { getConnections, seed } from '../src'; + +jest.setTimeout(30000); + +const sharedSeedRoot = path.join( + __dirname, + '..', + '..', + '..', + '__fixtures__', + 'seed' +); +const shared = (...segments: string[]) => + path.join(sharedSeedRoot, ...segments); +const pgpmWorkspace = path.join(sharedSeedRoot, '..', '..'); +const schemas = ['simple-pets-public', 'simple-pets-pets-public']; +const metaSchemas = [ + 'catalog_public', + 'routing_public', + 'apps_public', + 'metaschema_public', + 'metaschema_modules_public' +]; +const API_HOST = 'app.test.constructive.io'; + +const seedAdapters = [ + seed.pgpm(pgpmWorkspace), + seed.sqlfile([ + shared('app-schemas', 'simple-pets', 'schema.sql'), + shared('scoped', 'test-data.sql'), + shared('app-schemas', 'simple-pets', 'test-data.sql'), + path.join(__dirname, '..', 'sql', 'oauth-scoped.sql') + ]) +]; + +let request: supertest.Agent; +let teardown: (() => Promise) | undefined; + +beforeAll(async () => { + ({ request, teardown } = await getConnections( + { + schemas, + authRole: 'anonymous', + server: { + useRouting: true, + api: { + isPublic: true, + metaSchemas + } + } + }, + seedAdapters + )); +}); + +afterAll(async () => { + await teardown?.(); +}); + +describe('OAuth routes over scoped routing', () => { + it('resolves the API host and returns the database-scoped provider list', async () => { + const response = await request.get('/auth/providers').set('Host', API_HOST); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ providers: ['github'] }); + }); + + it('requires the state secret lazily only when a configured flow starts', async () => { + const response = await request.get('/auth/github').set('Host', API_HOST); + + expect(response.status).toBe(302); + const redirect = new URL(response.headers.location); + expect(redirect.searchParams.get('error')).toBe('OAUTH_INIT_FAILED'); + const setCookieHeader = response.headers['set-cookie']; + const setCookies: string[] = Array.isArray(setCookieHeader) + ? setCookieHeader + : setCookieHeader + ? [setCookieHeader] + : []; + expect(setCookies.join('\n')).not.toContain('oauth_state='); + expect(setCookies.join('\n')).not.toContain('oauth_pkce='); + }); + + it('does not fall back to a default database for an unknown host', async () => { + const response = await request + .get('/auth/providers') + .set('Host', 'unknown.test.constructive.io'); + + expect(response.status).toBe(404); + }); +}); diff --git a/graphql/server-test/sql/oauth-scoped.sql b/graphql/server-test/sql/oauth-scoped.sql new file mode 100644 index 0000000000..27668d4c29 --- /dev/null +++ b/graphql/server-test/sql/oauth-scoped.sql @@ -0,0 +1,165 @@ +-- Minimal current-contract OAuth metadata for scoped server integration tests. +-- +-- This deliberately models the constructive-db per-database ownership +-- contract: identity provider metadata and its matching internal secrets +-- module share the routed database_id and scope. It does not recreate the +-- removed services_public/default-database compatibility plane. + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE "simple-pets-private".oauth_internal_secrets ( + id uuid PRIMARY KEY, + value bytea NOT NULL, + algo text NOT NULL, + key_id uuid +); + +CREATE TABLE "simple-pets-private".oauth_identity_providers ( + slug text PRIMARY KEY, + kind text NOT NULL, + display_name text NOT NULL, + enabled boolean NOT NULL, + client_id text, + client_secret_id uuid, + authorization_url text, + token_url text, + userinfo_url text, + scopes text[], + extra_authorization_params jsonb, + pkce_enabled boolean +); + +INSERT INTO metaschema_public.table (id, database_id, schema_id, name) +VALUES + ( + '0a000000-0000-4000-8000-000000000001', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '6dba9876-043f-48ee-399d-ddc991ad978d', + 'oauth_internal_secrets' + ), + ( + '0a000000-0000-4000-8000-000000000002', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '6dba9876-043f-48ee-399d-ddc991ad978d', + 'oauth_identity_providers' + ) +ON CONFLICT (id) DO NOTHING; + +SET session_replication_role TO replica; + +INSERT INTO metaschema_modules_public.internal_secrets_module ( + id, + database_id, + schema_id, + private_schema_id, + internal_secrets_table_id, + internal_secrets_table_name, + scope, + prefix +) +VALUES ( + '0a000000-0000-4000-8000-000000000003', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '6dba9876-043f-48ee-399d-ddc991ad978d', + '6dba9876-043f-48ee-399d-ddc991ad978d', + '0a000000-0000-4000-8000-000000000001', + 'oauth_internal_secrets', + 'platform', + 'platform' +) +ON CONFLICT (database_id, scope) DO NOTHING; + +INSERT INTO metaschema_modules_public.identity_providers_module ( + id, + database_id, + schema_id, + private_schema_id, + table_id, + table_name, + scope, + prefix +) +VALUES ( + '0a000000-0000-4000-8000-000000000004', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '6dbae92a-5450-401b-1ed5-d69e7754940d', + '6dba9876-043f-48ee-399d-ddc991ad978d', + '0a000000-0000-4000-8000-000000000002', + 'oauth_identity_providers', + 'platform', + 'platform' +) +ON CONFLICT (database_id, scope) DO NOTHING; + +-- /auth/providers resolves user_auth_module together with provider metadata. +-- It does not execute these auth functions, so the lightweight fixture can +-- reference the provider table metadata for the required foreign keys. +INSERT INTO metaschema_modules_public.user_auth_module ( + id, + database_id, + schema_id, + emails_table_id, + users_table_id, + secrets_table_id, + encrypted_table_id, + sessions_table_id, + session_credentials_table_id, + audits_table_id +) +VALUES ( + '0a000000-0000-4000-8000-000000000005', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '6dbae92a-5450-401b-1ed5-d69e7754940d', + '0a000000-0000-4000-8000-000000000002', + '0a000000-0000-4000-8000-000000000002', + '0a000000-0000-4000-8000-000000000002', + '0a000000-0000-4000-8000-000000000002', + '0a000000-0000-4000-8000-000000000002', + '0a000000-0000-4000-8000-000000000002', + '0a000000-0000-4000-8000-000000000002' +) +ON CONFLICT (id) DO NOTHING; + +SET session_replication_role TO DEFAULT; + +INSERT INTO "simple-pets-private".oauth_internal_secrets ( + id, + value, + algo, + key_id +) +VALUES ( + '0a000000-0000-4000-8000-000000000006', + convert_to('scoped-routing-test-secret', 'UTF8'), + 'raw', + NULL +); + +INSERT INTO "simple-pets-private".oauth_identity_providers ( + slug, + kind, + display_name, + enabled, + client_id, + client_secret_id, + authorization_url, + token_url, + userinfo_url, + scopes, + extra_authorization_params, + pkce_enabled +) +VALUES ( + 'github', + 'oauth2', + 'GitHub', + true, + 'scoped-routing-client', + '0a000000-0000-4000-8000-000000000006', + 'https://github.example.test/authorize', + 'https://github.example.test/token', + 'https://github.example.test/userinfo', + ARRAY['read:user'], + '{"prompt":"select_account"}'::jsonb, + true +); diff --git a/graphql/server/package.json b/graphql/server/package.json index ce7cce2e89..fecdcb71a4 100644 --- a/graphql/server/package.json +++ b/graphql/server/package.json @@ -55,6 +55,7 @@ "@pgpmjs/logger": "workspace:^", "@pgpmjs/server-utils": "workspace:^", "@pgpmjs/types": "workspace:^", + "@pgsql/quotes": "^17.1.0", "agentic-server": "workspace:*", "cors": "^2.8.6", "deepmerge": "^4.3.1", diff --git a/graphql/server/src/middleware/__tests__/cookie.test.ts b/graphql/server/src/middleware/__tests__/cookie.test.ts index 034d268d65..680f71c805 100644 --- a/graphql/server/src/middleware/__tests__/cookie.test.ts +++ b/graphql/server/src/middleware/__tests__/cookie.test.ts @@ -1,18 +1,19 @@ import type { Request, Response } from 'express'; + +import type { AuthSettings } from '../../types'; import { - SESSION_COOKIE_NAME, + clearDeviceTokenCookie, + clearSessionCookie, DEVICE_TOKEN_COOKIE_NAME, - getSessionCookieConfig, getDeviceTokenCookieConfig, - setSessionCookie, - clearSessionCookie, - setDeviceTokenCookie, - clearDeviceTokenCookie, - parseCookieValue, getDeviceTokenFromRequest, + getSessionCookieConfig, getSessionTokenFromRequest, + parseCookieValue, + SESSION_COOKIE_NAME, + setDeviceTokenCookie, + setSessionCookie } from '../cookie'; -import type { AuthSettings } from '../../types'; describe('cookie utilities', () => { describe('getSessionCookieConfig', () => { @@ -24,7 +25,7 @@ describe('cookie utilities', () => { domain: undefined, httpOnly: true, maxAge: 86400, - path: '/', + path: '/' }); }); @@ -35,7 +36,7 @@ describe('cookie utilities', () => { cookieDomain: '.example.com', cookieHttponly: false, cookieMaxAge: '3600', - cookiePath: '/api', + cookiePath: '/api' }; const config = getSessionCookieConfig(authSettings); expect(config).toEqual({ @@ -44,14 +45,14 @@ describe('cookie utilities', () => { domain: '.example.com', httpOnly: false, maxAge: 3600, - path: '/api', + path: '/api' }); }); it('uses rememberMeDuration when rememberMe is true', () => { const authSettings: AuthSettings = { cookieMaxAge: '3600', - rememberMeDuration: '2592000', // 30 days + rememberMeDuration: '2592000' // 30 days }; const config = getSessionCookieConfig(authSettings, true); expect(config.maxAge).toBe(2592000); @@ -60,7 +61,7 @@ describe('cookie utilities', () => { it('uses cookieMaxAge when rememberMe is false', () => { const authSettings: AuthSettings = { cookieMaxAge: '3600', - rememberMeDuration: '2592000', + rememberMeDuration: '2592000' }; const config = getSessionCookieConfig(authSettings, false); expect(config.maxAge).toBe(3600); @@ -68,11 +69,31 @@ describe('cookie utilities', () => { it('falls back to cookieMaxAge when rememberMeDuration is not set', () => { const authSettings: AuthSettings = { - cookieMaxAge: '7200', + cookieMaxAge: '7200' }; const config = getSessionCookieConfig(authSettings, true); expect(config.maxAge).toBe(7200); }); + + it('converts PostgreSQL interval objects to cookie seconds', () => { + const authSettings: AuthSettings = { + cookieMaxAge: { hours: 2, milliseconds: 500 }, + rememberMeDuration: { days: 30 } + }; + + expect(getSessionCookieConfig(authSettings).maxAge).toBe(7200.5); + expect(getSessionCookieConfig(authSettings, true).maxAge).toBe( + 30 * 24 * 60 * 60 + ); + }); + + it('does not misread textual PostgreSQL intervals as raw seconds', () => { + const authSettings: AuthSettings = { + cookieMaxAge: '2 hours' + }; + + expect(getSessionCookieConfig(authSettings).maxAge).toBe(86400); + }); }); describe('getDeviceTokenCookieConfig', () => { @@ -85,7 +106,7 @@ describe('cookie utilities', () => { it('uses authSettings for other cookie options', () => { const authSettings: AuthSettings = { cookieSecure: true, - cookieDomain: '.example.com', + cookieDomain: '.example.com' }; const config = getDeviceTokenCookieConfig(authSettings); expect(config.secure).toBe(true); @@ -96,7 +117,7 @@ describe('cookie utilities', () => { describe('setSessionCookie', () => { it('sets cookie with correct options', () => { const mockRes = { - cookie: jest.fn(), + cookie: jest.fn() } as unknown as Response; const config = { @@ -105,7 +126,7 @@ describe('cookie utilities', () => { domain: '.example.com', httpOnly: true, maxAge: 3600, - path: '/', + path: '/' }; setSessionCookie(mockRes, 'test-token', config); @@ -119,7 +140,7 @@ describe('cookie utilities', () => { domain: '.example.com', httpOnly: true, maxAge: 3600000, // converted to milliseconds - path: '/', + path: '/' } ); }); @@ -128,7 +149,7 @@ describe('cookie utilities', () => { describe('clearSessionCookie', () => { it('clears cookie with correct options', () => { const mockRes = { - clearCookie: jest.fn(), + clearCookie: jest.fn() } as unknown as Response; const config = { @@ -137,28 +158,25 @@ describe('cookie utilities', () => { domain: '.example.com', httpOnly: true, maxAge: 3600, - path: '/', + path: '/' }; clearSessionCookie(mockRes, config); - expect(mockRes.clearCookie).toHaveBeenCalledWith( - SESSION_COOKIE_NAME, - { - secure: true, - sameSite: 'lax', - domain: '.example.com', - httpOnly: true, - path: '/', - } - ); + expect(mockRes.clearCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, { + secure: true, + sameSite: 'lax', + domain: '.example.com', + httpOnly: true, + path: '/' + }); }); }); describe('setDeviceTokenCookie', () => { it('sets device token cookie', () => { const mockRes = { - cookie: jest.fn(), + cookie: jest.fn() } as unknown as Response; const config: Parameters[2] = { @@ -166,7 +184,7 @@ describe('cookie utilities', () => { sameSite: 'lax', httpOnly: true, maxAge: 7776000, - path: '/', + path: '/' }; setDeviceTokenCookie(mockRes, 'device-123', config); @@ -175,7 +193,7 @@ describe('cookie utilities', () => { DEVICE_TOKEN_COOKIE_NAME, 'device-123', expect.objectContaining({ - maxAge: 7776000000, + maxAge: 7776000000 }) ); }); @@ -184,7 +202,7 @@ describe('cookie utilities', () => { describe('clearDeviceTokenCookie', () => { it('clears device token cookie', () => { const mockRes = { - clearCookie: jest.fn(), + clearCookie: jest.fn() } as unknown as Response; const config: Parameters[1] = { @@ -192,7 +210,7 @@ describe('cookie utilities', () => { sameSite: 'lax', httpOnly: true, maxAge: 7776000, - path: '/', + path: '/' }; clearDeviceTokenCookie(mockRes, config); @@ -201,7 +219,7 @@ describe('cookie utilities', () => { DEVICE_TOKEN_COOKIE_NAME, expect.objectContaining({ httpOnly: true, - path: '/', + path: '/' }) ); }); @@ -211,8 +229,8 @@ describe('cookie utilities', () => { it('parses cookie value from header', () => { const mockReq = { headers: { - cookie: 'foo=bar; constructive_session=test-token; baz=qux', - }, + cookie: 'foo=bar; constructive_session=test-token; baz=qux' + } } as unknown as Request; const value = parseCookieValue(mockReq, 'constructive_session'); @@ -222,8 +240,8 @@ describe('cookie utilities', () => { it('returns undefined when cookie not found', () => { const mockReq = { headers: { - cookie: 'foo=bar', - }, + cookie: 'foo=bar' + } } as unknown as Request; const value = parseCookieValue(mockReq, 'constructive_session'); @@ -232,7 +250,7 @@ describe('cookie utilities', () => { it('returns undefined when no cookie header', () => { const mockReq = { - headers: {}, + headers: {} } as unknown as Request; const value = parseCookieValue(mockReq, 'constructive_session'); @@ -242,8 +260,8 @@ describe('cookie utilities', () => { it('decodes URL-encoded cookie values', () => { const mockReq = { headers: { - cookie: 'token=hello%20world', - }, + cookie: 'token=hello%20world' + } } as unknown as Request; const value = parseCookieValue(mockReq, 'token'); @@ -255,8 +273,8 @@ describe('cookie utilities', () => { it('extracts device token from cookie', () => { const mockReq = { headers: { - cookie: `${DEVICE_TOKEN_COOKIE_NAME}=device-abc123`, - }, + cookie: `${DEVICE_TOKEN_COOKIE_NAME}=device-abc123` + } } as unknown as Request; const token = getDeviceTokenFromRequest(mockReq); @@ -268,8 +286,8 @@ describe('cookie utilities', () => { it('extracts session token from cookie', () => { const mockReq = { headers: { - cookie: `${SESSION_COOKIE_NAME}=session-xyz789`, - }, + cookie: `${SESSION_COOKIE_NAME}=session-xyz789` + } } as unknown as Request; const token = getSessionTokenFromRequest(mockReq); diff --git a/graphql/server/src/middleware/__tests__/oauth.test.ts b/graphql/server/src/middleware/__tests__/oauth.test.ts index 2a48af4e3a..c54ec73f03 100644 --- a/graphql/server/src/middleware/__tests__/oauth.test.ts +++ b/graphql/server/src/middleware/__tests__/oauth.test.ts @@ -1,28 +1,30 @@ -import express from 'express'; -import http from 'http'; -import type { AddressInfo } from 'net'; import { createSignedState, deriveCodeChallenge, - verifySignedState, + verifySignedState } from '@constructive-io/oauth'; +import express from 'express'; +import http from 'http'; +import type { AddressInfo } from 'net'; import { createOAuthRoutes } from '../oauth'; const OAUTH_STATE_SECRET = 'test-oauth-state-secret'; +const DATABASE_ID = '00000000-0000-4000-8000-000000000001'; +const API_ID = '00000000-0000-4000-8000-000000000002'; const originalFetch = global.fetch; const authQueryMock = jest.fn(); jest.mock('@pgpmjs/env', () => ({ - getNodeEnv: jest.fn(() => 'test'), + getNodeEnv: jest.fn(() => 'test') })); jest.mock('@pgpmjs/logger', () => ({ Logger: jest.fn(() => ({ error: jest.fn(), info: jest.fn(), - warn: jest.fn(), - })), + warn: jest.fn() + })) })); interface TestHttpResponse { @@ -34,6 +36,9 @@ interface TestHttpResponse { interface OAuthStatePayload { redirect_uri: string; provider: string; + database_id: string; + api_id: string | null; + origin: string; } interface OAuthPkcePayload { @@ -54,9 +59,9 @@ const providerConfig = { userinfoUrl: 'https://github.example.test/api/v3/user', scopes: ['read:user', 'user:email'], authorizationParams: { - prompt: 'select_account', + prompt: 'select_account' }, - pkceEnabled: true, + pkceEnabled: true }; afterEach(() => { @@ -66,13 +71,19 @@ afterEach(() => { function createConstructiveContext() { return { - withPgClient: jest.fn(async (fn: (client: { query: typeof authQueryMock }) => Promise) => - fn({ query: authQueryMock }), + api: { + apiId: API_ID + }, + databaseId: DATABASE_ID, + withPgClient: jest.fn( + async ( + fn: (client: { query: typeof authQueryMock }) => Promise + ) => fn({ query: authQueryMock }) ), useModule: jest.fn(async (name: string) => { if (name === 'identityProviders') { return { - providers: new Map([[providerConfig.slug, providerConfig]]), + providers: new Map([[providerConfig.slug, providerConfig]]) }; } if (name === 'userAuthModule') { @@ -80,37 +91,44 @@ function createConstructiveContext() { schemaName: 'constructive_auth_public', identityFunctionSchemaName: 'constructive_auth_private', signInIdentityFunction: 'sign_in_identity', - signUpIdentityFunction: 'sign_up_identity', + signUpIdentityFunction: 'sign_up_identity' }; } if (name === 'authSettings') { return { cookieHttponly: true, cookieSecure: false, - cookieSamesite: 'lax', + cookieSamesite: 'lax' }; } if (name === 'connectedAccountsModule') { return undefined; } return undefined; - }), + }) }; } async function withOAuthServer( run: (baseUrl: string) => Promise, + stateSecret: string | null = OAUTH_STATE_SECRET, + contextFactory: () => ReturnType< + typeof createConstructiveContext + > = createConstructiveContext ): Promise { const app = express(); app.use((req, _res, next) => { - (req as any).constructive = createConstructiveContext(); + (req as any).constructive = contextFactory(); next(); }); - app.use('/auth', createOAuthRoutes({ - oauth: { - stateSecret: OAUTH_STATE_SECRET, - }, - } as any)); + app.use( + '/auth', + createOAuthRoutes({ + oauth: { + stateSecret: stateSecret ?? undefined + } + } as any) + ); const server = await new Promise((resolve) => { const listening = app.listen(0, '127.0.0.1', () => resolve(listening)); @@ -128,7 +146,7 @@ async function withOAuthServer( async function request( url: string, - headers: Record = {}, + headers: Record = {} ): Promise { return new Promise((resolve, reject) => { const req = http.request(url, { method: 'GET', headers }, (res) => { @@ -138,7 +156,7 @@ async function request( resolve({ statusCode: res.statusCode ?? 0, headers: res.headers, - body: Buffer.concat(chunks).toString('utf8'), + body: Buffer.concat(chunks).toString('utf8') }); }); }); @@ -162,10 +180,27 @@ function readCookie(setCookies: string[], name: string): string { return decodeURIComponent(value); } +function createStatePayload( + baseUrl: string, + provider = 'github', + overrides: Partial = {} +): OAuthStatePayload { + return { + redirect_uri: '/dashboard', + provider, + database_id: DATABASE_ID, + api_id: API_ID, + origin: baseUrl, + ...overrides + }; +} + describe('OAuth routes', () => { it('binds PKCE verifier to the signed state cookie without exposing it in the redirect URL', async () => { await withOAuthServer(async (baseUrl) => { - const response = await request(`${baseUrl}/auth/github?redirect_uri=%2Fdashboard`); + const response = await request( + `${baseUrl}/auth/github?redirect_uri=%2Fdashboard` + ); expect(response.statusCode).toBe(302); const location = response.headers.location; @@ -184,47 +219,59 @@ describe('OAuth routes', () => { expect(location).not.toContain('code_verifier'); const statePayload = verifySignedState(stateCookie, { - secret: OAUTH_STATE_SECRET, + secret: OAUTH_STATE_SECRET }); expect(statePayload).toMatchObject({ redirect_uri: '/dashboard', provider: 'github', + database_id: DATABASE_ID, + api_id: API_ID, + origin: baseUrl }); const pkcePayload = verifySignedState(pkceCookie, { - secret: OAUTH_STATE_SECRET, + secret: OAUTH_STATE_SECRET }); expect(pkcePayload).toMatchObject({ state: stateCookie, - provider: 'github', + provider: 'github' }); expect(pkcePayload!.code_verifier).toHaveLength(43); expect(redirect.searchParams.get('code_challenge')).toBe( - deriveCodeChallenge(pkcePayload!.code_verifier), + deriveCodeChallenge(pkcePayload!.code_verifier) ); expect( - setCookies.find((value) => value.startsWith('oauth_state=')), + setCookies.find((value) => value.startsWith('oauth_state=')) ).toContain('HttpOnly'); expect( - setCookies.find((value) => value.startsWith('oauth_pkce=')), + setCookies.find((value) => value.startsWith('oauth_state=')) + ).toContain('Path=/auth'); + expect( + setCookies.find((value) => value.startsWith('oauth_state=')) + ).not.toContain('Domain='); + expect( + setCookies.find((value) => value.startsWith('oauth_pkce=')) ).toContain('HttpOnly'); + expect( + setCookies.find((value) => value.startsWith('oauth_pkce=')) + ).not.toContain('Domain='); }); }); it('rejects callback requests when the PKCE verifier is not bound to the returned state', async () => { await withOAuthServer(async (baseUrl) => { const stateCookie = createSignedState( - { redirect_uri: '/dashboard', provider: 'github' }, - { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 }, + createStatePayload(baseUrl), + { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 } ); const pkceCookie = createSignedState( { state: 'different-state', provider: 'github', - code_verifier: 'test-code-verifier', + code_verifier: 'test-code-verifier' }, - { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 }, + { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 } ); const callbackUrl = new URL('/auth/github/callback', baseUrl); callbackUrl.searchParams.set('code', 'callback-code'); @@ -233,8 +280,8 @@ describe('OAuth routes', () => { const response = await request(callbackUrl.toString(), { Cookie: [ `oauth_state=${encodeURIComponent(stateCookie)}`, - `oauth_pkce=${encodeURIComponent(pkceCookie)}`, - ].join('; '), + `oauth_pkce=${encodeURIComponent(pkceCookie)}` + ].join('; ') }); expect(response.statusCode).toBe(302); @@ -250,15 +297,15 @@ describe('OAuth routes', () => { const fetchMock = jest.fn(); global.fetch = fetchMock as unknown as typeof fetch; const stateCookie = createSignedState( - { redirect_uri: '/dashboard', provider: 'github' }, - { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 }, + createStatePayload(baseUrl), + { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 } ); const callbackUrl = new URL('/auth/google/callback', baseUrl); callbackUrl.searchParams.set('code', 'callback-code'); callbackUrl.searchParams.set('state', stateCookie); const response = await request(callbackUrl.toString(), { - Cookie: `oauth_state=${encodeURIComponent(stateCookie)}`, + Cookie: `oauth_state=${encodeURIComponent(stateCookie)}` }); expect(response.statusCode).toBe(302); @@ -271,20 +318,139 @@ describe('OAuth routes', () => { }); }); + it('rejects callback state bound to another database', async () => { + await withOAuthServer(async (baseUrl) => { + const fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + const stateCookie = createSignedState( + createStatePayload(baseUrl, 'github', { + database_id: '00000000-0000-4000-8000-000000000099' + }), + { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 } + ); + const callbackUrl = new URL('/auth/github/callback', baseUrl); + callbackUrl.searchParams.set('code', 'callback-code'); + callbackUrl.searchParams.set('state', stateCookie); + + const response = await request(callbackUrl.toString(), { + Cookie: `oauth_state=${encodeURIComponent(stateCookie)}` + }); + + expect(response.statusCode).toBe(302); + const redirect = new URL(response.headers.location!); + expect(redirect.searchParams.get('error')).toBe('INVALID_STATE'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(authQueryMock).not.toHaveBeenCalled(); + }); + }); + + it('rejects callback state bound to another API', async () => { + await withOAuthServer(async (baseUrl) => { + const fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + const stateCookie = createSignedState( + createStatePayload(baseUrl, 'github', { + api_id: '00000000-0000-4000-8000-000000000099' + }), + { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 } + ); + const callbackUrl = new URL('/auth/github/callback', baseUrl); + callbackUrl.searchParams.set('code', 'callback-code'); + callbackUrl.searchParams.set('state', stateCookie); + + const response = await request(callbackUrl.toString(), { + Cookie: `oauth_state=${encodeURIComponent(stateCookie)}` + }); + + expect(response.statusCode).toBe(302); + const redirect = new URL(response.headers.location!); + expect(redirect.searchParams.get('error')).toBe('INVALID_STATE'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(authQueryMock).not.toHaveBeenCalled(); + }); + }); + + it('rejects callback state bound to another origin', async () => { + await withOAuthServer(async (baseUrl) => { + const fetchMock = jest.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + const stateCookie = createSignedState( + createStatePayload(baseUrl, 'github', { + origin: 'https://other.example.test' + }), + { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 } + ); + const callbackUrl = new URL('/auth/github/callback', baseUrl); + callbackUrl.searchParams.set('code', 'callback-code'); + callbackUrl.searchParams.set('state', stateCookie); + + const response = await request(callbackUrl.toString(), { + Cookie: `oauth_state=${encodeURIComponent(stateCookie)}` + }); + + expect(response.statusCode).toBe(302); + const redirect = new URL(response.headers.location!); + expect(redirect.searchParams.get('error')).toBe('INVALID_STATE'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(authQueryMock).not.toHaveBeenCalled(); + }); + }); + + it('only requires the state secret when an OAuth flow starts', async () => { + await withOAuthServer(async (baseUrl) => { + const providersResponse = await request(`${baseUrl}/auth/providers`); + expect(providersResponse.statusCode).toBe(200); + expect(JSON.parse(providersResponse.body)).toEqual({ + providers: ['github'] + }); + + const beginResponse = await request(`${baseUrl}/auth/github`); + expect(beginResponse.statusCode).toBe(302); + const redirect = new URL(beginResponse.headers.location!); + expect(redirect.searchParams.get('error')).toBe('OAUTH_INIT_FAILED'); + expect(getSetCookieValues(beginResponse.headers)).toHaveLength(0); + }, null); + }); + + it('reports provider metadata failures instead of silently returning no providers', async () => { + await withOAuthServer( + async (baseUrl) => { + const response = await request(`${baseUrl}/auth/providers`); + + expect(response.statusCode).toBe(500); + expect(JSON.parse(response.body)).toEqual({ + error: 'OAUTH_CONFIGURATION_ERROR' + }); + }, + OAUTH_STATE_SECRET, + () => { + const context = createConstructiveContext(); + context.useModule.mockRejectedValue( + new Error('internal secrets scope mismatch') + ); + return context; + } + ); + }); + it('uses the identity function schema for successful sign-up callbacks', async () => { await withOAuthServer(async (baseUrl) => { - const beginResponse = await request(`${baseUrl}/auth/github?redirect_uri=%2Fdashboard`); + const beginResponse = await request( + `${baseUrl}/auth/github?redirect_uri=%2Fdashboard` + ); const setCookies = getSetCookieValues(beginResponse.headers); const stateCookie = readCookie(setCookies, 'oauth_state'); const pkceCookie = readCookie(setCookies, 'oauth_pkce'); const pkcePayload = verifySignedState(pkceCookie, { - secret: OAUTH_STATE_SECRET, + secret: OAUTH_STATE_SECRET }); expect(pkcePayload).toBeTruthy(); global.fetch = jest.fn(async (url: string | URL, init?: RequestInit) => { const urlString = url.toString(); - if (urlString === 'https://github.example.test/login/oauth/access_token') { + if ( + urlString === 'https://github.example.test/login/oauth/access_token' + ) { const body = JSON.parse(init?.body as string); expect(body.code_verifier).toBe(pkcePayload!.code_verifier); return { @@ -292,9 +458,9 @@ describe('OAuth routes', () => { status: 200, json: jest.fn().mockResolvedValue({ access_token: 'provider-access-token', - token_type: 'bearer', + token_type: 'bearer' }), - text: jest.fn(), + text: jest.fn() } as unknown as Response; } if (urlString === 'https://github.example.test/api/v3/user') { @@ -305,9 +471,9 @@ describe('OAuth routes', () => { id: 12345, login: 'octocat', email: 'octocat@example.test', - name: 'Octo Cat', + name: 'Octo Cat' }), - text: jest.fn(), + text: jest.fn() } as unknown as Response; } if (urlString === 'https://github.example.test/api/v3/user/emails') { @@ -318,10 +484,10 @@ describe('OAuth routes', () => { { email: 'octocat@example.test', primary: true, - verified: true, - }, + verified: true + } ]), - text: jest.fn(), + text: jest.fn() } as unknown as Response; } throw new Error(`Unexpected fetch URL: ${urlString}`); @@ -329,9 +495,9 @@ describe('OAuth routes', () => { authQueryMock.mockResolvedValueOnce({ rows: [ { - access_token: 'constructive-session-token', - }, - ], + access_token: 'constructive-session-token' + } + ] }); const callbackUrl = new URL('/auth/github/callback', baseUrl); @@ -340,20 +506,20 @@ describe('OAuth routes', () => { const callbackResponse = await request(callbackUrl.toString(), { Cookie: [ `oauth_state=${encodeURIComponent(stateCookie)}`, - `oauth_pkce=${encodeURIComponent(pkceCookie)}`, - ].join('; '), + `oauth_pkce=${encodeURIComponent(pkceCookie)}` + ].join('; ') }); expect(callbackResponse.statusCode).toBe(302); expect(callbackResponse.headers.location).toBe('/dashboard'); expect(authQueryMock).toHaveBeenCalledTimes(1); expect(authQueryMock.mock.calls[0][0]).toContain( - 'constructive_auth_private.sign_up_identity', + 'constructive_auth_private.sign_up_identity' ); expect( getSetCookieValues(callbackResponse.headers).some((cookie) => - cookie.startsWith('constructive_session='), - ), + cookie.startsWith('constructive_session=') + ) ).toBe(true); }); }); diff --git a/graphql/server/src/middleware/cookie.ts b/graphql/server/src/middleware/cookie.ts index 69218e7154..6b95fe016e 100644 --- a/graphql/server/src/middleware/cookie.ts +++ b/graphql/server/src/middleware/cookie.ts @@ -1,5 +1,7 @@ import type { Request, Response } from 'express'; + import type { AuthSettings } from '../types'; +import { pgIntervalToSeconds } from '../utils/pg-interval'; export const SESSION_COOKIE_NAME = 'constructive_session'; export const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token'; @@ -23,36 +25,36 @@ export const getSessionCookieConfig = ( rememberMe = false ): CookieConfig => { const DEFAULT_MAX_AGE = 86400; // 24 hours - let maxAge = DEFAULT_MAX_AGE; - if (rememberMe && typeof authSettings?.rememberMeDuration === 'string') { - const parsed = parseInt(authSettings.rememberMeDuration, 10); - if (!isNaN(parsed)) maxAge = parsed; - } else if (typeof authSettings?.cookieMaxAge === 'string') { - const parsed = parseInt(authSettings.cookieMaxAge, 10); - if (!isNaN(parsed)) maxAge = parsed; - } + const configuredMaxAge = rememberMe + ? (authSettings?.rememberMeDuration ?? authSettings?.cookieMaxAge) + : authSettings?.cookieMaxAge; + const maxAge = pgIntervalToSeconds(configuredMaxAge) ?? DEFAULT_MAX_AGE; return { secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production', - sameSite: (authSettings?.cookieSamesite as 'strict' | 'lax' | 'none') ?? 'lax', + sameSite: + (authSettings?.cookieSamesite as 'strict' | 'lax' | 'none') ?? 'lax', domain: authSettings?.cookieDomain ?? undefined, httpOnly: authSettings?.cookieHttponly ?? true, maxAge, - path: authSettings?.cookiePath ?? '/', + path: authSettings?.cookiePath ?? '/' }; }; /** * Build cookie config for device token (long-lived, 90 days). */ -export const getDeviceTokenCookieConfig = (authSettings?: AuthSettings): CookieConfig => { +export const getDeviceTokenCookieConfig = ( + authSettings?: AuthSettings +): CookieConfig => { return { secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production', - sameSite: (authSettings?.cookieSamesite as 'strict' | 'lax' | 'none') ?? 'lax', + sameSite: + (authSettings?.cookieSamesite as 'strict' | 'lax' | 'none') ?? 'lax', domain: authSettings?.cookieDomain ?? undefined, httpOnly: true, maxAge: DEVICE_TOKEN_MAX_AGE, - path: authSettings?.cookiePath ?? '/', + path: authSettings?.cookiePath ?? '/' }; }; @@ -70,20 +72,23 @@ export const setSessionCookie = ( domain: config.domain, httpOnly: config.httpOnly, maxAge: config.maxAge * 1000, // Express expects milliseconds - path: config.path, + path: config.path }); }; /** * Clear the session cookie. */ -export const clearSessionCookie = (res: Response, config: CookieConfig): void => { +export const clearSessionCookie = ( + res: Response, + config: CookieConfig +): void => { res.clearCookie(SESSION_COOKIE_NAME, { secure: config.secure, sameSite: config.sameSite, domain: config.domain, httpOnly: config.httpOnly, - path: config.path, + path: config.path }); }; @@ -101,20 +106,23 @@ export const setDeviceTokenCookie = ( domain: config.domain, httpOnly: config.httpOnly, maxAge: config.maxAge * 1000, - path: config.path, + path: config.path }); }; /** * Clear the device token cookie. */ -export const clearDeviceTokenCookie = (res: Response, config: CookieConfig): void => { +export const clearDeviceTokenCookie = ( + res: Response, + config: CookieConfig +): void => { res.clearCookie(DEVICE_TOKEN_COOKIE_NAME, { secure: config.secure, sameSite: config.sameSite, domain: config.domain, httpOnly: config.httpOnly, - path: config.path, + path: config.path }); }; @@ -122,10 +130,15 @@ export const clearDeviceTokenCookie = (res: Response, config: CookieConfig): voi * Parse a cookie value from the raw Cookie header. * Avoids pulling in cookie-parser as a dependency. */ -export const parseCookieValue = (req: Request, cookieName: string): string | undefined => { +export const parseCookieValue = ( + req: Request, + cookieName: string +): string | undefined => { const header = req.headers.cookie; if (!header) return undefined; - const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`)); + const match = header + .split(';') + .find((c) => c.trim().startsWith(`${cookieName}=`)); return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined; }; @@ -139,6 +152,8 @@ export const getDeviceTokenFromRequest = (req: Request): string | undefined => { /** * Get the session token from the request cookie. */ -export const getSessionTokenFromRequest = (req: Request): string | undefined => { +export const getSessionTokenFromRequest = ( + req: Request +): string | undefined => { return parseCookieValue(req, SESSION_COOKIE_NAME); }; diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index af3186dc9e..3f715d8572 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -16,46 +16,50 @@ * the manual `set_config()` calls in the original implementation. */ -import { Router, Request, Response } from 'express'; -import { - OAuthClient, - OAuthProfile, - createSignedState, - verifySignedState, -} from '@constructive-io/oauth'; -import { Logger } from '@pgpmjs/logger'; -import { getNodeEnv } from '@pgpmjs/env'; -import { QuoteUtils } from '@pgsql/quotes'; -import type { ConstructiveOptions } from '@constructive-io/graphql-types'; import type { AuthSettings, ConnectedAccountsModuleConfig, ConstructiveContext, - IdentityProvidersConfig, IdentityProviderFullConfig, - UserAuthModuleConfig, + IdentityProvidersConfig, + UserAuthModuleConfig } from '@constructive-io/express-context'; +import type { ConstructiveOptions } from '@constructive-io/graphql-types'; +import { + createSignedState, + OAuthClient, + OAuthProfile, + verifySignedState +} from '@constructive-io/oauth'; +import { getNodeEnv } from '@pgpmjs/env'; +import { Logger } from '@pgpmjs/logger'; +import { QuoteUtils } from '@pgsql/quotes'; +import { Request, Response,Router } from 'express'; +import { pgIntervalToMilliseconds } from '../utils/pg-interval'; import { DEVICE_TOKEN_COOKIE_NAME, - getSessionCookieConfig, getDeviceTokenCookieConfig, - setSessionCookie, - setDeviceTokenCookie, + getSessionCookieConfig, parseCookieValue, + setDeviceTokenCookie, + setSessionCookie } from './cookie'; -import { pgIntervalToMilliseconds } from '../utils/pg-interval'; const log = new Logger('oauth'); const OAUTH_STATE_COOKIE = 'oauth_state'; const OAUTH_PKCE_COOKIE = 'oauth_pkce'; +const OAUTH_COOKIE_PATH = '/auth'; const DEFAULT_OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes const DEFAULT_ERROR_REDIRECT_PATH = '/auth/error'; interface OAuthStatePayload { redirect_uri: string; provider: string; + database_id: string; + api_id: string | null; + origin: string; } interface OAuthPkcePayload { @@ -88,18 +92,18 @@ interface OAuthModules { } async function resolveOAuthModules( - ctx: ConstructiveContext, + ctx: ConstructiveContext ): Promise { const [ identityProviders, userAuthModule, authSettings, - connectedAccountsModule, + connectedAccountsModule ] = await Promise.all([ ctx.useModule('identityProviders'), ctx.useModule('userAuthModule'), ctx.useModule('authSettings'), - ctx.useModule('connectedAccountsModule'), + ctx.useModule('connectedAccountsModule') ]); if (!identityProviders || !userAuthModule) { @@ -110,7 +114,7 @@ async function resolveOAuthModules( identityProviders, userAuthModule, authSettings, - connectedAccountsModule, + connectedAccountsModule }; } @@ -120,7 +124,7 @@ async function resolveOAuthModules( function createOAuthClientForProvider( providerConfig: IdentityProviderFullConfig, - baseUrl: string, + baseUrl: string ): OAuthClient { return new OAuthClient({ providers: { @@ -136,11 +140,11 @@ function createOAuthClientForProvider( userinfoUrl: providerConfig.userinfoUrl, scopes: providerConfig.scopes, authorizationParams: providerConfig.authorizationParams, - pkceEnabled: providerConfig.pkceEnabled, - }, + pkceEnabled: providerConfig.pkceEnabled + } }, baseUrl, - callbackPath: '/auth/{provider}/callback', + callbackPath: '/auth/{provider}/callback' }); } @@ -168,7 +172,7 @@ function getBaseUrl(req: Request): string { function normalizeRedirectUri( redirectUri: string | undefined, - baseUrl: string, + baseUrl: string ): string | null { const requestedRedirectUri = redirectUri || '/'; @@ -194,7 +198,7 @@ function redirectToError( errorPath: string, error: string, provider: string, - errorDescription?: string, + errorDescription?: string ): void { const errorUrl = new URL(errorPath, baseUrl); errorUrl.searchParams.set('error', error); @@ -226,7 +230,7 @@ export function createOAuthRoutes(opts: ConstructiveOptions): Router { res.json({ providers }); } catch (error) { log.error('[oauth] Failed to fetch providers:', error); - res.json({ providers: [] }); + res.status(500).json({ error: 'OAUTH_CONFIGURATION_ERROR' }); } }); @@ -252,7 +256,7 @@ export function createOAuthRoutes(opts: ConstructiveOptions): Router { baseUrl, DEFAULT_ERROR_REDIRECT_PATH, 'API_NOT_CONFIGURED', - provider, + provider ); } @@ -265,7 +269,7 @@ export function createOAuthRoutes(opts: ConstructiveOptions): Router { baseUrl, DEFAULT_ERROR_REDIRECT_PATH, 'MODULES_NOT_CONFIGURED', - provider, + provider ); } @@ -281,7 +285,7 @@ export function createOAuthRoutes(opts: ConstructiveOptions): Router { baseUrl, errorRedirectPath, 'INVALID_REDIRECT_URI', - provider, + provider ); } @@ -294,39 +298,60 @@ export function createOAuthRoutes(opts: ConstructiveOptions): Router { baseUrl, errorRedirectPath, 'PROVIDER_NOT_CONFIGURED', - provider, + provider ); } const stateMaxAge = pgIntervalToMilliseconds(authSettings?.oauthStateMaxAge) ?? DEFAULT_OAUTH_STATE_MAX_AGE; + if (!ctx.databaseId) { + log.error(`[oauth] Missing database scope for ${provider}`); + return redirectToError( + res, + baseUrl, + errorRedirectPath, + 'API_NOT_CONFIGURED', + provider + ); + } const state = createSignedState( - { redirect_uri: redirectUri, provider }, { - secret: requireStateSecret(opts), - maxAgeMs: stateMaxAge, + redirect_uri: redirectUri, + provider, + database_id: ctx.databaseId, + api_id: ctx.api.apiId ?? null, + origin: baseUrl }, + { + secret: requireStateSecret(opts), + maxAgeMs: stateMaxAge + } ); const oauthCookieOptions = { - httpOnly: authSettings?.cookieHttponly ?? true, + httpOnly: true, secure: authSettings?.cookieSecure ?? isProduction, maxAge: stateMaxAge, - sameSite: (authSettings?.cookieSamesite as 'lax' | 'strict' | 'none') ?? 'lax', + sameSite: + (authSettings?.cookieSamesite as 'lax' | 'strict' | 'none') ?? 'lax', + path: OAUTH_COOKIE_PATH }; res.cookie(OAUTH_STATE_COOKIE, state, oauthCookieOptions); const client = createOAuthClientForProvider(providerConfig, baseUrl); - const { url, codeVerifier } = client.getAuthorizationUrl({ provider, state }); + const { url, codeVerifier } = client.getAuthorizationUrl({ + provider, + state + }); if (codeVerifier) { const pkceState = createSignedState( { state, provider, code_verifier: codeVerifier }, { secret: requireStateSecret(opts), - maxAgeMs: stateMaxAge, - }, + maxAgeMs: stateMaxAge + } ); res.cookie(OAUTH_PKCE_COOKIE, pkceState, oauthCookieOptions); } @@ -339,318 +364,315 @@ export function createOAuthRoutes(opts: ConstructiveOptions): Router { baseUrl, DEFAULT_ERROR_REDIRECT_PATH, 'OAUTH_INIT_FAILED', - provider, + 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); - const storedPkce = parseCookieValue(req, OAUTH_PKCE_COOKIE); - res.clearCookie(OAUTH_STATE_COOKIE); - res.clearCookie(OAUTH_PKCE_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, - ); - } + 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); - // Verify state - if (state !== storedState) { - log.warn(`[oauth] State mismatch for ${provider}`); - return redirectToError( - res, - baseUrl, - DEFAULT_ERROR_REDIRECT_PATH, - 'INVALID_STATE', - provider, - ); - } + const storedState = parseCookieValue(req, OAUTH_STATE_COOKIE); + const storedPkce = parseCookieValue(req, OAUTH_PKCE_COOKIE); + res.clearCookie(OAUTH_STATE_COOKIE, { path: OAUTH_COOKIE_PATH }); + res.clearCookie(OAUTH_PKCE_COOKIE, { path: OAUTH_COOKIE_PATH }); - const statePayload = verifySignedState( - storedState as string, - { secret: getStateSecret(opts) }, + // 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 ); - if (!statePayload) { - log.warn(`[oauth] Invalid or expired state for ${provider}`); + } + + // 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, + { secret: getStateSecret(opts) } + ); + if (!statePayload) { + log.warn(`[oauth] Invalid or expired state for ${provider}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'INVALID_STATE', + provider + ); + } + + if (statePayload.provider !== provider || statePayload.origin !== baseUrl) { + log.warn(`[oauth] State request scope mismatch for ${provider}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'INVALID_STATE', + provider + ); + } + + const { redirect_uri: redirectUriFromState } = 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 + ); + } + + if ( + statePayload.database_id !== ctx.databaseId || + statePayload.api_id !== (ctx.api.apiId ?? null) + ) { + log.warn(`[oauth] State database/API scope mismatch for ${provider}`); + return redirectToError( + res, + baseUrl, + DEFAULT_ERROR_REDIRECT_PATH, + 'INVALID_STATE', + provider + ); + } + + let modules: OAuthModules | null = null; + try { + modules = await resolveOAuthModules(ctx); + if (!modules) { + log.error(`[oauth] Required modules not provisioned for ${provider}`); return redirectToError( res, baseUrl, DEFAULT_ERROR_REDIRECT_PATH, - 'INVALID_STATE', - provider, + 'MODULES_NOT_CONFIGURED', + provider ); } - if (statePayload.provider !== provider) { - log.warn(`[oauth] State provider mismatch for ${provider}`); + const { authSettings, identityProviders } = modules; + const errorRedirectPath = + authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH; + const requireVerifiedEmail = + authSettings?.oauthRequireVerifiedEmail ?? true; + + const redirectUri = normalizeRedirectUri(redirectUriFromState, baseUrl); + if (!redirectUri) { + log.warn(`[oauth] Rejected cross-origin redirect_uri for ${provider}`); return redirectToError( res, baseUrl, - DEFAULT_ERROR_REDIRECT_PATH, - 'INVALID_STATE', - provider, + errorRedirectPath, + 'INVALID_REDIRECT_URI', + provider ); } - const { redirect_uri: redirectUriFromState } = statePayload; - const ctx = req.constructive; - - if (!ctx) { - log.error( - `[oauth] No constructive context for ${provider} callback`, - ); + // Get provider config from cached map + const providerConfig = identityProviders.providers.get(provider); + if (!providerConfig) { + log.error(`[oauth] Provider ${provider} not found in database`); return redirectToError( res, baseUrl, - DEFAULT_ERROR_REDIRECT_PATH, - 'API_NOT_CONFIGURED', - provider, + errorRedirectPath, + 'PROVIDER_NOT_CONFIGURED', + provider ); } - let modules: OAuthModules | null = null; - try { - 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 { authSettings, identityProviders } = modules; - const errorRedirectPath = - authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH; - const requireVerifiedEmail = - authSettings?.oauthRequireVerifiedEmail ?? true; - - const redirectUri = normalizeRedirectUri(redirectUriFromState, baseUrl); - if (!redirectUri) { - log.warn(`[oauth] Rejected cross-origin redirect_uri for ${provider}`); - return redirectToError( - res, - baseUrl, - errorRedirectPath, - 'INVALID_REDIRECT_URI', - provider, - ); - } - - // Get provider config from cached map - const providerConfig = identityProviders.providers.get(provider); - if (!providerConfig) { - log.error(`[oauth] Provider ${provider} not found in database`); + let codeVerifier: string | undefined; + if (providerConfig.pkceEnabled) { + const pkcePayload = verifySignedState(storedPkce, { + secret: getStateSecret(opts) + }); + if ( + !pkcePayload || + pkcePayload.state !== storedState || + pkcePayload.provider !== provider || + !pkcePayload.code_verifier + ) { + log.warn(`[oauth] Invalid PKCE verifier state for ${provider}`); return redirectToError( res, baseUrl, errorRedirectPath, - 'PROVIDER_NOT_CONFIGURED', - provider, - ); - } - - let codeVerifier: string | undefined; - if (providerConfig.pkceEnabled) { - const pkcePayload = verifySignedState( - storedPkce, - { secret: getStateSecret(opts) }, + 'INVALID_PKCE', + provider ); - if ( - !pkcePayload || - pkcePayload.state !== storedState || - pkcePayload.provider !== provider || - !pkcePayload.code_verifier - ) { - log.warn(`[oauth] Invalid PKCE verifier state for ${provider}`); - return redirectToError( - res, - baseUrl, - errorRedirectPath, - 'INVALID_PKCE', - provider, - ); - } - codeVerifier = pkcePayload.code_verifier; } + codeVerifier = pkcePayload.code_verifier; + } - const client = createOAuthClientForProvider(providerConfig, baseUrl); - const profile = await client.handleCallback({ - provider, - code: code as string, - codeVerifier, - }); - log.info(`[oauth] Got profile for ${provider}: ${profile.email}`); - - const deviceToken = - parseCookieValue(req, DEVICE_TOKEN_COOKIE_NAME) ?? null; - - const userAgent = req.get('user-agent') || ''; - const { connectedAccountsModule, userAuthModule } = modules; - const authPrivateSchema = userAuthModule.identityFunctionSchemaName; - const signInFn = userAuthModule.signInIdentityFunction; - const signUpFn = userAuthModule.signUpIdentityFunction; - const emailVerified = isEmailVerified(profile); - - // Check if identity already exists via connectedAccounts loader - // This determines whether to sign_in or sign_up, avoiding SAVEPOINT/rollback - let identityExists = false; - if (connectedAccountsModule) { - const checkSql = ` + const client = createOAuthClientForProvider(providerConfig, baseUrl); + const profile = await client.handleCallback({ + provider, + code: code as string, + codeVerifier + }); + log.info(`[oauth] Got profile for ${provider}: ${profile.email}`); + + const deviceToken = + parseCookieValue(req, DEVICE_TOKEN_COOKIE_NAME) ?? null; + + const userAgent = req.get('user-agent') || ''; + const { connectedAccountsModule, userAuthModule } = modules; + const authPrivateSchema = userAuthModule.identityFunctionSchemaName; + const signInFn = userAuthModule.signInIdentityFunction; + const signUpFn = userAuthModule.signUpIdentityFunction; + const emailVerified = isEmailVerified(profile); + + // Check if identity already exists via connectedAccounts loader + // This determines whether to sign_in or sign_up, avoiding SAVEPOINT/rollback + let identityExists = false; + if (connectedAccountsModule) { + const checkSql = ` SELECT 1 FROM ${QuoteUtils.quoteQualifiedIdentifier(connectedAccountsModule.privateSchemaName, connectedAccountsModule.tableName)} WHERE service = $1 AND identifier = $2 LIMIT 1 `; - // Intentional RLS bypass: pre-auth lookup for anonymous user who cannot query - // connected_accounts via RLS. Only checks existence by service+identifier. - const checkResult = await ctx.pool.query(checkSql, [ - profile.provider, - profile.providerId, - ]); - identityExists = checkResult.rowCount > 0; - log.info( - `[oauth] Identity check for ${profile.email}: ${identityExists ? 'exists' : 'new'}`, - ); - } + // Intentional RLS bypass: pre-auth lookup for anonymous user who cannot query + // connected_accounts via RLS. Only checks existence by service+identifier. + const checkResult = await ctx.pool.query(checkSql, [ + profile.provider, + profile.providerId + ]); + identityExists = checkResult.rowCount > 0; + log.info( + `[oauth] Identity check for ${profile.email}: ${identityExists ? 'exists' : 'new'}` + ); + } - // If new identity, check email verification requirement before proceeding - if (!identityExists && requireVerifiedEmail && !emailVerified) { - throw new Error('EMAIL_NOT_VERIFIED'); - } + // If new identity, check email verification requirement before proceeding + if (!identityExists && requireVerifiedEmail && !emailVerified) { + throw new Error('EMAIL_NOT_VERIFIED'); + } - const result = await ctx.withPgClient( - async (client) => { - const details = { - provider: profile.provider, - sub: profile.providerId, - email: profile.email, - email_verified: emailVerified, - name: profile.name, - picture: profile.picture, - raw_userinfo: profile.raw, - }; - - if (identityExists) { - // Sign in existing identity - const signInSql = ` + const result = await ctx.withPgClient( + async (client) => { + const details = { + provider: profile.provider, + sub: profile.providerId, + email: profile.email, + email_verified: emailVerified, + name: profile.name, + picture: profile.picture, + raw_userinfo: profile.raw + }; + + if (identityExists) { + // Sign in existing identity + const signInSql = ` SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(authPrivateSchema, signInFn)}( $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, - JSON.stringify(details), - profile.email, - true, - deviceToken, - ]); - return signInResult.rows[0] || {}; - } else { - // Sign up new identity - log.info( - `[oauth] Creating new account for ${profile.email}`, - ); - const signUpSql = ` + const signInResult = await client.query(signInSql, [ + profile.provider, + profile.providerId, + JSON.stringify(details), + profile.email, + true, + deviceToken + ]); + return signInResult.rows[0] || {}; + } else { + // Sign up new identity + log.info(`[oauth] Creating new account for ${profile.email}`); + const signUpSql = ` SELECT * FROM ${QuoteUtils.quoteQualifiedIdentifier(authPrivateSchema, signUpFn)}( $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] || {}; - } - }, - { - 'jwt.claims.user_agent': userAgent, - 'jwt.claims.origin': baseUrl, - }, - ); - - // 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()); + const signUpResult = await client.query(signUpSql, [ + profile.provider, + profile.providerId, + profile.email, + JSON.stringify(details), + true, + deviceToken + ]); + return signUpResult.rows[0] || {}; + } + }, + { + 'jwt.claims.user_agent': userAgent, + 'jwt.claims.origin': baseUrl } + ); - if (!result.access_token) { - throw new Error('No access token returned from sign_in_identity'); - } + // 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()); + } - const sessionConfig = getSessionCookieConfig( - modules.authSettings, - true, - ); - setSessionCookie(res, result.access_token, sessionConfig); + if (!result.access_token) { + throw new Error('No access token returned from sign_in_identity'); + } - if (result.out_device_token) { - const deviceConfig = getDeviceTokenCookieConfig( - modules.authSettings, - ); - setDeviceTokenCookie(res, result.out_device_token, deviceConfig); - } + const sessionConfig = getSessionCookieConfig(modules.authSettings, true); + setSessionCookie(res, result.access_token, sessionConfig); - log.info(`[oauth] OAuth success for ${profile.email}`); - return res.redirect(redirectUri); - } catch (error: any) { - const fallbackPath = - modules?.authSettings?.oauthErrorRedirectPath || - DEFAULT_ERROR_REDIRECT_PATH; - - // Handle specific error cases - if (error.message === 'EMAIL_NOT_VERIFIED') { - log.warn( - `[oauth] Rejecting unverified email for signup: ${provider}`, - ); - return redirectToError( - res, - baseUrl, - fallbackPath, - 'EMAIL_NOT_VERIFIED', - provider, - ); - } + if (result.out_device_token) { + const deviceConfig = getDeviceTokenCookieConfig(modules.authSettings); + setDeviceTokenCookie(res, result.out_device_token, deviceConfig); + } - log.error(`[oauth] Callback failed for ${provider}:`, error); - redirectToError(res, baseUrl, fallbackPath, 'CALLBACK_FAILED', provider); + log.info(`[oauth] OAuth success for ${profile.email}`); + return res.redirect(redirectUri); + } catch (error: any) { + const fallbackPath = + modules?.authSettings?.oauthErrorRedirectPath || + DEFAULT_ERROR_REDIRECT_PATH; + + // Handle specific error cases + if (error.message === 'EMAIL_NOT_VERIFIED') { + log.warn(`[oauth] Rejecting unverified email for signup: ${provider}`); + return redirectToError( + res, + baseUrl, + fallbackPath, + 'EMAIL_NOT_VERIFIED', + provider + ); } - }, - ); + + log.error(`[oauth] Callback failed for ${provider}:`, error); + redirectToError(res, baseUrl, fallbackPath, 'CALLBACK_FAILED', provider); + } + }); return router; } diff --git a/graphql/server/src/utils/__tests__/pg-interval.test.ts b/graphql/server/src/utils/__tests__/pg-interval.test.ts new file mode 100644 index 0000000000..0066967599 --- /dev/null +++ b/graphql/server/src/utils/__tests__/pg-interval.test.ts @@ -0,0 +1,27 @@ +import { pgIntervalToMilliseconds, pgIntervalToSeconds } from '../pg-interval'; + +describe('PostgreSQL interval conversion', () => { + it('converts structured intervals without mixing seconds and milliseconds', () => { + const interval = { + days: 1, + hours: 2, + minutes: 3, + seconds: 4, + milliseconds: 500 + }; + + expect(pgIntervalToMilliseconds(interval)).toBe(93_784_500); + expect(pgIntervalToSeconds(interval)).toBe(93_784.5); + }); + + it('treats numeric strings as seconds', () => { + expect(pgIntervalToMilliseconds('60')).toBe(60_000); + expect(pgIntervalToSeconds('60.5')).toBe(60.5); + }); + + it('rejects ambiguous, empty, and non-positive values', () => { + expect(pgIntervalToMilliseconds('2 hours')).toBeNull(); + expect(pgIntervalToMilliseconds('0')).toBeNull(); + expect(pgIntervalToMilliseconds(null)).toBeNull(); + }); +}); diff --git a/graphql/server/src/utils/pg-interval.ts b/graphql/server/src/utils/pg-interval.ts index c198696ddd..709ce2e708 100644 --- a/graphql/server/src/utils/pg-interval.ts +++ b/graphql/server/src/utils/pg-interval.ts @@ -7,23 +7,43 @@ import type { PgInterval } from '@constructive-io/express-context'; * settings cookie parser behavior. */ export function pgIntervalToMilliseconds( - interval: string | PgInterval | null | undefined, + interval: string | PgInterval | null | undefined ): number | null { if (!interval) return null; if (typeof interval === 'string') { - const seconds = parseInt(interval, 10); - return Number.isNaN(seconds) ? null : seconds * 1000; + const normalized = interval.trim(); + if (!/^[+-]?\d+(?:\.\d+)?$/.test(normalized)) return null; + const seconds = Number(normalized); + return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null; } - 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; + let totalMilliseconds = 0; + if (interval.years) { + totalMilliseconds += interval.years * 365 * 24 * 60 * 60 * 1000; + } + if (interval.months) { + totalMilliseconds += interval.months * 30 * 24 * 60 * 60 * 1000; + } + if (interval.days) { + totalMilliseconds += interval.days * 24 * 60 * 60 * 1000; + } + if (interval.hours) totalMilliseconds += interval.hours * 60 * 60 * 1000; + if (interval.minutes) totalMilliseconds += interval.minutes * 60 * 1000; + if (interval.seconds) totalMilliseconds += interval.seconds * 1000; + if (interval.milliseconds) { + totalMilliseconds += interval.milliseconds; + } - return totalSeconds > 0 ? totalSeconds * 1000 : null; + return totalMilliseconds > 0 ? totalMilliseconds : null; +} + +/** + * Convert a PostgreSQL interval into seconds for cookie configuration. + */ +export function pgIntervalToSeconds( + interval: string | PgInterval | null | undefined +): number | null { + const milliseconds = pgIntervalToMilliseconds(interval); + return milliseconds === null ? null : milliseconds / 1000; } diff --git a/packages/express-context/__tests__/context.test.ts b/packages/express-context/__tests__/context.test.ts new file mode 100644 index 0000000000..37d6a86d4d --- /dev/null +++ b/packages/express-context/__tests__/context.test.ts @@ -0,0 +1,114 @@ +import type { Request } from 'express'; +import type { Pool } from 'pg'; +import { getPgPool } from 'pg-cache'; +import { withPgClient as withPgClientFn } from 'pg-query-context'; + +import { buildContext } from '../src/context'; +import type { ApiStructure } from '../src/types'; + +jest.mock('pg-cache', () => ({ + getPgPool: jest.fn() +})); + +jest.mock('pg-query-context', () => ({ + withPgClient: jest.fn() +})); + +const api: ApiStructure = { + apiId: '00000000-0000-4000-8000-000000000002', + databaseId: '00000000-0000-4000-8000-000000000001', + dbname: 'tenant_db', + anonRole: 'anonymous', + roleName: 'authenticated', + schema: ['app_public'] +}; + +describe('buildContext withPgClient overrides', () => { + it('merges trusted per-call settings over request settings', async () => { + const tenantPool = { name: 'tenant' } as unknown as Pool; + const routingPool = { name: 'routing' } as unknown as Pool; + const getPoolMock = getPgPool as jest.MockedFunction; + getPoolMock + .mockReturnValueOnce(tenantPool) + .mockReturnValueOnce(routingPool); + + const client = { query: jest.fn() }; + const withClientMock = withPgClientFn as jest.MockedFunction< + typeof withPgClientFn + >; + withClientMock.mockImplementation(async (_pool, _settings, callback) => + callback(client as never) + ); + + const req = { + api, + requestId: 'request-id', + clientIp: '127.0.0.1' + } as unknown as Request; + const registry = { + resolve: jest.fn() + }; + const ctx = buildContext(req, { + loaders: registry as never, + routingSchema: 'routing_public' + }); + + expect(ctx).not.toBeNull(); + const baseSettings = { ...ctx!.pgSettings }; + await ctx!.withPgClient( + async (pgClient) => { + expect(pgClient).toBe(client); + return 'ok'; + }, + { + role: 'oauth_runtime', + 'jwt.claims.origin': 'https://api.example.test' + } + ); + + expect(withClientMock).toHaveBeenCalledWith( + tenantPool, + { + ...baseSettings, + role: 'oauth_runtime', + 'jwt.claims.origin': 'https://api.example.test' + }, + expect.any(Function) + ); + expect(ctx!.pgSettings).toEqual(baseSettings); + }); + + it('propagates callback errors without mutating request settings', async () => { + const tenantPool = { name: 'tenant' } as unknown as Pool; + const routingPool = { name: 'routing' } as unknown as Pool; + const getPoolMock = getPgPool as jest.MockedFunction; + getPoolMock + .mockReturnValueOnce(tenantPool) + .mockReturnValueOnce(routingPool); + + const expectedError = new Error('transaction failed'); + const withClientMock = withPgClientFn as jest.MockedFunction< + typeof withPgClientFn + >; + withClientMock.mockRejectedValueOnce(expectedError); + + const req = { + api, + requestId: 'request-id', + clientIp: '127.0.0.1' + } as unknown as Request; + const ctx = buildContext(req, { + loaders: { resolve: jest.fn() } as never, + routingSchema: 'routing_public' + }); + + expect(ctx).not.toBeNull(); + const baseSettings = { ...ctx!.pgSettings }; + await expect( + ctx!.withPgClient(async () => 'unreachable', { + 'jwt.claims.origin': 'https://api.example.test' + }) + ).rejects.toBe(expectedError); + expect(ctx!.pgSettings).toEqual(baseSettings); + }); +}); diff --git a/packages/express-context/src/loaders/__tests__/auth-settings.test.ts b/packages/express-context/src/loaders/__tests__/auth-settings.test.ts index 45527e8941..0593ad2a46 100644 --- a/packages/express-context/src/loaders/__tests__/auth-settings.test.ts +++ b/packages/express-context/src/loaders/__tests__/auth-settings.test.ts @@ -3,14 +3,17 @@ import type { Pool } from 'pg'; import { authSettingsLoader } from '../auth-settings'; import type { LoaderContext } from '../types'; -function createContext(query: jest.Mock): LoaderContext { +function createContext( + query: jest.Mock, + databaseId = 'hub-database-id' +): LoaderContext { const tenantPool = { query } as unknown as Pool; return { - servicesPool: { query: jest.fn() } as unknown as Pool, + routingPool: { query: jest.fn() } as unknown as Pool, tenantPool, - databaseId: 'hub-database-id', - dbname: 'constructive', + databaseId, + dbname: 'constructive' }; } @@ -26,9 +29,9 @@ describe('authSettingsLoader', () => { rows: [ { schema_name: 'constructive_auth_private', - table_name: 'app_settings_auth', - }, - ], + table_name: 'app_settings_auth' + } + ] }) .mockResolvedValueOnce({ rows: [ @@ -46,9 +49,9 @@ describe('authSettingsLoader', () => { captcha_site_key: null, oauth_state_max_age: { minutes: 10 }, oauth_require_verified_email: true, - oauth_error_redirect_path: '/auth/error', - }, - ], + oauth_error_redirect_path: '/auth/error' + } + ] }); const config = await authSettingsLoader.resolve(createContext(query)); @@ -70,7 +73,33 @@ describe('authSettingsLoader', () => { cookieSecure: false, cookieSamesite: 'lax', oauthRequireVerifiedEmail: true, - oauthErrorRedirectPath: '/auth/error', + oauthErrorRedirectPath: '/auth/error' }); }); + + it('returns undefined when the sessions module is not provisioned', async () => { + const query = jest.fn().mockResolvedValueOnce({ rows: [] }); + + await expect( + authSettingsLoader.resolve(createContext(query, 'unprovisioned-db')) + ).resolves.toBeUndefined(); + expect(query).toHaveBeenCalledTimes(1); + expect(query).toHaveBeenCalledWith( + expect.stringContaining('sm.database_id = $1'), + ['unprovisioned-db'] + ); + }); + + it('fails clearly when resolved table metadata is incomplete', async () => { + const query = jest.fn().mockResolvedValueOnce({ + rows: [{ schema_name: null, table_name: 'app_settings_auth' }] + }); + + await expect( + authSettingsLoader.resolve(createContext(query, 'invalid-metadata-db')) + ).rejects.toThrow( + 'invalid auth settings table metadata for database invalid-metadata-db' + ); + expect(query).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/express-context/src/loaders/__tests__/compute.test.ts b/packages/express-context/src/loaders/__tests__/compute.test.ts deleted file mode 100644 index 93b63d3f39..0000000000 --- a/packages/express-context/src/loaders/__tests__/compute.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Pool } from 'pg'; - -import { computeLoader } from '../compute'; -import type { LoaderContext } from '../types'; - -function createContext(query: jest.Mock): LoaderContext { - const tenantPool = { query } as unknown as Pool; - - return { - servicesPool: { query: jest.fn() } as unknown as Pool, - tenantPool, - databaseId: 'hub-database-id', - dbname: 'constructive', - }; -} - -describe('computeLoader', () => { - afterEach(() => { - computeLoader.invalidate(); - }); - - it('supports compute metadata before bindings and entity names were stored on module rows', async () => { - const query = jest.fn().mockResolvedValue({ - rows: [ - { - functions_schema_name: 'constructive_compute_public', - definitions_table_name: 'function_definitions', - bindings_table_name: 'function_api_bindings', - invocations_schema_name: 'constructive_compute_public', - invocations_table_name: 'function_invocations', - invocations_entity_field: null, - }, - ], - }); - - const config = await computeLoader.resolve(createContext(query)); - const sql = query.mock.calls[0][0] as string; - - expect(sql).toContain("to_jsonb(fm) ->> 'bindings_table_name'"); - expect(sql).toContain('legacy_bindings.name'); - expect(sql).toContain("to_jsonb(ivm) ->> 'entity_field'"); - expect(sql).not.toContain('fm.bindings_table_name'); - expect(sql).not.toContain('ivm.entity_field'); - expect(query).toHaveBeenCalledWith(sql, ['hub-database-id']); - expect(config).toEqual({ - modules: [ - { - schemaName: 'constructive_compute_public', - definitionsTableName: 'function_definitions', - bindingsTableName: 'function_api_bindings', - invocationsSchemaName: 'constructive_compute_public', - invocationsTableName: 'function_invocations', - invocationsEntityField: null, - }, - ], - }); - }); - - it('fails clearly when neither current nor legacy metadata resolves a bindings table', async () => { - const query = jest.fn().mockResolvedValue({ - rows: [ - { - functions_schema_name: 'compute_public', - definitions_table_name: 'function_definitions', - bindings_table_name: null, - invocations_schema_name: 'compute_public', - invocations_table_name: 'function_invocations', - invocations_entity_field: null, - }, - ], - }); - - await expect(computeLoader.resolve(createContext(query))).rejects.toThrow( - 'function bindings table missing for schema compute_public' - ); - }); -}); diff --git a/packages/express-context/src/loaders/__tests__/identity-providers.test.ts b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts index 1ef409820a..819ae5396c 100644 --- a/packages/express-context/src/loaders/__tests__/identity-providers.test.ts +++ b/packages/express-context/src/loaders/__tests__/identity-providers.test.ts @@ -1,10 +1,11 @@ import type { Pool } from 'pg'; -import type { LoaderContext } from '../types'; import { buildProvidersSql, - resolveIdentityProvidersConfig, + identityProvidersLoader, + resolveIdentityProvidersConfig } from '../identity-providers'; +import type { LoaderContext } from '../types'; import { userAuthModuleLoader } from '../user-auth-module'; type QueryResult = { rows: unknown[] }; @@ -22,21 +23,22 @@ function createMockPool(handlers: QueryHandler[]) { }); return { - pool: { query }, - queries, + pool: { query } as unknown as Pool, + queries }; } function createContext( - servicesPool: ReturnType['pool'], - tenantPool: ReturnType['pool'], - databaseId = 'tenant-db' + tenantPool: Pool, + databaseId = 'tenant-db', + routingPool: Pool = { query: jest.fn() } as unknown as Pool ): LoaderContext { return { - servicesPool: servicesPool as unknown as Pool, - tenantPool: tenantPool as unknown as Pool, + routingPool, + tenantPool, databaseId, - dbname: 'constructive-test', + apiId: 'api-id', + dbname: 'constructive-test' }; } @@ -47,34 +49,28 @@ function identityProvidersModuleRow(databaseId: string, scope: string) { private_schema_name: 'auth_private', table_name: 'identity_providers', scope, - prefix: scope, + prefix: scope }; } describe('identityProvidersLoader metadata resolution', () => { - it('resolves provider secrets table through config_secrets_module schema metadata', async () => { + afterEach(() => { + identityProvidersLoader.invalidate(); + }); + + it('loads database-owned provider config through internal secrets metadata', async () => { const tenant = createMockPool([ () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), - ]); - const services = createMockPool([ - () => ({ rows: [{ database_id: 'platform-db' }] }), - () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), () => ({ - rows: [ - { - has_internal_secrets_module: true, - has_config_secrets_module: false, - }, - ], + rows: [{ internal_secrets_table_id: 'secrets-table-id' }] }), - () => ({ rows: [{ internal_secrets_table_id: 'secrets-table-id' }] }), () => ({ rows: [ { schema_name: 'secret_private', - table_name: 'resolved_secrets', - }, - ], + table_name: 'app_secrets' + } + ] }), () => ({ rows: [ @@ -90,201 +86,115 @@ describe('identityProvidersLoader metadata resolution', () => { userinfo_url: 'https://github.example/user', scopes: ['read:user'], extra_authorization_params: { prompt: 'select_account' }, - pkce_enabled: true, - }, - ], - }), + pkce_enabled: true + } + ] + }) ]); + const routing = createMockPool([]); const config = await resolveIdentityProvidersConfig( - createContext(services.pool, tenant.pool) + createContext(tenant.pool, 'tenant-db', routing.pool) ); + expect(config).toMatchObject({ + schemaName: 'auth_public', + privateSchemaName: 'auth_private', + tableName: 'identity_providers', + scope: 'app', + prefix: 'app', + rotateSecretFunction: 'rotate_identity_provider_app_secret' + }); expect(config?.providers.get('github')).toMatchObject({ clientId: 'dummy-client-id', clientSecret: 'dummy-client-secret', authorizationUrl: 'https://github.example/authorize', - authorizationParams: { prompt: 'select_account' }, + authorizationParams: { prompt: 'select_account' } }); - expect(services.queries[0].values).toEqual(['constructive-test']); - expect(services.queries[2].sql).toContain('to_regclass'); - expect(services.queries[3].sql).toContain('internal_secrets_module'); - expect(services.queries[3].values).toEqual(['platform-db', 'platform']); - expect(services.queries[5].sql).toContain( - 'secret_private.resolved_secrets' - ); - expect(services.queries[5].sql).not.toContain('constructive_store_private'); - expect(services.queries[5].sql).not.toContain('platform_secrets'); - }); - - it('supports the Hub DB config_secrets_module contract', async () => { - const tenant = createMockPool([ - () => ({ rows: [identityProvidersModuleRow('tenant-db', 'platform')] }), - ]); - const services = createMockPool([ - () => ({ rows: [{ database_id: 'platform-db' }] }), - () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), - () => ({ - rows: [ - { - has_internal_secrets_module: false, - has_config_secrets_module: true, - }, - ], - }), - () => ({ rows: [{ table_id: 'platform-secrets-table-id' }] }), - () => ({ - rows: [ - { - schema_name: 'constructive_store_private', - table_name: 'platform_secrets', - }, - ], - }), - () => ({ rows: [] }), - ]); - - const config = await resolveIdentityProvidersConfig( - createContext(services.pool, tenant.pool) - ); - - expect(config?.rotateSecretFunction).toBe( - 'rotate_identity_provider_platform_secret' + expect(routing.queries).toHaveLength(0); + expect(tenant.queries[0].values).toEqual(['tenant-db']); + expect(tenant.queries[1].sql).toContain('internal_secrets_module'); + expect(tenant.queries[1].values).toEqual(['tenant-db', 'app']); + expect(tenant.queries[2].sql).toContain('metaschema.schema_and_table'); + expect(tenant.queries[3].sql).toContain('secret_private.app_secrets'); + expect(tenant.queries.map(({ sql }) => sql).join('\n')).not.toContain( + 'services_public' ); - expect(services.queries[3].sql).toContain('config_secrets_module'); - expect(services.queries[3].values).toEqual(['platform-db', 'platform']); - expect(services.queries[5].sql).toContain( - 'constructive_store_private.platform_secrets' + expect(tenant.queries.map(({ sql }) => sql).join('\n')).not.toContain( + 'config_secrets_module' ); }); - it('falls back to config_secrets_module when both relations exist but only the legacy row is provisioned', async () => { - const tenant = createMockPool([ - () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), - ]); - const services = createMockPool([ - () => ({ rows: [{ database_id: 'platform-db' }] }), - () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), - () => ({ - rows: [ - { - has_internal_secrets_module: true, - has_config_secrets_module: true, - }, - ], - }), - () => ({ rows: [] }), - () => ({ rows: [{ table_id: 'legacy-secrets-table-id' }] }), - () => ({ - rows: [ - { - schema_name: 'legacy_private', - table_name: 'legacy_secrets', - }, - ], - }), - () => ({ rows: [] }), - ]); + it('returns undefined when identity providers are not provisioned', async () => { + const tenant = createMockPool([() => ({ rows: [] })]); - await resolveIdentityProvidersConfig( - createContext(services.pool, tenant.pool) - ); - - expect(services.queries[3].sql).toContain('internal_secrets_module'); - expect(services.queries[4].sql).toContain('config_secrets_module'); - expect(services.queries[6].sql).toContain('legacy_private.legacy_secrets'); - }); - - it('builds provider SQL without the old platform secrets hardcode', () => { - const sql = buildProvidersSql( - 'auth_private', - 'identity_providers', - 'secret_private', - 'resolved_secrets' - ); - - expect(sql).toContain('auth_private.identity_providers'); - expect(sql).toContain('secret_private.resolved_secrets'); - expect(sql).not.toContain('constructive_store_private'); - expect(sql).not.toContain('platform_secrets'); + await expect( + resolveIdentityProvidersConfig(createContext(tenant.pool)) + ).resolves.toBeUndefined(); + expect(tenant.queries).toHaveLength(1); }); - it('throws a clear error when neither secrets metadata relation exists', async () => { + it('rejects ambiguous provider ownership within one database', async () => { const tenant = createMockPool([ - () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), - ]); - const services = createMockPool([ - () => ({ rows: [{ database_id: 'platform-db' }] }), - () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), () => ({ rows: [ - { - has_internal_secrets_module: false, - has_config_secrets_module: false, - }, - ], - }), + identityProvidersModuleRow('tenant-db', 'app'), + identityProvidersModuleRow('tenant-db', 'platform') + ] + }) ]); await expect( - resolveIdentityProvidersConfig(createContext(services.pool, tenant.pool)) + resolveIdentityProvidersConfig(createContext(tenant.pool)) ).rejects.toThrow( - 'secrets module metadata unavailable for database platform-db' + 'multiple identity_providers_module rows found for database tenant-db' ); + expect(tenant.queries).toHaveLength(1); }); - it('throws a clear error when neither available module has the requested scope', async () => { + it('throws when the matching internal secrets scope is missing', async () => { const tenant = createMockPool([ () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), - ]); - const services = createMockPool([ - () => ({ rows: [{ database_id: 'platform-db' }] }), - () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), - () => ({ - rows: [ - { - has_internal_secrets_module: true, - has_config_secrets_module: true, - }, - ], - }), - () => ({ rows: [] }), - () => ({ rows: [] }), + () => ({ rows: [] }) ]); await expect( - resolveIdentityProvidersConfig(createContext(services.pool, tenant.pool)) + resolveIdentityProvidersConfig(createContext(tenant.pool)) ).rejects.toThrow( - 'internal_secrets_module and config_secrets_module missing for scope platform on database platform-db' + 'internal_secrets_module missing for scope app on database tenant-db' ); }); - it('throws a clear error when config_secrets_module table resolution fails', async () => { + it('throws when the internal secrets table id cannot be resolved', async () => { const tenant = createMockPool([ () => ({ rows: [identityProvidersModuleRow('tenant-db', 'app')] }), - ]); - const services = createMockPool([ - () => ({ rows: [{ database_id: 'platform-db' }] }), - () => ({ rows: [identityProvidersModuleRow('platform-db', 'platform')] }), () => ({ - rows: [ - { - has_internal_secrets_module: true, - has_config_secrets_module: false, - }, - ], + rows: [{ internal_secrets_table_id: 'missing-table-id' }] }), - () => ({ rows: [{ internal_secrets_table_id: 'missing-table-id' }] }), () => { throw new Error('NOT_FOUND'); - }, + } ]); await expect( - resolveIdentityProvidersConfig(createContext(services.pool, tenant.pool)) + resolveIdentityProvidersConfig(createContext(tenant.pool)) ).rejects.toThrow( - 'schema/table resolution missing for internal_secrets_module scope platform on database platform-db' + 'schema/table resolution missing for internal_secrets_module scope app on database tenant-db' + ); + }); + + it('builds provider SQL from resolved identifiers', () => { + const sql = buildProvidersSql( + 'auth_private', + 'identity_providers', + 'secret_private', + 'app_secrets' ); + + expect(sql).toContain('auth_private.identity_providers'); + expect(sql).toContain('secret_private.app_secrets'); + expect(sql).not.toContain('constructive_store_private'); + expect(sql).not.toContain('platform_secrets'); }); }); @@ -293,7 +203,7 @@ describe('userAuthModuleLoader', () => { userAuthModuleLoader.invalidate(); }); - it('continues resolving identity auth function constants', async () => { + it('resolves identity auth function constants', async () => { const tenant = createMockPool([ () => ({ rows: [ @@ -305,22 +215,15 @@ describe('userAuthModuleLoader', () => { sign_out_function: 'sign_out', sign_in_cross_origin_function: null, request_cross_origin_token_function: null, - extend_token_expires: '1 hour', - }, - ], - }), - () => ({ - rows: [ - { - schema_name: 'auth_private', - }, - ], + extend_token_expires: '1 hour' + } + ] }), + () => ({ rows: [{ schema_name: 'auth_private' }] }) ]); - const services = createMockPool([]); const config = await userAuthModuleLoader.resolve( - createContext(services.pool, tenant.pool, 'user-auth-db') + createContext(tenant.pool, 'user-auth-db') ); expect(config).toMatchObject({ @@ -328,11 +231,11 @@ describe('userAuthModuleLoader', () => { identityFunctionSchemaName: 'auth_private', sessionCredentialsSchemaName: 'session_private', signInIdentityFunction: 'sign_in_identity', - signUpIdentityFunction: 'sign_up_identity', + signUpIdentityFunction: 'sign_up_identity' }); }); - it('falls back to the public auth schema when identity function schema is not discoverable', async () => { + it('falls back to the public auth schema when identity functions are not discoverable', async () => { const tenant = createMockPool([ () => ({ rows: [ @@ -344,22 +247,21 @@ describe('userAuthModuleLoader', () => { sign_out_function: 'sign_out', sign_in_cross_origin_function: null, request_cross_origin_token_function: null, - extend_token_expires: '1 hour', - }, - ], + extend_token_expires: '1 hour' + } + ] }), - () => ({ rows: [] }), + () => ({ rows: [] }) ]); - const services = createMockPool([]); const config = await userAuthModuleLoader.resolve( - createContext(services.pool, tenant.pool, 'fallback-user-auth-db') + createContext(tenant.pool, 'fallback-user-auth-db') ); expect(config).toMatchObject({ schemaName: 'auth_public', identityFunctionSchemaName: 'auth_public', - sessionCredentialsSchemaName: 'auth_public', + sessionCredentialsSchemaName: 'auth_public' }); }); }); diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index 0169c179dd..68c8753a59 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -46,10 +46,20 @@ async function discoverAuthSettingsTable( }>(AUTH_SETTINGS_DISCOVERY_SQL, [databaseId]); const resolved = discovery.rows[0]; if (!resolved) return null; + if ( + typeof resolved.schema_name !== 'string' || + resolved.schema_name.length === 0 || + typeof resolved.table_name !== 'string' || + resolved.table_name.length === 0 + ) { + throw new Error( + `invalid auth settings table metadata for database ${databaseId}` + ); + } return { schemaName: resolved.schema_name, - tableName: resolved.table_name, + tableName: resolved.table_name }; } @@ -112,7 +122,7 @@ export const authSettingsLoader: ModuleLoader = captchaSiteKey: row.captcha_site_key, oauthStateMaxAge: row.oauth_state_max_age, oauthRequireVerifiedEmail: row.oauth_require_verified_email, - oauthErrorRedirectPath: row.oauth_error_redirect_path, + oauthErrorRedirectPath: row.oauth_error_redirect_path }; - }, + } }); diff --git a/packages/express-context/src/loaders/compute.ts b/packages/express-context/src/loaders/compute.ts index 8c5c2fcaa2..ec11b7aec8 100644 --- a/packages/express-context/src/loaders/compute.ts +++ b/packages/express-context/src/loaders/compute.ts @@ -22,22 +22,12 @@ const COMPUTE_MODULE_SQL = ` SELECT fs.schema_name AS functions_schema_name, fm.definitions_table_name, - COALESCE( - to_jsonb(fm) ->> 'bindings_table_name', - legacy_bindings.name - ) AS bindings_table_name, + fm.bindings_table_name, ivs.schema_name AS invocations_schema_name, ivm.invocations_table_name, - to_jsonb(ivm) ->> 'entity_field' AS invocations_entity_field + ivm.entity_field AS invocations_entity_field FROM metaschema_modules_public.function_module fm JOIN metaschema_public.schema fs ON fs.id = fm.schema_id - LEFT JOIN metaschema_public.table legacy_bindings - ON legacy_bindings.schema_id = fm.schema_id - AND legacy_bindings.name = regexp_replace( - fm.definitions_table_name, - '_definitions$', - '_api_bindings' - ) JOIN metaschema_modules_public.function_invocation_module ivm ON ivm.database_id = fm.database_id AND ivm.scope = fm.scope JOIN metaschema_public.schema ivs ON ivs.id = ivm.schema_id @@ -50,7 +40,7 @@ const COMPUTE_MODULE_SQL = ` interface ComputeModuleRow { functions_schema_name: string; definitions_table_name: string; - bindings_table_name: string | null; + bindings_table_name: string; invocations_schema_name: string; invocations_table_name: string; invocations_entity_field: string | null; @@ -58,38 +48,32 @@ interface ComputeModuleRow { // ─── Loader ───────────────────────────────────────────────────────────────── -export const computeLoader: ModuleLoader = - createModuleLoader({ - name: 'compute', - ttlMs: 60_000, - async resolve(ctx: LoaderContext) { - const { tenantPool, databaseId } = ctx; +export const computeLoader: ModuleLoader = createModuleLoader({ + name: 'compute', + ttlMs: 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; - const result = await tenantPool.query( - COMPUTE_MODULE_SQL, - [databaseId] - ); - if (result.rows.length === 0) return undefined; + const result = await tenantPool.query( + COMPUTE_MODULE_SQL, + [databaseId], + ); + if (result.rows.length === 0) return undefined; - return { - modules: result.rows.map((row) => { - if (!row.bindings_table_name) { - throw new Error( - `function bindings table missing for schema ${row.functions_schema_name}` - ); - } - - return { - schemaName: row.functions_schema_name, - definitionsTableName: row.definitions_table_name, - bindingsTableName: row.bindings_table_name, - invocationsSchemaName: row.invocations_schema_name, - invocationsTableName: row.invocations_table_name, - // Scope-key column of the invocations table (entity_field), or NULL - // for global scopes and DB revisions that predate this metadata. - invocationsEntityField: row.invocations_entity_field, - }; - }), - }; - }, - }); + return { + modules: result.rows.map((row) => ({ + schemaName: row.functions_schema_name, + definitionsTableName: row.definitions_table_name, + // Physical bindings table name, recorded by the metaschema generator + // on the function_module config row — read as a fact, never derived + // from the definitions table name. + bindingsTableName: row.bindings_table_name, + invocationsSchemaName: row.invocations_schema_name, + invocationsTableName: row.invocations_table_name, + // Scope-key column of the invocations table (entity_field), or NULL + // for global scopes. Consumers set this column on inserts. + invocationsEntityField: row.invocations_entity_field, + })), + }; + }, +}); diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts index 627de21254..bb00d80243 100644 --- a/packages/express-context/src/loaders/identity-providers.ts +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -1,25 +1,22 @@ /** * Identity Providers Module Loader * - * Resolves the identity_providers_module config for the current request and - * loads enabled provider credentials from the platform database. + * Resolves the identity_providers_module config and enabled provider + * credentials for the current request database. */ import { QuoteUtils } from '@pgsql/quotes'; import type { - ConfigSecretsModuleRow, - InternalSecretsModuleRow, IdentityProviderConfigMap, IdentityProvidersConfig, IdentityProvidersModuleRow, - PlatformDatabaseRow, + InternalSecretsModuleRow, ProviderRow, - SchemaAndTableRow, - SecretsModuleAvailabilityRow, + SchemaAndTableRow } from '../types'; -import type { LoaderContext, ModuleLoader } from './types'; import { createModuleLoader } from './create-loader'; +import type { LoaderContext, ModuleLoader } from './types'; // ─── SQL ──────────────────────────────────────────────────────────────────── @@ -35,15 +32,6 @@ const IDENTITY_PROVIDERS_MODULE_SQL = ` 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 -`; - -const CONFIG_SECRETS_MODULE_SQL = ` - SELECT csm.table_id - FROM metaschema_modules_public.config_secrets_module csm - WHERE csm.database_id = $1 - AND csm.scope = $2 - LIMIT 1 `; const INTERNAL_SECRETS_MODULE_SQL = ` @@ -54,38 +42,11 @@ const INTERNAL_SECRETS_MODULE_SQL = ` LIMIT 1 `; -const SECRETS_MODULE_AVAILABILITY_SQL = ` - SELECT - to_regclass( - 'metaschema_modules_public.internal_secrets_module' - ) IS NOT NULL AS has_internal_secrets_module, - to_regclass( - 'metaschema_modules_public.config_secrets_module' - ) IS NOT NULL AS has_config_secrets_module -`; - const SCHEMA_AND_TABLE_SQL = ` SELECT schema_name, table_name FROM metaschema.schema_and_table($1) `; -const PLATFORM_DATABASE_SQL = ` - SELECT d.id AS database_id - FROM metaschema_public.database d - WHERE d.name = $1 - OR EXISTS ( - SELECT 1 - FROM services_public.apis a - WHERE a.database_id = d.id - AND a.dbname = $1 - ) - ORDER BY - CASE WHEN d.name = $1 THEN 0 ELSE 1 END, - CASE WHEN d.owner_id IS NULL THEN 0 ELSE 1 END, - d.created_at ASC - LIMIT 1 -`; - function buildProvidersSql( ipSchema: string, ipTable: string, @@ -148,73 +109,22 @@ async function resolveSecretsTable( databaseId: string, scope: string ): Promise { - const availabilityResult = - await ctx.servicesPool.query( - SECRETS_MODULE_AVAILABILITY_SQL - ); - const availability = availabilityResult.rows[0]; - - // Some supported DB revisions still expose the pre-split secrets metadata. - // Prefer the new contract while retaining support for the legacy module. - let selectedModule: - | { - name: 'internal_secrets_module' | 'config_secrets_module'; - tableId: string; - } - | undefined; - - if (availability?.has_internal_secrets_module) { - const internalResult = - await ctx.servicesPool.query( - INTERNAL_SECRETS_MODULE_SQL, - [databaseId, scope] - ); - const tableId = internalResult.rows[0]?.internal_secrets_table_id; - if (tableId) { - selectedModule = { - name: 'internal_secrets_module', - tableId, - }; - } - } - - if (!selectedModule && availability?.has_config_secrets_module) { - const configResult = await ctx.servicesPool.query( - CONFIG_SECRETS_MODULE_SQL, + const secretsModuleResult = + await ctx.tenantPool.query( + INTERNAL_SECRETS_MODULE_SQL, [databaseId, scope] ); - const tableId = configResult.rows[0]?.table_id; - if (tableId) { - selectedModule = { - name: 'config_secrets_module', - tableId, - }; - } - } - - if (!selectedModule) { - const availableModules = [ - availability?.has_internal_secrets_module && 'internal_secrets_module', - availability?.has_config_secrets_module && 'config_secrets_module', - ].filter(Boolean); - - if (availableModules.length === 0) { - throw new Error( - `secrets module metadata unavailable for database ${databaseId}` - ); - } - + const tableId = secretsModuleResult.rows[0]?.internal_secrets_table_id; + if (!tableId) { throw new Error( - `${availableModules.join( - ' and ' - )} missing for scope ${scope} on database ${databaseId}` + `internal_secrets_module missing for scope ${scope} on database ${databaseId}` ); } try { - const schemaResult = await ctx.servicesPool.query( + const schemaResult = await ctx.tenantPool.query( SCHEMA_AND_TABLE_SQL, - [selectedModule.tableId] + [tableId] ); const schemaRow = schemaResult.rows[0]; if (schemaRow) return schemaRow; @@ -223,61 +133,40 @@ async function resolveSecretsTable( } throw new Error( - `schema/table resolution missing for ${selectedModule.name} scope ${scope} on database ${databaseId}` + `schema/table resolution missing for internal_secrets_module scope ${scope} on database ${databaseId}` ); } export async function resolveIdentityProvidersConfig( ctx: LoaderContext ): Promise { - const { servicesPool, tenantPool, databaseId, dbname } = ctx; + const { tenantPool, databaseId } = ctx; const moduleResult = await tenantPool.query( IDENTITY_PROVIDERS_MODULE_SQL, [databaseId] ); - const moduleRow = moduleResult.rows[0]; - if (!moduleRow) { + if (moduleResult.rows.length > 1) { throw new Error( - `identity_providers_module missing for database ${databaseId}` + `multiple identity_providers_module rows found for database ${databaseId}; provider ownership scope must be explicit` ); } - const functionPrefix = moduleRow.prefix || moduleRow.scope || 'platform'; - - // Provider credentials are platform-managed; auth functions remain scoped - // to the current request database. - const platformDatabaseResult = await servicesPool.query( - PLATFORM_DATABASE_SQL, - [dbname] - ); - const platformDatabaseId = platformDatabaseResult.rows[0]?.database_id; - if (!platformDatabaseId) return undefined; - - const providerModuleRow = - platformDatabaseId === databaseId - ? moduleRow - : ( - await servicesPool.query( - IDENTITY_PROVIDERS_MODULE_SQL, - [platformDatabaseId] - ) - ).rows[0]; - if (!providerModuleRow) { - throw new Error( - `identity_providers_module missing for database ${platformDatabaseId}` - ); + const moduleRow = moduleResult.rows[0]; + if (!moduleRow) { + return undefined; } + const functionPrefix = moduleRow.prefix || moduleRow.scope; const secretsTable = await resolveSecretsTable( ctx, - providerModuleRow.database_id, - providerModuleRow.scope + databaseId, + moduleRow.scope ); - const providersResult = await servicesPool.query( + const providersResult = await tenantPool.query( buildProvidersSql( - providerModuleRow.private_schema_name, - providerModuleRow.table_name, + moduleRow.private_schema_name, + moduleRow.table_name, secretsTable.schema_name, secretsTable.table_name ) @@ -302,7 +191,7 @@ export async function resolveIdentityProvidersConfig( authorizationParams: normalizeStringParams( row.extra_authorization_params ), - pkceEnabled: row.pkce_enabled ?? true, + pkceEnabled: row.pkce_enabled ?? true }); } @@ -313,7 +202,7 @@ export async function resolveIdentityProvidersConfig( scope: moduleRow.scope, prefix: functionPrefix, rotateSecretFunction: `rotate_identity_provider_${functionPrefix}_secret`, - providers, + providers }; } @@ -321,7 +210,7 @@ export const identityProvidersLoader: ModuleLoader = createModuleLoader({ name: 'identityProviders', ttlMs: 5 * 60_000, - resolve: resolveIdentityProvidersConfig, + resolve: resolveIdentityProvidersConfig }); export { buildProvidersSql }; diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index ae1930996b..af7549b1fa 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -264,17 +264,8 @@ export interface IdentityProvidersModuleRow { prefix: string | null; } -export interface ConfigSecretsModuleRow { - table_id: string; -} - -export interface ConfigSecretsModuleRow { - table_id: string; -} - -export interface SecretsModuleAvailabilityRow { - has_internal_secrets_module: boolean; - has_config_secrets_module: boolean; +export interface InternalSecretsModuleRow { + internal_secrets_table_id: string; } export interface SchemaAndTableRow { @@ -282,10 +273,6 @@ export interface SchemaAndTableRow { table_name: string; } -export interface PlatformDatabaseRow { - database_id: string; -} - export interface ProviderRow { slug: string; kind: 'oauth2' | 'oidc'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82e707d8e1..13a05a61c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1962,6 +1962,9 @@ importers: '@constructive-io/graphql-types': specifier: workspace:^ version: link:../types/dist + '@constructive-io/oauth': + specifier: workspace:^ + version: link:../../packages/oauth/dist '@constructive-io/query-builder': specifier: workspace:^ version: link:../../postgres/query-builder/dist @@ -5458,6 +5461,12 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pgsql/quotes@17.1.0': + resolution: + { + integrity: sha512-J/H+LcrENBpYgL45WW6aTjb5Yk4tX4+AmB2/k8KZa+Zh3wiCtqmNIag+HZz5HmWaF6EZK9ZGC95NBD1fs+rUvg==, + } + '@pgsql/quotes@18.1.0': resolution: {integrity: sha512-wZcMP1QHiQbWWKOw4nfvZ74xUSz/9jqeSUXVnoR1saI5M0D+iYbiuOlfNDyYmziLwgEkCuSJLZK1dZ+eQCwgAA==} @@ -13096,6 +13105,8 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@pgsql/quotes@17.1.0': {} + '@pgsql/quotes@18.1.0': {} '@pgsql/transform@18.8.0': From cc660c304dcb26300231c5c46bb6ad10844ee460 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 28 Jul 2026 23:19:50 +0800 Subject: [PATCH 32/32] fix(oauth): resolve auth settings from metadata tables --- .../src/loaders/__tests__/auth-settings.test.ts | 9 ++++++++- packages/express-context/src/loaders/auth-settings.ts | 11 +++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/express-context/src/loaders/__tests__/auth-settings.test.ts b/packages/express-context/src/loaders/__tests__/auth-settings.test.ts index 0593ad2a46..5e066a4972 100644 --- a/packages/express-context/src/loaders/__tests__/auth-settings.test.ts +++ b/packages/express-context/src/loaders/__tests__/auth-settings.test.ts @@ -61,7 +61,14 @@ describe('authSettingsLoader', () => { expect.stringContaining('sm.auth_settings_table_id'), ['hub-database-id'] ); - expect(query.mock.calls[0][0]).toContain('metaschema.schema_and_table'); + expect(query.mock.calls[0][0]).toContain('metaschema_public.table'); + expect(query.mock.calls[0][0]).toContain('metaschema_public.schema'); + expect(query.mock.calls[0][0]).toContain( + 't.database_id = sm.database_id' + ); + expect(query.mock.calls[0][0]).toContain( + 's.database_id = sm.database_id' + ); expect(query.mock.calls[0][0]).not.toContain('sm.auth_settings_table_name'); expect(query.mock.calls[0][0]).not.toContain('sm.auth_settings_table AS'); expect(query.mock.calls[1][0]).toContain( diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index 68c8753a59..de087d2683 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -20,11 +20,14 @@ import type { LoaderContext, ModuleLoader } from './types'; // ─── SQL ──────────────────────────────────────────────────────────────────── const AUTH_SETTINGS_DISCOVERY_SQL = ` - SELECT resolved.schema_name, resolved.table_name + SELECT s.schema_name, t.name AS table_name FROM metaschema_modules_public.sessions_module sm - CROSS JOIN LATERAL metaschema.schema_and_table( - sm.auth_settings_table_id - ) resolved + JOIN metaschema_public.table t + ON t.id = sm.auth_settings_table_id + AND t.database_id = sm.database_id + JOIN metaschema_public.schema s + ON s.id = t.schema_id + AND s.database_id = sm.database_id WHERE sm.database_id = $1 LIMIT 1 `;