diff --git a/packages/express-context/__tests__/loaders/auth-loaders.test.ts b/packages/express-context/__tests__/loaders/auth-loaders.test.ts new file mode 100644 index 0000000000..f09e8e31fe --- /dev/null +++ b/packages/express-context/__tests__/loaders/auth-loaders.test.ts @@ -0,0 +1,207 @@ +import type { Pool } from 'pg'; + +import { authSettingsLoader } from '../../src/loaders/auth-settings'; +import { authSurfaceLoader } from '../../src/loaders/auth-surface'; +import { + identityProvidersLoader, + requireIdentityProvider +} from '../../src/loaders/identity-providers'; +import type { LoaderContext } from '../../src/loaders/types'; +import type { IdentityProvidersModule } from '../../src/types'; + +interface Call { + text: string; + values?: unknown[]; +} + +/** + * A pool that answers each query in turn and records what it was asked. The + * loaders' whole job is issuing the right SQL with the right binding, so the + * calls are the assertion target. + */ +const fakePool = (responses: Array<{ rows: unknown[] }>) => { + const calls: Call[] = []; + let i = 0; + const pool = { + query: jest.fn(async (text: string, values?: unknown[]) => { + calls.push({ text, values }); + const next = responses[i++]; + if (!next) throw new Error(`unexpected query #${i}: ${text}`); + return next; + }) + } as unknown as Pool; + return { pool, calls }; +}; + +const ctx = (tenantPool: Pool, databaseId = 'db-1'): LoaderContext => ({ + routingPool: {} as Pool, + tenantPool, + databaseId, + dbname: 'tenant' +}); + +beforeEach(() => { + authSettingsLoader.invalidate(); + authSurfaceLoader.invalidate(); + identityProvidersLoader.invalidate(); +}); + +describe('authSettingsLoader', () => { + it('discovers the settings table for the context database only', async () => { + const { pool, calls } = fakePool([ + { rows: [{ schema_name: 'tenant_a_auth', table_name: 'auth_settings' }] }, + { rows: [{ cookie_secure: true, cookie_samesite: 'lax', cookie_path: '/' }] } + ]); + + const settings = await authSettingsLoader.resolve(ctx(pool, 'db-a')); + + expect(calls[0].values).toEqual(['db-a']); + expect(calls[0].text).toMatch(/WHERE sm\.database_id = \$1/); + // Step 2 reads out of the schema step 1 resolved — which is precisely why + // step 1 being keyed matters. + expect(calls[1].text).toContain('"tenant_a_auth"."auth_settings"'); + expect(settings).toMatchObject({ cookieSecure: true, cookieSamesite: 'lax' }); + }); + + it('is undefined when the tenant provisions no sessions module', async () => { + const { pool } = fakePool([{ rows: [] }]); + await expect(authSettingsLoader.resolve(ctx(pool))).resolves.toBeUndefined(); + }); + + it('refuses a context with no databaseId rather than running unkeyed', async () => { + const { pool, calls } = fakePool([]); + await expect(authSettingsLoader.resolve(ctx(pool, ''))).rejects.toThrow(/no databaseId/); + expect(calls).toHaveLength(0); + }); +}); + +describe('authSurfaceLoader', () => { + it('resolves the tenant auth schemas in one keyed round trip', async () => { + const { pool, calls } = fakePool([ + { + rows: [ + { + private_schema: 'tenant_a_auth_private', + public_schema: 'tenant_a_auth_public', + identifiers_public_schema: 'tenant_a_identifiers', + emails_table: 'emails', + connected_accounts_view: 'user_connected_accounts' + } + ] + } + ]); + + const surface = await authSurfaceLoader.resolve(ctx(pool, 'db-a')); + + expect(calls).toHaveLength(1); + expect(calls[0].values).toEqual(['db-a']); + expect(surface).toEqual({ + privateSchema: 'tenant_a_auth_private', + publicSchema: 'tenant_a_auth_public', + identifiersPublicSchema: 'tenant_a_identifiers', + emailsTable: 'emails', + connectedAccountsView: 'user_connected_accounts' + }); + }); + + it('joins the companion modules within the same tenant', async () => { + const { pool, calls } = fakePool([{ rows: [] }]); + await authSurfaceLoader.resolve(ctx(pool)); + // A join on schema id alone would pair one tenant's providers with + // another's connected accounts and still return a plausible row. + expect(calls[0].text).toMatch(/connected\.database_id = providers\.database_id/); + expect(calls[0].text).toMatch(/emails\.database_id = providers\.database_id/); + }); +}); + +describe('identityProvidersLoader', () => { + const providerRow: Record = { + id: 'p1', + slug: 'google', + kind: 'oidc', + display_name: 'Google', + enabled: true, + client_id: 'client-abc', + client_secret: 'shh', + authorization_url: null, + token_url: null, + userinfo_url: null, + issuer_url: 'https://accounts.google.com', + discovery_url_override: null, + discovery_doc: null, + jwks: null, + jwks_fetched_at: null, + acceptable_client_ids: null, + scopes: ['openid', 'email'], + extra_authorization_params: null, + email_optional: null, + allow_link_by_email: null, + skip_nonce_check: null, + pkce_enabled: null + }; + + const provisioned = (rows: unknown[]) => [ + { rows: [{ schema_name: 'tenant_a_auth_private', table_name: 'identity_providers' }] }, + { rows: [{ schema_name: 'tenant_a_secrets', table_name: 'internal_secrets' }] }, + { rows } + ]; + + it('keys both discovery steps and inlines the tenant secret getter', async () => { + const { pool, calls } = fakePool(provisioned([providerRow])); + + const module = await identityProvidersLoader.resolve(ctx(pool, 'db-a')); + + expect(calls[0].values).toEqual(['db-a']); + expect(calls[1].values).toEqual(['db-a']); + expect(calls[2].text).toContain('"tenant_a_secrets"."internal_secrets_get"'); + expect(calls[2].text).toContain('"tenant_a_auth_private"."identity_providers"'); + expect(module?.providers.google).toMatchObject({ + clientId: 'client-abc', + clientSecret: 'shh', + issuerUrl: 'https://accounts.google.com' + }); + }); + + it('defaults an unset nonce/PKCE policy to the safe side', async () => { + const { pool } = fakePool(provisioned([providerRow])); + const module = await identityProvidersLoader.resolve(ctx(pool)); + expect(module?.providers.google).toMatchObject({ skipNonceCheck: false, pkceEnabled: true }); + }); + + it('fails when the secret store is absent instead of yielding a secretless client', async () => { + const { pool } = fakePool([ + { rows: [{ schema_name: 'tenant_a_auth_private', table_name: 'identity_providers' }] }, + { rows: [] } + ]); + await expect(identityProvidersLoader.resolve(ctx(pool))).rejects.toThrow( + /internal_secrets_module/ + ); + }); + + it('is undefined when the tenant provisions no providers module', async () => { + const { pool } = fakePool([{ rows: [] }]); + await expect(identityProvidersLoader.resolve(ctx(pool))).resolves.toBeUndefined(); + }); + + it('rejects a provider row with no client_id', async () => { + const { pool } = fakePool(provisioned([{ ...providerRow, client_id: null }])); + await expect(identityProvidersLoader.resolve(ctx(pool))).rejects.toThrow(/client_id is not set/); + }); +}); + +describe('requireIdentityProvider', () => { + const module = (enabled: boolean): IdentityProvidersModule => ({ + providers: { google: { slug: 'google', enabled } as IdentityProvidersModule['providers'][string] }, + source: { schemaName: 's', tableName: 't' } + }); + + it('returns the provider when configured and enabled', () => { + expect(requireIdentityProvider(module(true), 'google').slug).toBe('google'); + }); + + it('distinguishes unprovisioned, unknown and disabled', () => { + expect(() => requireIdentityProvider(undefined, 'google')).toThrow(/not provisioned/); + expect(() => requireIdentityProvider(module(true), 'okta')).toThrow(/not configured/); + expect(() => requireIdentityProvider(module(false), 'google')).toThrow(/disabled/); + }); +}); diff --git a/packages/express-context/__tests__/loaders/tenant-keying.test.ts b/packages/express-context/__tests__/loaders/tenant-keying.test.ts new file mode 100644 index 0000000000..d5f3b7c9c2 --- /dev/null +++ b/packages/express-context/__tests__/loaders/tenant-keying.test.ts @@ -0,0 +1,61 @@ +/** + * The invariant behind constructive-planning#1403: every discovery query a + * loader runs against a *tenant* database must be keyed by that tenant. + * + * One serving database holds several tenants' schemas in the normal + * schema-per-tenant topology, so an unkeyed `metaschema_modules_public` lookup + * does not fail — it returns a neighbouring tenant's row, and the loader cache + * then serves that wrong answer for its whole TTL. That is a cross-tenant + * config read with no symptom, which is why it is asserted structurally here + * rather than left to a test of any one loader. + */ + +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; + +const LOADERS_DIR = join(__dirname, '..', '..', 'src', 'loaders'); + +/** Loader sources — the plumbing files carry no SQL. */ +const INFRASTRUCTURE = new Set(['create-loader.ts', 'index.ts', 'registry.ts', 'types.ts']); + +const loaderSources = readdirSync(LOADERS_DIR) + .filter(f => f.endsWith('.ts') && !INFRASTRUCTURE.has(f)) + .map(file => ({ file, src: readFileSync(join(LOADERS_DIR, file), 'utf8') })); + +/** + * Every `SELECT ... FROM metaschema_modules_public. ...` in the source, + * sliced from FROM to the end of the template literal it lives in. + */ +const moduleQueries = ({ file, src }: { file: string; src: string }) => { + const matches = [...src.matchAll(/FROM\s+metaschema_modules_public\.(\w+)([\s\S]*?)`/g)]; + return matches.map(m => ({ file, module: m[1], body: m[2] })); +}; + +describe('tenant-DB discovery is keyed by database_id', () => { + it('finds the module discovery queries it is meant to be checking', () => { + // A regex that silently matches nothing would make every assertion below + // vacuously true — including for a loader added later. + const all = loaderSources.flatMap(moduleQueries); + expect(all.length).toBeGreaterThanOrEqual(6); + expect(new Set(all.map(q => q.module))).toContain('sessions_module'); + }); + + it.each(loaderSources.flatMap(moduleQueries))( + '$file: $module is filtered by database_id', + ({ body }) => { + expect(body).toMatch(/WHERE[\s\S]*\bdatabase_id\s*=\s*\$1/); + } + ); + + it('binds a parameter to every parameterised discovery query', () => { + for (const { file, src } of loaderSources) { + if (!/\$1/.test(src)) continue; + // `$1` in the SQL with no second argument at the call site is a runtime + // error, not a type error — pg accepts `query(text)` happily. + expect({ file, passesParams: /query<[^>]*>\([\s\S]*?,\s*\[/.test(src) }).toEqual({ + file, + passesParams: true + }); + } + }); +}); diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index dd98c85077..013e195f5d 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -42,6 +42,7 @@ export type { ApiError, ApiStructure, AuthSettings, + AuthSurface, BillingConfig, BuiltinModuleMap, ComputeConfig, @@ -49,6 +50,8 @@ export type { ConstructiveAPIToken, ConstructiveContext, DatabaseSettings, + IdentityProviderConfig, + IdentityProvidersModule, InferenceLogConfig, LlmConfig, PubkeyChallengeSettings, @@ -85,6 +88,7 @@ export type { export { agentChatLoader, authSettingsLoader, + authSurfaceLoader, billingLoader, computeLoader, corsLoader, @@ -92,9 +96,12 @@ export { createLoaderRegistry, createModuleLoader, databaseSettingsLoader, + identityProvidersLoader, inferenceLogLoader, llmLoader, pubkeyLoader, + requireDatabaseId, + requireIdentityProvider, rlsLoader, webauthnLoader, } from './loaders'; diff --git a/packages/express-context/src/loaders/agent-chat.ts b/packages/express-context/src/loaders/agent-chat.ts index 14050a4d92..7138dee58f 100644 --- a/packages/express-context/src/loaders/agent-chat.ts +++ b/packages/express-context/src/loaders/agent-chat.ts @@ -3,11 +3,16 @@ * * Resolves per-database agent chat config from metaschema_modules_public.agent_chat_module. * Returns the schema and table names for threads, messages, and tasks. + * + * Keyed by `database_id` like every other module lookup: one serving database + * holds several tenants' schemas, so an unkeyed discovery resolves a + * neighbouring tenant's tables instead of failing. */ import type { AgentChatConfig } from '../types'; import { createModuleLoader } from './create-loader'; import type { LoaderContext, ModuleLoader } from './types'; +import { requireDatabaseId } from './types'; // ─── SQL ──────────────────────────────────────────────────────────────────── @@ -19,6 +24,7 @@ const AGENT_CHAT_MODULE_SQL = ` acm.task_table_name FROM metaschema_modules_public.agent_chat_module acm JOIN metaschema_public.schema s ON s.id = acm.schema_id + WHERE acm.database_id = $1 LIMIT 1 `; @@ -37,10 +43,12 @@ export const agentChatLoader: ModuleLoader = createModuleLoader name: 'agentChat', ttlMs: 60_000, async resolve(ctx: LoaderContext) { - const { tenantPool } = ctx; + const { tenantPool, databaseId } = ctx; + requireDatabaseId(databaseId, 'agentChat'); const result = await tenantPool.query( AGENT_CHAT_MODULE_SQL, + [databaseId], ); const row = result.rows[0]; if (!row) return undefined; diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index 2b0a7c505d..e5734cc603 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -8,11 +8,18 @@ * * This is the pattern for any module whose config lives in the tenant * database rather than the routing database. + * + * Discovery is keyed by `ctx.databaseId`. One serving database holds several + * tenants' schemas in the normal schema-per-tenant topology, so an unfiltered + * discovery returns whichever row the planner reaches first and step 2 then + * reads a *neighbouring tenant's* cookie/captcha policy — and the loader cache + * makes that resolution sticky for its TTL. */ import type { AuthSettings } from '../types'; import { createModuleLoader } from './create-loader'; import type { LoaderContext, ModuleLoader } from './types'; +import { requireDatabaseId } from './types'; // ─── SQL ──────────────────────────────────────────────────────────────────── @@ -20,6 +27,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 `; @@ -58,11 +66,13 @@ export const authSettingsLoader: ModuleLoader = createModuleLoader name: 'authSettings', ttlMs: 5 * 60_000, async resolve(ctx: LoaderContext) { - const { tenantPool } = ctx; + const { tenantPool, databaseId } = ctx; + requireDatabaseId(databaseId, 'authSettings'); // 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/auth-surface.ts b/packages/express-context/src/loaders/auth-surface.ts new file mode 100644 index 0000000000..0ca09fb1b1 --- /dev/null +++ b/packages/express-context/src/loaders/auth-surface.ts @@ -0,0 +1,89 @@ +/** + * Auth Surface Loader (Tier 2 — tenant DB) + * + * Where a tenant's auth surface physically lives: the schemas holding the + * generated identity procedures, and the physical names of the identifier + * relations a caller reads back. + * + * This is platform knowledge, not application logic. Schema and table names + * carry the tenant's provisioning prefix and scope, so two tenants in the same + * cluster disagree about them and no consumer can hardcode them — every + * consumer that touches an auth row was hand-writing this same query first + * (constructive-planning#1414), and each hand-written copy is another chance to + * key discovery wrong, where wrong means a cross-tenant read rather than a + * crash. + * + * Procedure *names* are fixed by the generators that emit them + * (`sign_in_identity`, `sign_up_identity`, `verify_idp`, `link_identity`); only + * their schemas vary, which is why only schemas are resolved here. + */ + +import type { AuthSurface } from '../types'; +import { createModuleLoader } from './create-loader'; +import type { LoaderContext, ModuleLoader } from './types'; +import { requireDatabaseId } from './types'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +/** + * One round trip for the whole surface. + * + * `identity_providers_module` anchors the auth schemas and + * `connected_accounts_module` the identifier schemas; the emails relation name + * comes from `emails_module`. The joins between them are on `database_id` for + * the same reason the outer filter is: they must land in the *same* tenant. + */ +const AUTH_SURFACE_SQL = ` + SELECT + auth_private.schema_name AS private_schema, + auth_public.schema_name AS public_schema, + identifiers_public.schema_name AS identifiers_public_schema, + emails.table_name AS emails_table, + 'user_' || connected.table_name AS connected_accounts_view + FROM metaschema_modules_public.identity_providers_module providers + JOIN metaschema_public.schema auth_private ON auth_private.id = providers.private_schema_id + JOIN metaschema_public.schema auth_public ON auth_public.id = providers.schema_id + JOIN metaschema_modules_public.connected_accounts_module connected + ON connected.database_id = providers.database_id + JOIN metaschema_public.schema identifiers_public ON identifiers_public.id = connected.schema_id + JOIN metaschema_modules_public.emails_module emails + ON emails.database_id = providers.database_id + WHERE providers.database_id = $1 + LIMIT 1 +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface AuthSurfaceRow { + private_schema: string; + public_schema: string; + identifiers_public_schema: string; + emails_table: string; + connected_accounts_view: string; +} + +// ─── Loader ───────────────────────────────────────────────────────────────── + +export const authSurfaceLoader: ModuleLoader = createModuleLoader({ + name: 'authSurface', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + requireDatabaseId(databaseId, 'authSurface'); + + const result = await tenantPool.query(AUTH_SURFACE_SQL, [databaseId]); + const row = result.rows[0]; + // Absent identity modules mean this tenant has no auth surface, which is + // the loader contract's "not provisioned" — the caller decides whether that + // is fatal for the route it is serving. + if (!row) return undefined; + + return { + privateSchema: row.private_schema, + publicSchema: row.public_schema, + identifiersPublicSchema: row.identifiers_public_schema, + emailsTable: row.emails_table, + connectedAccountsView: row.connected_accounts_view + }; + } +}); 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..837a8292e3 --- /dev/null +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -0,0 +1,240 @@ +/** + * Identity Providers Loader (Tier 2 — tenant DB) + * + * Per-tenant OIDC/OAuth provider configuration: client id, client secret, + * endpoints, scopes and the linking policy. This is tenant *data*, not + * deployment config — a second tenant in the same process has different + * providers, and an env var cannot express that, so nothing here reads + * `process.env`. + * + * Which fields a provider config must carry is platform knowledge too. Deriving + * the shape per integration is how one of them ends up not checking `nonce` + * (constructive-planning#1414), so the field set — issuer, JWKS, acceptable + * audiences, `skipNonceCheck`, `pkceEnabled` — is fixed here rather than + * rediscovered. + * + * NOT registered in the default registry: it costs three round trips and + * decrypts secrets, so it is opt-in for the services that actually serve an + * auth flow. + * + * registry.register(identityProvidersLoader); + */ + +import type { IdentityProviderConfig, IdentityProvidersModule } from '../types'; +import { createModuleLoader } from './create-loader'; +import type { LoaderContext, ModuleLoader } from './types'; +import { requireDatabaseId } from './types'; + +// ─── SQL ──────────────────────────────────────────────────────────────────── + +const IDENTITY_PROVIDERS_DISCOVERY_SQL = ` + SELECT s.schema_name AS schema_name, m.table_name AS table_name + FROM metaschema_modules_public.identity_providers_module m + JOIN metaschema_public.schema s ON s.id = m.private_schema_id + WHERE m.database_id = $1 + LIMIT 1 +`; + +const INTERNAL_SECRETS_DISCOVERY_SQL = ` + SELECT s.schema_name AS schema_name, m.internal_secrets_table_name AS table_name + FROM metaschema_modules_public.internal_secrets_module m + JOIN metaschema_public.schema s ON s.id = m.private_schema_id + WHERE m.database_id = $1 + LIMIT 1 +`; + +interface DiscoveredLocation { + schema_name: string; + table_name: string; +} + +/** + * The providers query, with the tenant's own secret getter inlined. + * + * The getter is `_get(name, namespace_id)` in the + * discovered store schema — the same function the auth procedures use, so a + * secret rotated through the platform's rotate verb is picked up with no + * further coordination. A provider whose `client_secret_id` is set but whose + * secret does not resolve yields `clientSecret: null`, which the caller must + * treat as a configuration fault rather than as a public client. + */ +const buildProvidersQuery = (providers: DiscoveredLocation, secrets: DiscoveredLocation) => ` + SELECT + p.id, + p.slug, + p.kind, + p.display_name, + p.enabled, + p.client_id, + CASE + WHEN p.client_secret_id IS NULL THEN NULL + ELSE "${secrets.schema_name}"."${secrets.table_name}_get"( + p.slug || '/client-secret', + uuid_nil() + ) + END AS client_secret, + p.authorization_url, + p.token_url, + p.userinfo_url, + p.issuer_url, + p.discovery_url_override, + p.discovery_doc, + p.jwks, + p.jwks_fetched_at, + p.acceptable_client_ids, + p.scopes, + p.extra_authorization_params, + p.email_optional, + p.allow_link_by_email, + p.skip_nonce_check, + p.pkce_enabled + FROM "${providers.schema_name}"."${providers.table_name}" p +`; + +// ─── Row Types ────────────────────────────────────────────────────────────── + +interface ProviderRow { + id: string; + slug: string; + kind: string; + display_name: string | null; + enabled: boolean; + client_id: string | null; + client_secret: string | null; + authorization_url: string | null; + token_url: string | null; + userinfo_url: string | null; + issuer_url: string | null; + discovery_url_override: string | null; + discovery_doc: Record | null; + jwks: Record | null; + jwks_fetched_at: Date | null; + acceptable_client_ids: string[] | null; + scopes: string[] | null; + extra_authorization_params: Record | null; + email_optional: boolean | null; + allow_link_by_email: boolean | null; + skip_nonce_check: boolean | null; + pkce_enabled: boolean | null; +} + +// ─── Transforms ───────────────────────────────────────────────────────────── + +const toProviderConfig = (row: ProviderRow): IdentityProviderConfig => { + if (!row.client_id) { + throw new Error(`identity provider "${row.slug}": client_id is not set`); + } + return { + id: row.id, + slug: row.slug, + kind: row.kind, + displayName: row.display_name ?? row.slug, + enabled: row.enabled, + clientId: row.client_id, + clientSecret: row.client_secret, + authorizationUrl: row.authorization_url, + tokenUrl: row.token_url, + userinfoUrl: row.userinfo_url, + issuerUrl: row.issuer_url, + discoveryUrlOverride: row.discovery_url_override, + discoveryDoc: row.discovery_doc, + jwks: row.jwks, + jwksFetchedAt: row.jwks_fetched_at, + acceptableClientIds: row.acceptable_client_ids ?? [], + scopes: row.scopes ?? [], + extraAuthorizationParams: row.extra_authorization_params ?? {}, + emailOptional: row.email_optional ?? false, + allowLinkByEmail: row.allow_link_by_email ?? false, + // Both default to the safe side: a config that does not say otherwise gets + // nonce checking and PKCE. + skipNonceCheck: row.skip_nonce_check ?? false, + pkceEnabled: row.pkce_enabled ?? true + }; +}; + +const discoverOne = async ( + ctx: LoaderContext, + sql: string, + moduleName: string +): Promise => { + const result = await ctx.tenantPool.query(sql, [ctx.databaseId]); + const row = result.rows[0]; + if (!row?.schema_name || !row?.table_name) { + // Not provisioned for this tenant — the loader contract's undefined. The + // module name is kept in the debug trail rather than guessed at by callers. + return undefined; + } + return { schema_name: row.schema_name, table_name: row.table_name }; +}; + +// ─── Loader ───────────────────────────────────────────────────────────────── + +/** + * Short TTL on purpose: an operator disabling a provider or rotating a secret + * expects the next sign-in attempt to see it, and the whole set is one query. + */ +export const identityProvidersLoader: ModuleLoader = + createModuleLoader({ + name: 'identityProviders', + ttlMs: 30_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + requireDatabaseId(databaseId, 'identityProviders'); + + const providers = await discoverOne( + ctx, + IDENTITY_PROVIDERS_DISCOVERY_SQL, + 'identity_providers_module' + ); + if (!providers) return undefined; + + const secrets = await discoverOne( + ctx, + INTERNAL_SECRETS_DISCOVERY_SQL, + 'internal_secrets_module' + ); + // A provider table without its secret store cannot yield a usable client + // secret, and silently returning secret-less providers would present a + // confidential client as a public one. + if (!secrets) { + throw new Error( + `identityProviders: database ${databaseId} provisions identity_providers_module ` + + 'but not internal_secrets_module, so client secrets cannot be resolved' + ); + } + + const result = await tenantPool.query(buildProvidersQuery(providers, secrets)); + + const bySlug: Record = {}; + for (const row of result.rows) { + bySlug[row.slug] = toProviderConfig(row); + } + + return { + providers: bySlug, + source: { schemaName: providers.schema_name, tableName: providers.table_name } + }; + } + }); + +/** + * Pick one provider by slug, failing loud on unknown or disabled providers — a + * start leg for a provider the tenant never configured is a 404, not a redirect + * to a half-built authorize URL. + */ +export function requireIdentityProvider( + module: IdentityProvidersModule | undefined, + slug: string +): IdentityProviderConfig { + if (!module) { + throw new Error('identityProviders: module is not provisioned for this database'); + } + const provider = module.providers[slug]; + if (!provider) { + throw new Error(`identity provider "${slug}" is not configured`); + } + if (!provider.enabled) { + throw new Error(`identity provider "${slug}" is disabled`); + } + return provider; +} diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index a1fcbb424c..a8a3203428 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) + * - authSurface (identity/connected-accounts/emails modules → tenant DB) + * + * Opt-in (not in the default registry, register it explicitly): + * - identityProviders (three round trips, decrypts client secrets) * * To add a new per-db lookup, implement a ModuleLoader and register it: * @@ -28,6 +32,7 @@ // Core types export type { LoaderContext, ModuleLoader } from './types'; +export { requireDatabaseId } from './types'; // Factory export type { CreateLoaderOptions } from './create-loader'; @@ -40,10 +45,12 @@ export { createLoaderRegistry } from './registry'; // Built-in loaders export { agentChatLoader } from './agent-chat'; export { authSettingsLoader } from './auth-settings'; +export { authSurfaceLoader } from './auth-surface'; export { billingLoader } from './billing'; export { computeLoader } from './compute'; export { corsLoader } from './cors'; export { databaseSettingsLoader } from './database-settings'; +export { identityProvidersLoader, requireIdentityProvider } from './identity-providers'; export { inferenceLogLoader } from './inference-log'; export { llmLoader } from './llm'; export { pubkeyLoader } from './pubkey'; @@ -55,6 +62,7 @@ export { webauthnLoader } from './webauthn'; */ import { agentChatLoader } from './agent-chat'; import { authSettingsLoader } from './auth-settings'; +import { authSurfaceLoader } from './auth-surface'; import { billingLoader } from './billing'; import { computeLoader } from './compute'; import { corsLoader } from './cors'; @@ -74,6 +82,7 @@ export function createDefaultRegistry() { registry.register(pubkeyLoader); registry.register(webauthnLoader); registry.register(authSettingsLoader); + registry.register(authSurfaceLoader); registry.register(billingLoader); registry.register(inferenceLogLoader); registry.register(agentChatLoader); diff --git a/packages/express-context/src/loaders/types.ts b/packages/express-context/src/loaders/types.ts index 096967668b..cec903d7b3 100644 --- a/packages/express-context/src/loaders/types.ts +++ b/packages/express-context/src/loaders/types.ts @@ -30,6 +30,24 @@ export const routingSchemaOf = ( return schema; }; +/** + * Assert a loader was handed the tenant it is resolving for. + * + * Every tenant-DB discovery query keys on `database_id`, because one serving + * database holds several tenants' schemas: without the key the query returns an + * arbitrary tenant's row, which is a cross-tenant config read rather than a + * crash. A context with no `databaseId` is therefore a wiring fault, and must + * fail here rather than degrade into an unkeyed query. + */ +export function requireDatabaseId( + databaseId: string | undefined, + loaderName: string +): asserts databaseId is string { + if (!databaseId) { + throw new Error(`loader ${loaderName}: context carries no databaseId`); + } +} + /** * Context passed to every loader's resolve function. * Provides both pool references so the loader can query whichever diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index ab77655d8b..4316018209 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -78,6 +78,62 @@ export interface AuthSettings { captchaSiteKey?: string | null; } +/** + * Where a tenant's auth surface physically lives. + * + * Only schemas and relation names — the generated procedure names + * (`sign_in_identity`, `sign_up_identity`, `verify_idp`, `link_identity`) are + * fixed by the generators that emit them. + */ +export interface AuthSurface { + /** Schema holding `sign_in_identity` / `sign_up_identity` / `verify_idp`. */ + privateSchema: string; + /** Schema holding `link_identity` (an authenticated user action). */ + publicSchema: string; + /** Schema holding the user-facing identifier views (`emails`, connected accounts). */ + identifiersPublicSchema: string; + /** Physical name of the emails relation in that schema. */ + emailsTable: string; + /** Physical name of the connected-accounts relation the owner can read. */ + connectedAccountsView: string; +} + +/** One identity provider row, with its client secret resolved. */ +export interface IdentityProviderConfig { + id: string; + /** The `:provider` path segment. */ + slug: string; + kind: string; + displayName: string; + enabled: boolean; + clientId: string; + clientSecret: string | null; + authorizationUrl: string | null; + tokenUrl: string | null; + userinfoUrl: string | null; + issuerUrl: string | null; + discoveryUrlOverride: string | null; + discoveryDoc: Record | null; + /** Cached JWKS for id_token verification, refreshed by the callback leg. */ + jwks: Record | null; + jwksFetchedAt: Date | null; + /** Additional audiences accepted in an id_token (native app clients). */ + acceptableClientIds: string[]; + scopes: string[]; + extraAuthorizationParams: Record; + emailOptional: boolean; + allowLinkByEmail: boolean; + skipNonceCheck: boolean; + pkceEnabled: boolean; +} + +export interface IdentityProvidersModule { + /** Keyed by slug. */ + providers: Record; + /** Physical location the providers were read from, for error context. */ + source: { schemaName: string; tableName: string }; +} + export interface ApiStructure { apiId?: string; dbname: string; @@ -184,6 +240,8 @@ export interface BuiltinModuleMap { corsOrigins: string[]; databaseSettings: DatabaseSettings; authSettings: AuthSettings; + authSurface: AuthSurface; + identityProviders: IdentityProvidersModule; pubkeyChallengeSettings: PubkeyChallengeSettings; webauthnSettings: WebauthnSettings; billing: BillingConfig;