From d8c28bc3dff4dd533d20b96a174e8000dd9da307 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Sun, 9 Aug 2026 19:39:56 +0800 Subject: [PATCH 01/11] feat: add OAuth server config and errors --- graphql/env/README.md | 12 ++ .../__snapshots__/merge.test.ts.snap | 4 + graphql/env/__tests__/merge.test.ts | 103 ++++++++++++++++++ graphql/env/src/env.ts | 4 + graphql/env/src/index.ts | 5 + graphql/env/src/merge.ts | 9 +- graphql/env/src/oauth.ts | 74 +++++++++++++ graphql/types/README.md | 10 ++ graphql/types/src/constructive.ts | 8 +- graphql/types/src/index.ts | 5 + graphql/types/src/oauth.ts | 17 +++ packages/errors/README.md | 3 + packages/errors/__tests__/parse.test.ts | 4 +- packages/errors/__tests__/sso.test.ts | 39 +++++++ packages/errors/src/error.ts | 7 +- packages/errors/src/factory.ts | 32 ++++-- packages/errors/src/parse.ts | 3 +- packages/errors/src/registry.ts | 84 ++++++++++++++ 18 files changed, 410 insertions(+), 13 deletions(-) create mode 100644 graphql/env/src/oauth.ts create mode 100644 graphql/types/src/oauth.ts create mode 100644 packages/errors/__tests__/sso.test.ts diff --git a/graphql/env/README.md b/graphql/env/README.md index e5084a59d8..c31dd26031 100644 --- a/graphql/env/README.md +++ b/graphql/env/README.md @@ -57,6 +57,14 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag - `API_ANON_ROLE` - Anonymous role name - `API_ROLE_NAME` - Default role name +### OAuth Server +- `OAUTH_ENABLED` - Explicitly enable the unified-auth Provider flow (default: `false`) +- `OAUTH_PROVIDER_REQUEST_TIMEOUT_MS` - Per-request Provider timeout in milliseconds (default: `10000`, maximum: `60000`) + +Provider endpoints, client IDs, secrets, scopes, and policy are Tenant data; +they are not process environment variables. Explicit malformed OAuth values +fail during option resolution instead of falling back silently. + ## Defaults GraphQL defaults are provided by `@constructive-io/graphql-types`: @@ -76,6 +84,10 @@ GraphQL defaults are provided by `@constructive-io/graphql-types`: isPublic: true, metaSchemas: ['routing_public', 'metaschema_public', 'metaschema_modules_public'], routingSchema: 'routing_public' + }, + oauth: { + enabled: false, + providerRequestTimeoutMs: 10000 } } ``` diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index 6383de2044..ace18ac67b 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -80,6 +80,10 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "useTx": false, }, }, + "oauth": { + "enabled": false, + "providerRequestTimeoutMs": 10000, + }, "pg": { "database": "config-db", "host": "override-host", diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index fa7dd645e8..f16d841a2f 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import { getGraphQLEnvVars } from '../src/env'; import { getEnvOptions } from '../src/merge'; +import { OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS } from '../src/oauth'; const writeConfig = (dir: string, config: Record): void => { fs.writeFileSync(path.join(dir, 'pgpm.json'), JSON.stringify(config, null, 2)); @@ -230,6 +231,108 @@ describe('getEnvOptions', () => { expect(result.sms).toBeUndefined(); }); + it('defaults OAuth off with a ten-second Provider timeout', () => { + expect(getEnvOptions({}, process.cwd(), {}).oauth).toEqual({ + enabled: false, + providerRequestTimeoutMs: 10_000 + }); + }); + + it('keeps absent OAuth environment variables out of partial overrides', () => { + expect(getGraphQLEnvVars({})).not.toHaveProperty('oauth'); + }); + + it('parses explicit OAuth environment overrides', () => { + expect( + getGraphQLEnvVars({ + OAUTH_ENABLED: 'true', + OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: '2500' + }).oauth + ).toEqual({ + enabled: true, + providerRequestTimeoutMs: 2500 + }); + }); + + it('preserves config OAuth enablement when environment overrides are absent', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graphql-env-oauth-')); + writeConfig(tempDir, { + oauth: { + enabled: true, + providerRequestTimeoutMs: 8000 + } + }); + + expect(getEnvOptions({}, tempDir, {}).oauth).toEqual({ + enabled: true, + providerRequestTimeoutMs: 8000 + }); + }); + + it('honors config, env, and runtime priority for OAuth', () => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'graphql-env-oauth-priority-') + ); + writeConfig(tempDir, { + oauth: { + enabled: false, + providerRequestTimeoutMs: 5000 + } + }); + + const result = getEnvOptions( + { oauth: { providerRequestTimeoutMs: 9000 } }, + tempDir, + { OAUTH_ENABLED: 'true', OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: '7000' } + ); + + expect(result.oauth).toEqual({ + enabled: true, + providerRequestTimeoutMs: 9000 + }); + }); + + it.each(['not-a-boolean', '', 'enabled'])( + 'rejects an explicitly malformed OAuth enabled value %p', + value => { + expect(() => getGraphQLEnvVars({ OAUTH_ENABLED: value })).toThrow( + /OAUTH_ENABLED/ + ); + } + ); + + it.each([ + 'not-a-number', + '0', + '-1', + '1.5', + String(OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS + 1) + ])('rejects an invalid OAuth Provider timeout %p', value => { + expect(() => + getEnvOptions({}, process.cwd(), { + OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: value + }) + ).toThrow(/providerRequestTimeoutMs|OAUTH_PROVIDER_REQUEST_TIMEOUT_MS/); + }); + + it('rejects invalid OAuth config and runtime override types after merging', () => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'graphql-env-oauth-invalid-') + ); + writeConfig(tempDir, { oauth: { enabled: 'yes' } }); + + expect(() => getEnvOptions({}, tempDir, {})).toThrow( + /oauth.enabled must be a boolean/ + ); + expect(() => + getEnvOptions( + { oauth: { providerRequestTimeoutMs: 60_001 } }, + process.cwd(), + {} + ) + ).toThrow(/providerRequestTimeoutMs/); + }); + it('omits an invalid SMS timeout from partial env overrides', () => { const result = getGraphQLEnvVars({ SMS_REQUEST_TIMEOUT_MS: '5s' diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 014924ef24..358d32c600 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -1,6 +1,8 @@ import { ConstructiveOptions } from '@constructive-io/graphql-types'; import { parseEnvBoolean, parseEnvNumber } from '12factor-env'; +import { getOAuthEnvVars } from './oauth'; + /** * @param env - Environment object to read from (defaults to process.env for backwards compatibility) */ @@ -38,6 +40,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial // let an absent env var overwrite pgpm.json or consumer-specific values. const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS); const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN); + const oauth = getOAuthEnvVars(env); const hasSmsEnvOverrides = Boolean( SMS_PROVIDER || SMS_SENDER_ID || @@ -67,6 +70,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial ...(API_ANON_ROLE && { anonRole: API_ANON_ROLE }), ...(API_ROLE_NAME && { roleName: API_ROLE_NAME }) }, + ...(oauth && { oauth }), ...((EMBEDDER_PROVIDER || CHAT_PROVIDER) && { llm: { ...((EMBEDDER_PROVIDER || EMBEDDER_MODEL || EMBEDDER_BASE_URL) && { diff --git a/graphql/env/src/index.ts b/graphql/env/src/index.ts index 50627b7d54..6fb27c2f29 100644 --- a/graphql/env/src/index.ts +++ b/graphql/env/src/index.ts @@ -1,4 +1,9 @@ // Export Constructive-specific env functions export { getGraphQLEnvVars } from './env'; export { getConstructiveEnvOptions,getEnvOptions } from './merge'; +export { + getOAuthEnvVars, + OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS, + validateOAuthServerOptions +} from './oauth'; export type { DevSmsOptions, SmsOptions } from '@constructive-io/graphql-types'; diff --git a/graphql/env/src/merge.ts b/graphql/env/src/merge.ts index 15f1402c53..a371cfc569 100644 --- a/graphql/env/src/merge.ts +++ b/graphql/env/src/merge.ts @@ -3,6 +3,7 @@ import { getEnvOptions as getPgpmEnvOptions, loadConfigSync, replaceArrays } fro import deepmerge from 'deepmerge'; import { getGraphQLEnvVars } from './env'; +import { validateOAuthServerOptions } from './oauth'; /** * Get Constructive environment options by merging: @@ -36,7 +37,7 @@ export const getEnvOptions = ( const configOptions = loadConfigSync(cwd) as Partial; // Merge in order: core -> graphql defaults -> config (for graphql keys) -> graphql env -> overrides - return deepmerge.all([ + const merged = deepmerge.all([ coreOptions, constructiveGraphqlDefaults, // Only merge graphql-related keys from config (if present) @@ -44,6 +45,7 @@ export const getEnvOptions = ( ...(configOptions.graphile && { graphile: configOptions.graphile }), ...(configOptions.features && { features: configOptions.features }), ...(configOptions.api && { api: configOptions.api }), + ...(configOptions.oauth && { oauth: configOptions.oauth }), ...(configOptions.sms && { sms: configOptions.sms }), }, graphqlEnvOptions, @@ -51,6 +53,11 @@ export const getEnvOptions = ( ], { arrayMerge: replaceArrays }) as ConstructiveOptions; + + return { + ...merged, + oauth: validateOAuthServerOptions(merged.oauth) + }; }; /** diff --git a/graphql/env/src/oauth.ts b/graphql/env/src/oauth.ts new file mode 100644 index 0000000000..4262e2f578 --- /dev/null +++ b/graphql/env/src/oauth.ts @@ -0,0 +1,74 @@ +import { + oauthServerDefaults, + type OAuthServerOptions +} from '@constructive-io/graphql-types'; +import { bool, env as validateEnv, EnvError, num } from '12factor-env'; + +export const OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS = 60_000; + +const assertProviderRequestTimeout = (value: unknown): number => { + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + value <= 0 || + value > OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS + ) { + throw new EnvError( + `oauth.providerRequestTimeoutMs must be an integer between 1 and ${OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS}` + ); + } + return value; +}; + +/** Parse only explicitly supplied OAuth environment overrides. */ +export const getOAuthEnvVars = ( + input: NodeJS.ProcessEnv +): OAuthServerOptions | undefined => { + const overrides: OAuthServerOptions = {}; + let configured = false; + + if (input.OAUTH_ENABLED !== undefined) { + const parsed = validateEnv( + { OAUTH_ENABLED: input.OAUTH_ENABLED }, + {}, + { OAUTH_ENABLED: bool() } + ); + overrides.enabled = parsed.OAUTH_ENABLED; + configured = true; + } + + if (input.OAUTH_PROVIDER_REQUEST_TIMEOUT_MS !== undefined) { + const parsed = validateEnv( + { + OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: + input.OAUTH_PROVIDER_REQUEST_TIMEOUT_MS + }, + {}, + { OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: num() } + ); + overrides.providerRequestTimeoutMs = assertProviderRequestTimeout( + parsed.OAUTH_PROVIDER_REQUEST_TIMEOUT_MS + ); + configured = true; + } + + return configured ? overrides : undefined; +}; + +/** Validate and complete the effective OAuth options after all merge layers. */ +export const validateOAuthServerOptions = ( + input: OAuthServerOptions | undefined +): Required => { + const enabled = input?.enabled ?? oauthServerDefaults.enabled; + if (typeof enabled !== 'boolean') { + throw new EnvError('oauth.enabled must be a boolean'); + } + + return { + enabled, + providerRequestTimeoutMs: assertProviderRequestTimeout( + input?.providerRequestTimeoutMs ?? + oauthServerDefaults.providerRequestTimeoutMs + ) + }; +}; diff --git a/graphql/types/README.md b/graphql/types/README.md index e071cedb40..5160fec847 100644 --- a/graphql/types/README.md +++ b/graphql/types/README.md @@ -47,6 +47,10 @@ const config: ConstructiveOptions = { simpleInflection: true, postgis: true, }, + oauth: { + enabled: false, + providerRequestTimeoutMs: 10_000, + }, }; ``` @@ -68,6 +72,12 @@ Configuration for the Constructive API including meta API settings, exposed sche Feature flags for GraphQL/Graphile including inflection settings and PostGIS support. +### OAuthServerOptions + +GraphQL-server-owned OAuth enablement and bounded Provider request timeout. +Provider credentials and endpoint configuration remain Tenant data and are not +part of this type. + ## Re-exports This package re-exports all types from `@pgpmjs/types` for convenience, so you can import both core PGPM types and GraphQL types from a single package. diff --git a/graphql/types/src/constructive.ts b/graphql/types/src/constructive.ts index 485a4f4a59..cb88edbdf5 100644 --- a/graphql/types/src/constructive.ts +++ b/graphql/types/src/constructive.ts @@ -17,6 +17,7 @@ import { GraphileFeatureOptions, GraphileOptions} from './graphile'; import { LlmOptions } from './llm'; +import { oauthServerDefaults, type OAuthServerOptions } from './oauth'; import { SmsOptions } from './sms'; /** @@ -29,6 +30,8 @@ export interface ConstructiveGraphQLOptions { features?: GraphileFeatureOptions; /** API configuration options */ api?: ApiOptions; + /** GraphQL server OAuth feature and transport options */ + oauth?: OAuthServerOptions; } /** @@ -58,6 +61,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt llm?: LlmOptions; /** SMS provider configuration */ sms?: SmsOptions; + /** GraphQL server OAuth feature and transport options */ + oauth?: OAuthServerOptions; } /** @@ -66,7 +71,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt export const constructiveGraphqlDefaults: ConstructiveGraphQLOptions = { graphile: graphileDefaults, features: graphileFeatureDefaults, - api: apiDefaults + api: apiDefaults, + oauth: oauthServerDefaults }; /** diff --git a/graphql/types/src/index.ts b/graphql/types/src/index.ts index 895604e137..dd8eaa6fb7 100644 --- a/graphql/types/src/index.ts +++ b/graphql/types/src/index.ts @@ -27,6 +27,11 @@ export { LlmEmbedderOptions, LlmOptions} from './llm'; +// Export GraphQL-server OAuth options +export { + oauthServerDefaults, + type OAuthServerOptions} from './oauth'; + // Export SMS types export { DevSmsOptions, diff --git a/graphql/types/src/oauth.ts b/graphql/types/src/oauth.ts new file mode 100644 index 0000000000..2b52a2003b --- /dev/null +++ b/graphql/types/src/oauth.ts @@ -0,0 +1,17 @@ +/** + * GraphQL-server-owned OAuth runtime options. + * + * Provider endpoints, credentials, scopes, and policy remain Tenant data and + * are deliberately absent from this process-level configuration surface. + */ +export interface OAuthServerOptions { + /** Explicitly enables the unified-auth Provider flow. */ + enabled?: boolean; + /** Maximum duration of one outbound Provider HTTP request. */ + providerRequestTimeoutMs?: number; +} + +export const oauthServerDefaults: Required = { + enabled: false, + providerRequestTimeoutMs: 10_000 +}; diff --git a/packages/errors/README.md b/packages/errors/README.md index 4668324e57..5ad2b283c6 100644 --- a/packages/errors/README.md +++ b/packages/errors/README.md @@ -30,6 +30,9 @@ if (parsed.class === 'public') { // Throw a structured error throw errors.ACCOUNT_EXISTS(); + +// Preserve an internal cause without exposing it in transport extensions +throw errors.INVALID_OAUTH_STATE(undefined, undefined, { cause: caught }); ``` ## Design notes diff --git a/packages/errors/__tests__/parse.test.ts b/packages/errors/__tests__/parse.test.ts index da301fd44e..f1fbf0dc89 100644 --- a/packages/errors/__tests__/parse.test.ts +++ b/packages/errors/__tests__/parse.test.ts @@ -126,10 +126,12 @@ describe('toError', () => { }); it('falls back to UNKNOWN_ERROR and the raw message for unresolved errors', () => { - const err = toError(new Error('totally opaque failure')); + const original = new Error('totally opaque failure'); + const err = toError(original); expect(err.code).toBe('UNKNOWN_ERROR'); expect(err.errorClass).toBe('internal'); expect(err.message).toBe('totally opaque failure'); + expect(err.cause).toBe(original); }); it('returns a ConstructiveError unchanged', () => { diff --git a/packages/errors/__tests__/sso.test.ts b/packages/errors/__tests__/sso.test.ts new file mode 100644 index 0000000000..36eea0d62f --- /dev/null +++ b/packages/errors/__tests__/sso.test.ts @@ -0,0 +1,39 @@ +import { ConstructiveError, errors, getDefinition } from '../src'; + +const PUBLIC_SSO_CODES = [ + 'INVALID_SSO_SITE_STATE', + 'INVALID_SSO_CALLBACK', + 'INVALID_SSO_RETURN_TARGET', + 'SSO_LOGIN_TRANSACTION_EXPIRED', + 'SSO_LOGIN_TRANSACTION_ALREADY_USED', + 'OAUTH_SIGN_IN_DISABLED', + 'INVALID_OAUTH_STATE', + 'INVALID_OAUTH_PKCE', + 'IDENTITY_PROVIDER_NOT_CONFIGURED', + 'IDENTITY_PROVIDER_UNSUPPORTED', + 'SSO_ACCOUNT_CONFLICT', + 'INVALID_SSO_HANDOFF', + 'SSO_HANDOFF_EXPIRED', + 'SSO_HANDOFF_ALREADY_USED' +] as const; + +describe('OAuth/SSO error contract', () => { + it.each(PUBLIC_SSO_CODES)('registers %s as a stable public error', code => { + const definition = getDefinition(code); + expect(definition).toMatchObject({ code, class: 'public' }); + expect(definition?.message).not.toEqual(code); + }); + + it('preserves a cause without exposing it in transport extensions', () => { + const cause = new Error('provider response contained a secret'); + const error = errors.INVALID_OAUTH_STATE(undefined, undefined, { cause }); + + expect(error).toBeInstanceOf(ConstructiveError); + expect(error.cause).toBe(cause); + expect(error.toExtensions()).toEqual({ + code: 'INVALID_OAUTH_STATE', + class: 'public', + http: 400 + }); + }); +}); diff --git a/packages/errors/src/error.ts b/packages/errors/src/error.ts index 98f9269f15..a91f27621a 100644 --- a/packages/errors/src/error.ts +++ b/packages/errors/src/error.ts @@ -1,6 +1,6 @@ import type { ErrorClass, ErrorContext } from './types'; -export interface ConstructiveErrorArgs { +export interface ConstructiveErrorArgs extends ErrorOptions { code: string; message: string; errorClass: ErrorClass; @@ -22,7 +22,10 @@ export class ConstructiveError extends Error { readonly context?: ErrorContext; constructor(args: ConstructiveErrorArgs) { - super(args.message); + super( + args.message, + args.cause === undefined ? undefined : { cause: args.cause } + ); this.name = 'ConstructiveError'; this.code = args.code; this.errorClass = args.errorClass; diff --git a/packages/errors/src/factory.ts b/packages/errors/src/factory.ts index 8d34d01c95..48b535a3c1 100644 --- a/packages/errors/src/factory.ts +++ b/packages/errors/src/factory.ts @@ -10,8 +10,16 @@ import type { ErrorClass, ErrorContext, ErrorDefinition } from './types'; * The `[keyof C]` tuple wrapper prevents `never` from distributing. */ export type ErrorFactory = [keyof C] extends [never] - ? (context?: Record, overrideMessage?: string) => ConstructiveError - : (context: C, overrideMessage?: string) => ConstructiveError; + ? ( + context?: Record, + overrideMessage?: string, + options?: ErrorOptions + ) => ConstructiveError + : ( + context: C, + overrideMessage?: string, + options?: ErrorOptions + ) => ConstructiveError; export type ErrorsApi = { [K in keyof R]: R[K] extends { __context: (context: infer C) => void } @@ -25,13 +33,18 @@ export type ErrorsApi = { export function makeErrorFromDefinition( def: ErrorDefinition ): ErrorFactory { - const factory = (context?: ErrorContext, overrideMessage?: string): ConstructiveError => + const factory = ( + context?: ErrorContext, + overrideMessage?: string, + options?: ErrorOptions + ): ConstructiveError => new ConstructiveError({ code: def.code, message: overrideMessage ?? format(def.code, context ?? {}), errorClass: def.class, http: def.http, - context + context, + cause: options?.cause }); return factory as ErrorFactory; } @@ -59,14 +72,19 @@ export function makeError( messageFn: (context: C) => string, httpCode = 500, errorClass: ErrorClass = 'internal' -): (context: C, overrideMessage?: string) => ConstructiveError { - return (context: C, overrideMessage?: string) => +): ( + context: C, + overrideMessage?: string, + options?: ErrorOptions +) => ConstructiveError { + return (context: C, overrideMessage?: string, options?: ErrorOptions) => new ConstructiveError({ code, message: overrideMessage ?? messageFn(context), errorClass, http: httpCode, - context + context, + cause: options?.cause }); } diff --git a/packages/errors/src/parse.ts b/packages/errors/src/parse.ts index 807076af1d..df1b953978 100644 --- a/packages/errors/src/parse.ts +++ b/packages/errors/src/parse.ts @@ -207,6 +207,7 @@ export function toError(error: unknown, locale?: string): ConstructiveError { message, errorClass: parsed.class, http: def ? def.http : httpStatusFor(code).status, - context: parsed.context + context: parsed.context, + cause: parsed.originalError }); } diff --git a/packages/errors/src/registry.ts b/packages/errors/src/registry.ts index 7c5cf70fc1..6886dbd34a 100644 --- a/packages/errors/src/registry.ts +++ b/packages/errors/src/registry.ts @@ -148,6 +148,90 @@ export const registry = { http: 404, message: 'No single sign-on account was found.' }), + OAUTH_SIGN_IN_DISABLED: defineError({ + code: 'OAUTH_SIGN_IN_DISABLED', + class: 'public', + http: 403, + message: 'OAuth sign in is not enabled.' + }), + INVALID_SSO_SITE_STATE: defineError({ + code: 'INVALID_SSO_SITE_STATE', + class: 'public', + http: 400, + message: 'The sign-in request is invalid or has expired. Please restart sign in.' + }), + INVALID_SSO_CALLBACK: defineError({ + code: 'INVALID_SSO_CALLBACK', + class: 'public', + http: 400, + message: 'The requested sign-in callback is not registered for this Site.' + }), + INVALID_SSO_RETURN_TARGET: defineError({ + code: 'INVALID_SSO_RETURN_TARGET', + class: 'public', + http: 400, + message: 'The requested return location is invalid.' + }), + SSO_LOGIN_TRANSACTION_EXPIRED: defineError({ + code: 'SSO_LOGIN_TRANSACTION_EXPIRED', + class: 'public', + http: 410, + message: 'The sign-in request has expired. Please restart sign in.' + }), + SSO_LOGIN_TRANSACTION_ALREADY_USED: defineError({ + code: 'SSO_LOGIN_TRANSACTION_ALREADY_USED', + class: 'public', + http: 409, + message: 'The sign-in request has already been completed. Please restart sign in.' + }), + INVALID_OAUTH_STATE: defineError({ + code: 'INVALID_OAUTH_STATE', + class: 'public', + http: 400, + message: 'The external sign-in state is invalid or has expired. Please restart sign in.' + }), + INVALID_OAUTH_PKCE: defineError({ + code: 'INVALID_OAUTH_PKCE', + class: 'public', + http: 400, + message: 'The external sign-in verification failed. Please restart sign in.' + }), + IDENTITY_PROVIDER_NOT_CONFIGURED: defineError({ + code: 'IDENTITY_PROVIDER_NOT_CONFIGURED', + class: 'public', + http: 400, + message: 'This identity provider is not configured.' + }), + IDENTITY_PROVIDER_UNSUPPORTED: defineError({ + code: 'IDENTITY_PROVIDER_UNSUPPORTED', + class: 'public', + http: 400, + message: 'This identity provider is not supported.' + }), + SSO_ACCOUNT_CONFLICT: defineError({ + code: 'SSO_ACCOUNT_CONFLICT', + class: 'public', + http: 409, + message: 'An account already uses this email. Sign in with its existing method.' + }), + INVALID_SSO_HANDOFF: defineError({ + code: 'INVALID_SSO_HANDOFF', + class: 'public', + http: 400, + message: 'The Site sign-in handoff is invalid.' + }), + SSO_HANDOFF_EXPIRED: defineError({ + code: 'SSO_HANDOFF_EXPIRED', + class: 'public', + http: 410, + message: 'The Site sign-in handoff has expired. Please restart sign in.' + }), + SSO_HANDOFF_ALREADY_USED: defineError({ + code: 'SSO_HANDOFF_ALREADY_USED', + class: 'public', + http: 409, + message: 'The Site sign-in handoff has already been used.' + }), MAGIC_LINK_SIGN_IN_DISABLED: defineError({ code: 'MAGIC_LINK_SIGN_IN_DISABLED', class: 'public', From 79a3c03bbe885b900d2e82ac2ce71694a5f4525d Mon Sep 17 00:00:00 2001 From: zetazzz Date: Sun, 9 Aug 2026 20:09:08 +0800 Subject: [PATCH 02/11] feat: add OAuth provider adapters --- packages/oauth/README.md | 177 ++++-------- packages/oauth/__tests__/adapters.test.ts | 236 ++++++++++++++++ packages/oauth/__tests__/http.test.ts | 91 ++++++ packages/oauth/__tests__/oauth-client.test.ts | 267 ------------------ packages/oauth/__tests__/primitives.test.ts | 102 +++++++ packages/oauth/jest.config.js | 1 + packages/oauth/package.json | 10 +- packages/oauth/src/adapter.ts | 25 ++ packages/oauth/src/authorization.ts | 86 ++++++ packages/oauth/src/endpoint.ts | 102 +++++++ packages/oauth/src/http.ts | 118 ++++++++ packages/oauth/src/index.ts | 59 ++-- packages/oauth/src/middleware/express.ts | 244 ---------------- packages/oauth/src/oauth-client.ts | 210 -------------- packages/oauth/src/primitives.ts | 35 +++ packages/oauth/src/providers/common.ts | 105 +++++++ packages/oauth/src/providers/facebook.ts | 35 --- packages/oauth/src/providers/github.ts | 241 +++++++++++++--- packages/oauth/src/providers/google.ts | 255 +++++++++++++++-- packages/oauth/src/providers/index.ts | 49 ++-- packages/oauth/src/providers/linkedin.ts | 32 --- packages/oauth/src/types.ts | 144 ++++++---- packages/oauth/src/utils/state.ts | 1 - pnpm-lock.yaml | 14 +- 24 files changed, 1553 insertions(+), 1086 deletions(-) create mode 100644 packages/oauth/__tests__/adapters.test.ts create mode 100644 packages/oauth/__tests__/http.test.ts delete mode 100644 packages/oauth/__tests__/oauth-client.test.ts create mode 100644 packages/oauth/__tests__/primitives.test.ts create mode 100644 packages/oauth/src/adapter.ts create mode 100644 packages/oauth/src/authorization.ts create mode 100644 packages/oauth/src/endpoint.ts create mode 100644 packages/oauth/src/http.ts delete mode 100644 packages/oauth/src/middleware/express.ts delete mode 100644 packages/oauth/src/oauth-client.ts create mode 100644 packages/oauth/src/primitives.ts create mode 100644 packages/oauth/src/providers/common.ts delete mode 100644 packages/oauth/src/providers/facebook.ts delete mode 100644 packages/oauth/src/providers/linkedin.ts delete mode 100644 packages/oauth/src/utils/state.ts diff --git a/packages/oauth/README.md b/packages/oauth/README.md index f8c6866041..e38b082639 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -1,128 +1,69 @@ # @constructive-io/oauth -

- -

- -

- - - - - - - - - -

- -> Minimal OAuth 2.0 client for social authentication - -A lightweight OAuth 2.0 client for social authentication with Google, GitHub, Facebook, and LinkedIn. Uses [`@constructive-io/csrf`](../csrf) for secure state management. No external auth library dependencies - uses native fetch for HTTP requests. - -## Installation - -```bash -pnpm add @constructive-io/oauth -``` - -## Usage - -### Basic Setup - -```typescript -import { createOAuthClient } from '@constructive-io/oauth'; - -const client = createOAuthClient({ - providers: { - google: { - clientId: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - }, - github: { - clientId: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET, - }, - }, - baseUrl: 'https://api.example.com', -}); - -// Generate authorization URL -const { url, state } = client.getAuthorizationUrl({ provider: 'google' }); - -// After user authorizes, exchange code for profile -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', +Protocol primitives and Provider adapters for Constructive OAuth/OIDC sign-in. + +The package owns: + +- cryptographically random OAuth state, OIDC nonce, and RFC 7636 S256 PKCE; +- authorization URL construction with protected parameters; +- exact Provider endpoint allowlists and bounded, no-redirect JSON requests; +- a protocol-neutral `ProviderAdapter` contract; +- registered Google/OIDC and GitHub/OAuth adapter implementations; and +- safe normalized external identities without raw Provider payloads or tokens. + +It deliberately does not own Express routes, Cookies, Tenant resolution, +database state, account association, Constructive credentials, or Site handoff. +Those remain in their Constructive orchestration owners. + +## Security model + +Every Provider flow uses Authorization Code with S256 PKCE. Constructive creates +and persists the state, verifier, and optional nonce before navigation. Only the +state and S256 challenge reach the browser. The callback supplies the code to +Constructive, and the selected adapter exchanges it with the original +server-held verifier. + +Provider configuration is supplied by the caller after Tenant-scoped loader +resolution. Adapters do not read environment variables or databases. Endpoints +must match the concrete adapter's HTTPS allowlist, requests reject redirects, +and Provider response bodies are never included in errors. + +## Example + +```ts +import { + deriveS256CodeChallenge, + generateCodeVerifier, + generateOidcNonce, + generateOpaqueState, + getProviderAdapter +} from '@constructive-io/oauth'; + +const adapter = getProviderAdapter(provider.slug); +const config = adapter.validateConfiguration(provider); +const state = generateOpaqueState(); +const codeVerifier = generateCodeVerifier(); +const nonce = adapter.kind === 'google' ? generateOidcNonce() : undefined; + +const { url } = adapter.createAuthorizationRequest({ + config, + redirectUri, + state, + codeChallenge: deriveS256CodeChallenge(codeVerifier), + nonce }); - -// Mount routes -app.get('/auth/:provider', oauth.initiateAuth); -app.get('/auth/:provider/callback', oauth.handleCallback); -app.get('/auth/providers', oauth.getProviders); ``` -## Supported Providers +The common service persists the request artifacts before using `url`. It later +calls `completeAuthorization` with the callback code and server-held artifacts, +then consumes only the returned `NormalizedExternalIdentity`. -| Provider | Scopes | -|----------|--------| -| Google | `openid`, `email`, `profile` | -| GitHub | `user:email`, `read:user` | -| Facebook | `email`, `public_profile` | -| LinkedIn | `openid`, `profile`, `email` | +## Legacy surface -## API - -### `createOAuthClient(config)` - -Creates an OAuth client instance. - -### `createOAuthMiddleware(config)` - -Creates Express route handlers for OAuth flows. - -### `OAuthProfile` - -The normalized user profile returned after authentication: - -```typescript -interface OAuthProfile { - provider: string; // 'google', 'github', etc. - providerId: string; // Provider's unique user ID - email: string | null; - name: string | null; - picture: string | null; - raw: unknown; // Original provider response -} -``` +The previous hard-coded Provider registry, Express middleware, +`/auth/providers` discovery handler, browser state Cookie, and raw profile +payload are intentionally not part of this API. Provider discovery belongs to +Tenant-scoped GraphQL, and replay protection belongs to persisted server state. ## License diff --git a/packages/oauth/__tests__/adapters.test.ts b/packages/oauth/__tests__/adapters.test.ts new file mode 100644 index 0000000000..b9d65b6e74 --- /dev/null +++ b/packages/oauth/__tests__/adapters.test.ts @@ -0,0 +1,236 @@ +import { + exportJWK, + generateKeyPair, + type KeyLike, + SignJWT} from 'jose'; + +import { + getProviderAdapter, + getProviderAdapterKinds, + githubAdapter, + googleAdapter, + type IdentityProviderConfiguration, + ProviderAdapterError} from '../src'; + +const providerConfig = ( + overrides: Partial +): IdentityProviderConfiguration => ({ + slug: 'provider', + kind: 'oauth2', + displayName: 'Provider', + enabled: true, + clientId: 'client-id', + clientSecret: 'client-secret', + authorizationUrl: null, + tokenUrl: null, + userinfoUrl: null, + issuerUrl: null, + discoveryDoc: null, + jwks: null, + acceptableClientIds: [], + scopes: [], + extraAuthorizationParams: {}, + emailOptional: true, + skipNonceCheck: false, + pkceEnabled: true, + ...overrides +}); + +const jsonResponse = (value: unknown): Response => + new Response(JSON.stringify(value), { + headers: { 'content-type': 'application/json' } + }); + +describe('Provider adapter registry', () => { + it('registers Google and GitHub without a Provider-specific workflow API', () => { + expect(getProviderAdapterKinds()).toEqual(['google', 'github']); + expect(getProviderAdapter('google')).toBe(googleAdapter); + expect(getProviderAdapter('github')).toBe(githubAdapter); + expect(() => getProviderAdapter('not-registered')).toThrow( + ProviderAdapterError + ); + }); +}); + +describe('Google OIDC adapter', () => { + let privateKey: KeyLike; + let publicJwk: Awaited>; + + beforeAll(async () => { + const pair = await generateKeyPair('RS256'); + privateKey = pair.privateKey; + publicJwk = await exportJWK(pair.publicKey); + publicJwk.kid = 'test-key'; + publicJwk.alg = 'RS256'; + }); + + const googleConfig = (): IdentityProviderConfiguration => + providerConfig({ + slug: 'google', + kind: 'oidc', + displayName: 'Google', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + issuerUrl: 'https://accounts.google.com', + jwks: { keys: [publicJwk] }, + scopes: ['openid', 'email', 'profile'] + }); + + it('builds mandatory S256 and nonce authorization parameters', () => { + const config = googleAdapter.validateConfiguration(googleConfig()); + const url = new URL( + googleAdapter.createAuthorizationRequest({ + config, + redirectUri: 'https://auth.example.com/auth/oauth/callback', + state: 's'.repeat(43), + codeChallenge: 'c'.repeat(43), + nonce: 'n'.repeat(43) + }).url + ); + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + code_challenge: 'c'.repeat(43), + code_challenge_method: 'S256', + nonce: 'n'.repeat(43), + state: 's'.repeat(43) + }); + }); + + it('verifies the ID token and returns only normalized identity data', async () => { + const idToken = await new SignJWT({ + email: 'person@example.com', + email_verified: false, + name: 'Example Person', + nonce: 'n'.repeat(43), + picture: 'https://images.example.com/person.png' + }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer('https://accounts.google.com') + .setAudience('client-id') + .setSubject('google-subject') + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + jsonResponse({ access_token: 'never-return-this', id_token: idToken }) + ) as unknown as jest.MockedFunction; + + const identity = await googleAdapter.completeAuthorization({ + config: googleAdapter.validateConfiguration(googleConfig()), + redirectUri: 'https://auth.example.com/auth/oauth/callback', + code: 'authorization-code', + codeVerifier: 'a'.repeat(43), + nonce: 'n'.repeat(43), + requestTimeoutMs: 1000, + fetch: fetchMock + }); + + expect(identity).toEqual({ + providerKey: 'google', + subject: 'google-subject', + email: 'person@example.com', + profile: { + name: 'Example Person', + avatarUrl: 'https://images.example.com/person.png', + emailVerified: false + } + }); + expect(JSON.stringify(identity)).not.toContain('never-return-this'); + }); + + it('fails closed when PKCE or nonce verification is disabled', () => { + expect(() => + googleAdapter.validateConfiguration( + { ...googleConfig(), pkceEnabled: false } + ) + ).toThrow(ProviderAdapterError); + expect(() => + googleAdapter.validateConfiguration( + { ...googleConfig(), skipNonceCheck: true } + ) + ).toThrow(ProviderAdapterError); + }); + + it('rejects an ID token that is not bound to the original nonce', async () => { + const idToken = await new SignJWT({ nonce: 'different-nonce' }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer('https://accounts.google.com') + .setAudience('client-id') + .setSubject('google-subject') + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + jsonResponse({ id_token: idToken }) + ) as unknown as jest.MockedFunction; + + await expect( + googleAdapter.completeAuthorization({ + config: googleAdapter.validateConfiguration(googleConfig()), + redirectUri: 'https://auth.example.com/auth/oauth/callback', + code: 'authorization-code', + codeVerifier: 'a'.repeat(43), + nonce: 'n'.repeat(43), + requestTimeoutMs: 1000, + fetch: fetchMock + }) + ).rejects.toMatchObject({ reason: 'IDENTITY_VERIFICATION_FAILED' }); + }); +}); + +describe('GitHub OAuth adapter', () => { + const githubConfig = (): IdentityProviderConfiguration => + providerConfig({ + slug: 'github', + displayName: 'GitHub', + authorizationUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + userinfoUrl: 'https://api.github.com/user', + scopes: ['read:user', 'user:email'] + }); + + it('uses server-only access token for profile/email and normalizes stable ID', async () => { + const responses: unknown[] = [ + { access_token: 'github-server-token' }, + { + id: 42, + login: 'octocat', + name: 'Octo Cat', + email: null, + avatar_url: 'https://avatars.githubusercontent.com/u/42' + }, + [{ email: 'octo@example.com', primary: true, verified: true }] + ]; + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + jsonResponse(responses.shift()) + ) as unknown as jest.MockedFunction; + + const identity = await githubAdapter.completeAuthorization({ + config: githubAdapter.validateConfiguration(githubConfig()), + redirectUri: 'https://auth.example.com/auth/oauth/callback', + code: 'authorization-code', + codeVerifier: 'b'.repeat(43), + requestTimeoutMs: 1000, + fetch: fetchMock + }); + + expect(identity).toEqual({ + providerKey: 'github', + subject: '42', + email: 'octo@example.com', + profile: { + name: 'Octo Cat', + username: 'octocat', + avatarUrl: 'https://avatars.githubusercontent.com/u/42', + emailVerified: true + } + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({ + Authorization: 'Bearer github-server-token' + }); + expect(JSON.stringify(identity)).not.toContain('github-server-token'); + }); +}); diff --git a/packages/oauth/__tests__/http.test.ts b/packages/oauth/__tests__/http.test.ts new file mode 100644 index 0000000000..b66249d887 --- /dev/null +++ b/packages/oauth/__tests__/http.test.ts @@ -0,0 +1,91 @@ +import { + ProviderAdapterError, + requestProviderJson, + validateProviderEndpoint +} from '../src'; + +const endpoint = validateProviderEndpoint('https://api.example.com/token', [ + 'https://api.example.com/token' +]); + +describe('bounded Provider requests', () => { + it('uses no-redirect fetch and parses a bounded JSON response', async () => { + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ ok: true }), { + headers: { 'content-type': 'application/json' } + }) + ) as unknown as jest.MockedFunction; + + await expect( + requestProviderJson(endpoint, { method: 'POST' }, { + timeoutMs: 1000, + fetch: fetchMock + }) + ).resolves.toEqual({ ok: true }); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: 'POST', + redirect: 'error' + }); + }); + + it('does not expose an unsuccessful Provider response body', async () => { + const secretBody = 'provider-secret-response'; + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(secretBody, { + status: 400, + headers: { 'content-type': 'application/json' } + }) + ) as unknown as jest.MockedFunction; + + const error = (await requestProviderJson(endpoint, {}, { + timeoutMs: 1000, + fetch: fetchMock + }).catch(value => value)) as ProviderAdapterError; + expect(error).toBeInstanceOf(ProviderAdapterError); + expect(error.reason).toBe('INVALID_RESPONSE'); + expect(error.message).not.toContain(secretBody); + }); + + it('rejects oversized and non-JSON responses', async () => { + const oversized = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response('{}', { + headers: { + 'content-length': '70000', + 'content-type': 'application/json' + } + }) + ) as unknown as jest.MockedFunction; + await expect( + requestProviderJson(endpoint, {}, { timeoutMs: 1000, fetch: oversized }) + ).rejects.toMatchObject({ reason: 'INVALID_RESPONSE' }); + + const wrongType = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response('{}', { headers: { 'content-type': 'text/plain' } }) + ) as unknown as jest.MockedFunction; + await expect( + requestProviderJson(endpoint, {}, { timeoutMs: 1000, fetch: wrongType }) + ).rejects.toMatchObject({ reason: 'INVALID_RESPONSE' }); + }); + + it('classifies timeout without swallowing its cause', async () => { + const fetchMock = jest.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')) + ); + }) + ) as unknown as jest.MockedFunction; + + const error = (await requestProviderJson(endpoint, {}, { + timeoutMs: 1, + fetch: fetchMock + }).catch(value => value)) as ProviderAdapterError; + expect(error).toMatchObject({ reason: 'REQUEST_TIMEOUT' }); + expect(error.cause).toBeInstanceOf(DOMException); + }); +}); diff --git a/packages/oauth/__tests__/oauth-client.test.ts b/packages/oauth/__tests__/oauth-client.test.ts deleted file mode 100644 index 541192de35..0000000000 --- a/packages/oauth/__tests__/oauth-client.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { createOAuthClient } from '../src/oauth-client'; -import { getProvider, getProviderIds } from '../src/providers'; -import { generateState, verifyState } from '../src/utils/state'; - -describe('OAuthClient', () => { - const config = { - providers: { - google: { - clientId: 'test-google-client-id', - clientSecret: 'test-google-client-secret', - }, - github: { - clientId: 'test-github-client-id', - clientSecret: 'test-github-client-secret', - }, - }, - baseUrl: 'https://api.example.com', - }; - - describe('getAuthorizationUrl', () => { - it('should generate authorization URL for Google', () => { - const client = createOAuthClient(config); - const { url, state } = client.getAuthorizationUrl({ provider: 'google' }); - - expect(url).toContain('https://accounts.google.com/o/oauth2/v2/auth'); - expect(url).toContain('client_id=test-google-client-id'); - expect(url).toContain('redirect_uri='); - expect(url).toContain('response_type=code'); - expect(url).toContain('scope=openid+email+profile'); - expect(url).toContain(`state=${state}`); - expect(state).toHaveLength(64); - }); - - it('should generate authorization URL for GitHub', () => { - const client = createOAuthClient(config); - const { url, state } = client.getAuthorizationUrl({ provider: 'github' }); - - expect(url).toContain('https://github.com/login/oauth/authorize'); - expect(url).toContain('client_id=test-github-client-id'); - expect(url).toContain('scope=user%3Aemail+read%3Auser'); - expect(state).toHaveLength(64); - }); - - it('should use custom state when provided', () => { - const client = createOAuthClient(config); - const customState = 'my-custom-state-123'; - const { url, state } = client.getAuthorizationUrl({ - provider: 'google', - state: customState, - }); - - expect(state).toBe(customState); - expect(url).toContain(`state=${customState}`); - }); - - it('should use custom redirect URI when provided', () => { - const client = createOAuthClient(config); - const customRedirectUri = 'https://custom.example.com/callback'; - const { url } = client.getAuthorizationUrl({ - provider: 'google', - redirectUri: customRedirectUri, - }); - - expect(url).toContain(`redirect_uri=${encodeURIComponent(customRedirectUri)}`); - }); - - it('should use custom scopes when provided', () => { - const client = createOAuthClient(config); - const { url } = client.getAuthorizationUrl({ - provider: 'google', - scopes: ['email'], - }); - - expect(url).toContain('scope=email'); - expect(url).not.toContain('profile'); - }); - - it('should throw error for unknown provider', () => { - const client = createOAuthClient(config); - - expect(() => { - client.getAuthorizationUrl({ provider: 'unknown' }); - }).toThrow('Unknown provider: unknown'); - }); - - it('should throw error for unconfigured provider', () => { - const client = createOAuthClient(config); - - expect(() => { - client.getAuthorizationUrl({ provider: 'facebook' }); - }).toThrow('No credentials configured for provider: facebook'); - }); - }); - - describe('getConfig', () => { - it('should return config with defaults', () => { - const client = createOAuthClient(config); - const returnedConfig = client.getConfig(); - - expect(returnedConfig.callbackPath).toBe('/auth/{provider}/callback'); - expect(returnedConfig.stateCookieName).toBe('oauth_state'); - expect(returnedConfig.stateCookieMaxAge).toBe(600); - }); - - it('should allow overriding defaults', () => { - const client = createOAuthClient({ - ...config, - callbackPath: '/custom/callback/{provider}', - stateCookieName: 'custom_state', - stateCookieMaxAge: 300, - }); - const returnedConfig = client.getConfig(); - - expect(returnedConfig.callbackPath).toBe('/custom/callback/{provider}'); - expect(returnedConfig.stateCookieName).toBe('custom_state'); - expect(returnedConfig.stateCookieMaxAge).toBe(300); - }); - }); -}); - -describe('providers', () => { - it('should have all expected providers', () => { - const ids = getProviderIds(); - expect(ids).toContain('google'); - expect(ids).toContain('github'); - expect(ids).toContain('facebook'); - expect(ids).toContain('linkedin'); - }); - - it('should return provider config by id', () => { - const google = getProvider('google'); - expect(google).toBeDefined(); - expect(google!.id).toBe('google'); - expect(google!.name).toBe('Google'); - expect(google!.authorizationUrl).toBe('https://accounts.google.com/o/oauth2/v2/auth'); - }); - - it('should return undefined for unknown provider', () => { - const unknown = getProvider('unknown'); - expect(unknown).toBeUndefined(); - }); -}); - -describe('state utilities', () => { - describe('generateState', () => { - it('should generate random state of default length', () => { - const state = generateState(); - expect(state).toHaveLength(64); - }); - - it('should generate random state of custom length', () => { - const state = generateState(16); - expect(state).toHaveLength(32); - }); - - it('should generate unique states', () => { - const state1 = generateState(); - const state2 = generateState(); - expect(state1).not.toBe(state2); - }); - }); - - describe('verifyState', () => { - it('should return true for matching states', () => { - const state = generateState(); - expect(verifyState(state, state)).toBe(true); - }); - - it('should return false for non-matching states', () => { - const state1 = generateState(); - const state2 = generateState(); - expect(verifyState(state1, state2)).toBe(false); - }); - - it('should return false for undefined expected state', () => { - expect(verifyState(undefined, 'some-state')).toBe(false); - }); - - it('should return false for undefined actual state', () => { - expect(verifyState('some-state', undefined)).toBe(false); - }); - - it('should return false for different length states', () => { - expect(verifyState('short', 'much-longer-state')).toBe(false); - }); - }); -}); - -describe('provider profile mapping', () => { - it('should map Google profile correctly', () => { - const google = getProvider('google')!; - const profile = google.mapProfile({ - sub: '123456789', - email: 'test@gmail.com', - name: 'Test User', - picture: 'https://example.com/photo.jpg', - }); - - expect(profile.provider).toBe('google'); - expect(profile.providerId).toBe('123456789'); - expect(profile.email).toBe('test@gmail.com'); - expect(profile.name).toBe('Test User'); - expect(profile.picture).toBe('https://example.com/photo.jpg'); - }); - - it('should map GitHub profile correctly', () => { - const github = getProvider('github')!; - const profile = github.mapProfile({ - id: 12345, - login: 'testuser', - name: 'Test User', - email: 'test@github.com', - avatar_url: 'https://avatars.githubusercontent.com/u/12345', - }); - - expect(profile.provider).toBe('github'); - expect(profile.providerId).toBe('12345'); - expect(profile.email).toBe('test@github.com'); - expect(profile.name).toBe('Test User'); - expect(profile.picture).toBe('https://avatars.githubusercontent.com/u/12345'); - }); - - it('should map Facebook profile correctly', () => { - const facebook = getProvider('facebook')!; - const profile = facebook.mapProfile({ - id: '987654321', - name: 'Test User', - email: 'test@facebook.com', - picture: { data: { url: 'https://example.com/fb-photo.jpg' } }, - }); - - expect(profile.provider).toBe('facebook'); - expect(profile.providerId).toBe('987654321'); - expect(profile.email).toBe('test@facebook.com'); - expect(profile.name).toBe('Test User'); - expect(profile.picture).toBe('https://example.com/fb-photo.jpg'); - }); - - it('should map LinkedIn profile correctly', () => { - const linkedin = getProvider('linkedin')!; - const profile = linkedin.mapProfile({ - sub: 'linkedin-123', - email: 'test@linkedin.com', - name: 'Test User', - picture: 'https://example.com/li-photo.jpg', - }); - - expect(profile.provider).toBe('linkedin'); - expect(profile.providerId).toBe('linkedin-123'); - expect(profile.email).toBe('test@linkedin.com'); - expect(profile.name).toBe('Test User'); - expect(profile.picture).toBe('https://example.com/li-photo.jpg'); - }); - - it('should handle missing optional fields', () => { - const google = getProvider('google')!; - const profile = google.mapProfile({ - sub: '123456789', - }); - - expect(profile.provider).toBe('google'); - expect(profile.providerId).toBe('123456789'); - expect(profile.email).toBeNull(); - expect(profile.name).toBeNull(); - expect(profile.picture).toBeNull(); - }); -}); diff --git a/packages/oauth/__tests__/primitives.test.ts b/packages/oauth/__tests__/primitives.test.ts new file mode 100644 index 0000000000..7af99eab24 --- /dev/null +++ b/packages/oauth/__tests__/primitives.test.ts @@ -0,0 +1,102 @@ +import { + createAuthorizationUrl, + deriveS256CodeChallenge, + generateCodeVerifier, + generateOidcNonce, + generateOpaqueState, + isOpaqueOAuthValue, + ProviderAdapterError, + validateProviderEndpoint +} from '../src'; + +describe('OAuth protocol primitives', () => { + it('generates unique 32-byte browser-safe values', () => { + const values = [ + generateOpaqueState(), + generateOpaqueState(), + generateCodeVerifier(), + generateOidcNonce() + ]; + expect(new Set(values).size).toBe(values.length); + for (const value of values) { + expect(value).toHaveLength(43); + expect(isOpaqueOAuthValue(value)).toBe(true); + expect(value).toMatch(/^[A-Za-z0-9_-]+$/); + } + }); + + it('derives the RFC 7636 S256 example challenge', () => { + expect( + deriveS256CodeChallenge( + 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + ) + ).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'); + }); + + it('rejects a verifier outside the RFC 7636 shape', () => { + expect(() => deriveS256CodeChallenge('too-short')).toThrow( + ProviderAdapterError + ); + }); +}); + +describe('Provider endpoint and authorization URL safety', () => { + const endpoint = validateProviderEndpoint( + 'https://accounts.example.com/oauth/authorize', + ['https://accounts.example.com/oauth/authorize'] + ); + + it('requires an exact, clean, allowlisted HTTPS endpoint', () => { + for (const unsafe of [ + 'http://accounts.example.com/oauth/authorize', + 'https://accounts.example.com/oauth/authorize?next=unsafe', + 'https://user:secret@accounts.example.com/oauth/authorize', + 'https://127.0.0.1/oauth/authorize', + 'https://[::1]/oauth/authorize', + 'https://accounts.example.com/other' + ]) { + expect(() => + validateProviderEndpoint(unsafe, [ + 'https://accounts.example.com/oauth/authorize' + ]) + ).toThrow(ProviderAdapterError); + } + }); + + it('owns all security-sensitive authorization parameters', () => { + expect(() => + createAuthorizationUrl({ + endpoint, + clientId: 'client-id', + redirectUri: 'https://auth.example.com/auth/oauth/callback', + scopes: ['openid'], + state: 's'.repeat(43), + codeChallenge: 'c'.repeat(43), + extraParameters: { prompt: 'select_account', state: 'overridden' } + }) + ).toThrow(/owned by the OAuth flow/); + + const url = new URL( + createAuthorizationUrl({ + endpoint, + clientId: 'client-id', + redirectUri: 'https://auth.example.com/auth/oauth/callback', + scopes: ['openid', 'email'], + state: 's'.repeat(43), + codeChallenge: 'c'.repeat(43), + nonce: 'n'.repeat(43), + extraParameters: { prompt: 'select_account' } + }) + ); + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + client_id: 'client-id', + code_challenge: 'c'.repeat(43), + code_challenge_method: 'S256', + nonce: 'n'.repeat(43), + prompt: 'select_account', + response_type: 'code', + scope: 'openid email', + state: 's'.repeat(43) + }); + }); +}); diff --git a/packages/oauth/jest.config.js b/packages/oauth/jest.config.js index 047b2ae4ee..610b872561 100644 --- a/packages/oauth/jest.config.js +++ b/packages/oauth/jest.config.js @@ -4,5 +4,6 @@ module.exports = { '^.+\\.tsx?$': ['ts-jest', { useESM: false }], }, testMatch: ['**/__tests__/**/*.test.ts'], + modulePathIgnorePatterns: ['/dist/'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], }; diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 2cfd50f3c0..7ddb16fa0f 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -2,7 +2,7 @@ "name": "@constructive-io/oauth", "version": "0.27.0", "author": "Constructive ", - "description": "OAuth 2.0 client for social authentication (Google, GitHub, Facebook, LinkedIn)", + "description": "OAuth/OIDC protocol primitives and Provider adapters for Constructive authentication", "main": "index.js", "module": "esm/index.js", "types": "index.d.ts", @@ -29,7 +29,7 @@ "test:watch": "jest --watch" }, "dependencies": { - "@constructive-io/csrf": "workspace:^" + "jose": "^5.10.0" }, "devDependencies": { "@types/node": "^22.19.11", @@ -42,9 +42,9 @@ "authentication", "google", "github", - "facebook", - "linkedin", - "social-login", + "oidc", + "pkce", + "provider-adapter", "constructive" ] } diff --git a/packages/oauth/src/adapter.ts b/packages/oauth/src/adapter.ts new file mode 100644 index 0000000000..c61bccdea1 --- /dev/null +++ b/packages/oauth/src/adapter.ts @@ -0,0 +1,25 @@ +import type { + IdentityProviderConfiguration, + NormalizedExternalIdentity, + ProviderAuthorizationInput, + ProviderAuthorizationResult, + ProviderCallbackInput, + ValidatedProviderConfiguration +} from './types'; + +/** + * Protocol-neutral Provider boundary. Common login orchestration remains + * outside adapters and no inheritance hierarchy is required. + */ +export interface ProviderAdapter< + C extends ValidatedProviderConfiguration = ValidatedProviderConfiguration +> { + readonly kind: string; + validateConfiguration(input: IdentityProviderConfiguration): C; + createAuthorizationRequest( + input: ProviderAuthorizationInput + ): ProviderAuthorizationResult; + completeAuthorization( + input: ProviderCallbackInput + ): Promise; +} diff --git a/packages/oauth/src/authorization.ts b/packages/oauth/src/authorization.ts new file mode 100644 index 0000000000..a85e10fac6 --- /dev/null +++ b/packages/oauth/src/authorization.ts @@ -0,0 +1,86 @@ +import { isOpaqueOAuthValue } from './primitives'; +import type { ValidatedEndpoint } from './types'; +import { ProviderAdapterError } from './types'; + +const PROTECTED_PARAMETERS = new Set([ + 'client_id', + 'code_challenge', + 'code_challenge_method', + 'nonce', + 'redirect_uri', + 'response_type', + 'scope', + 'state' +]); + +export interface AuthorizationUrlInput { + endpoint: ValidatedEndpoint; + clientId: string; + redirectUri: string; + scopes: readonly string[]; + state: string; + codeChallenge: string; + nonce?: string; + extraParameters?: Readonly>; +} + +export const validateProviderCallbackUri = (value: string): string => { + let redirectUri: URL; + try { + redirectUri = new URL(value); + } catch (cause) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'The Provider callback URI is invalid.', + { cause } + ); + } + if ( + redirectUri.protocol !== 'https:' || + redirectUri.username || + redirectUri.password || + redirectUri.hash + ) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'The Provider callback URI is invalid.' + ); + } + return redirectUri.toString(); +}; + +export const createAuthorizationUrl = (input: AuthorizationUrlInput): string => { + const redirectUri = validateProviderCallbackUri(input.redirectUri); + if ( + !isOpaqueOAuthValue(input.state) || + !isOpaqueOAuthValue(input.codeChallenge) || + (input.nonce !== undefined && !isOpaqueOAuthValue(input.nonce)) + ) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'The Provider authorization input is invalid.' + ); + } + + const url = new URL(input.endpoint); + for (const [key, value] of Object.entries(input.extraParameters ?? {})) { + if (PROTECTED_PARAMETERS.has(key.toLowerCase())) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + `The Provider parameter "${key}" is owned by the OAuth flow.` + ); + } + url.searchParams.set(key, value); + } + + url.searchParams.set('client_id', input.clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', input.scopes.join(' ')); + url.searchParams.set('state', input.state); + url.searchParams.set('code_challenge', input.codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + if (input.nonce) url.searchParams.set('nonce', input.nonce); + + return url.toString(); +}; diff --git a/packages/oauth/src/endpoint.ts b/packages/oauth/src/endpoint.ts new file mode 100644 index 0000000000..44915ca026 --- /dev/null +++ b/packages/oauth/src/endpoint.ts @@ -0,0 +1,102 @@ +import { isIP } from 'net'; + +import { ProviderAdapterError, type ValidatedEndpoint } from './types'; + +const isUnsafeIpv4 = (hostname: string): boolean => { + const octets = hostname.split('.').map(Number); + if (octets.length !== 4 || octets.some(value => !Number.isInteger(value))) { + return true; + } + const [a, b] = octets; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + a >= 224 + ); +}; + +const isUnsafeIpv6 = (hostname: string): boolean => { + const value = hostname.toLowerCase(); + return ( + value === '::' || + value === '::1' || + value.startsWith('fc') || + value.startsWith('fd') || + /^fe[89ab]/.test(value) || + value.startsWith('ff') + ); +}; + +const isUnsafeHostname = (hostname: string): boolean => { + const normalized = hostname + .toLowerCase() + .replace(/^\[/, '') + .replace(/\]$/, '') + .replace(/\.$/, ''); + if (normalized === 'localhost' || normalized.endsWith('.localhost')) { + return true; + } + const family = isIP(normalized); + return family === 4 + ? isUnsafeIpv4(normalized) + : family === 6 + ? isUnsafeIpv6(normalized) + : false; +}; + +const canonicalEndpoint = (value: string): string => { + let url: URL; + try { + url = new URL(value); + } catch (cause) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Provider endpoint is not a valid URL.', + { cause } + ); + } + + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.search || + url.hash || + isUnsafeHostname(url.hostname) + ) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Provider endpoint is not an approved HTTPS URL.' + ); + } + return url.toString(); +}; + +/** Validate one configured endpoint against a concrete adapter's exact list. */ +export const validateProviderEndpoint = ( + value: string | null | undefined, + allowed: readonly string[] +): ValidatedEndpoint => { + if (!value) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'A required Provider endpoint is not configured.' + ); + } + const endpoint = canonicalEndpoint(value); + const allowlist = allowed.map(canonicalEndpoint); + if (!allowlist.includes(endpoint)) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The configured Provider endpoint is not supported.' + ); + } + return endpoint as ValidatedEndpoint; +}; diff --git a/packages/oauth/src/http.ts b/packages/oauth/src/http.ts new file mode 100644 index 0000000000..cccec0f108 --- /dev/null +++ b/packages/oauth/src/http.ts @@ -0,0 +1,118 @@ +import { + ProviderAdapterError, + type ValidatedEndpoint +} from './types'; + +const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024; + +export interface ProviderJsonRequestOptions { + timeoutMs: number; + fetch?: typeof fetch; + maxResponseBytes?: number; +} + +const readBoundedBody = async ( + response: Response, + maxBytes: number +): Promise => { + const contentLength = response.headers.get('content-length'); + if (contentLength && Number(contentLength) > maxBytes) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider response exceeded the allowed size.' + ); + } + + if (!response.body) return ''; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) { + await reader.cancel(); + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider response exceeded the allowed size.' + ); + } + chunks.push(value); + } + return Buffer.concat(chunks.map(chunk => Buffer.from(chunk))).toString('utf8'); +}; + +/** Bounded, no-redirect JSON request for already allowlisted endpoints. */ +export const requestProviderJson = async ( + endpoint: ValidatedEndpoint, + init: Omit, + options: ProviderJsonRequestOptions +): Promise => { + if (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Provider request timeout is invalid.' + ); + } + + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, options.timeoutMs); + + try { + const response = await (options.fetch ?? fetch)(endpoint, { + ...init, + redirect: 'error', + signal: controller.signal + }); + + if (!response.ok) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider returned an unsuccessful response.', + { status: response.status } + ); + } + + const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; + if ( + !contentType.includes('application/json') && + !contentType.includes('+json') + ) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider returned an unsupported response type.' + ); + } + + const body = await readBoundedBody( + response, + options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES + ); + try { + return JSON.parse(body); + } catch (cause) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider returned invalid JSON.', + { cause } + ); + } + } catch (cause) { + if (cause instanceof ProviderAdapterError) throw cause; + throw new ProviderAdapterError( + timedOut ? 'REQUEST_TIMEOUT' : 'NETWORK_FAILURE', + timedOut + ? 'The Provider request timed out.' + : 'The Provider request failed.', + { cause } + ); + } finally { + clearTimeout(timeout); + } +}; diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 42d1c94d82..0d27d33c3c 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -1,30 +1,39 @@ +export type { ProviderAdapter } from './adapter'; export { - createOAuthMiddleware, - generateState, - OAuthCallbackContext, - OAuthErrorContext, - OAuthMiddlewareConfig, - OAuthRouteHandlers, - verifyState, -} from './middleware/express'; -export { createOAuthClient,OAuthClient } from './oauth-client'; + type AuthorizationUrlInput, + createAuthorizationUrl, + validateProviderCallbackUri} from './authorization'; +export { validateProviderEndpoint } from './endpoint'; export { - facebookProvider, - getProvider, - getProviderIds, - githubProvider, - googleProvider, - linkedinProvider, - providers, + type ProviderJsonRequestOptions, + requestProviderJson} from './http'; +export { + constantTimeEqual, + deriveS256CodeChallenge, + generateCodeVerifier, + generateOidcNonce, + generateOpaqueState, + isOpaqueOAuthValue +} from './primitives'; +export type { + ValidatedGitHubConfiguration, + ValidatedGoogleConfiguration +} from './providers'; +export { + getProviderAdapter, + getProviderAdapterKinds, + githubAdapter, + googleAdapter } from './providers'; export { - AuthorizationUrlParams, - CallbackParams, - createOAuthError, - OAuthClientConfig, - OAuthCredentials, - OAuthError, - OAuthProfile, - OAuthProviderConfig, - TokenResponse, + type IdentityProviderConfiguration, + type NormalizedExternalIdentity, + ProviderAdapterError, + type ProviderAuthorizationInput, + type ProviderAuthorizationResult, + type ProviderCallbackInput, + type ProviderFailureReason, + type SafeExternalProfile, + type ValidatedEndpoint, + type ValidatedProviderConfiguration } from './types'; diff --git a/packages/oauth/src/middleware/express.ts b/packages/oauth/src/middleware/express.ts deleted file mode 100644 index 607d2d76a4..0000000000 --- a/packages/oauth/src/middleware/express.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { OAuthClient } from '../oauth-client'; -import { getProviderIds } from '../providers'; -import { createOAuthError,OAuthClientConfig, OAuthProfile } from '../types'; -import { generateState, verifyState } from '../utils/state'; - -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 }; diff --git a/packages/oauth/src/oauth-client.ts b/packages/oauth/src/oauth-client.ts deleted file mode 100644 index cbdd348e50..0000000000 --- a/packages/oauth/src/oauth-client.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { extractPrimaryEmail,getProvider, GITHUB_EMAILS_URL } from './providers'; -import { - AuthorizationUrlParams, - CallbackParams, - createOAuthError, - OAuthClientConfig, - OAuthProfile, - TokenResponse, -} from './types'; -import { generateState } from './utils/state'; - -export class OAuthClient { - private config: OAuthClientConfig; - - constructor(config: OAuthClientConfig) { - this.config = { - callbackPath: '/auth/{provider}/callback', - stateCookieName: 'oauth_state', - stateCookieMaxAge: 600, - ...config, - }; - } - - 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 state = customState || generateState(); - const callbackUrl = this.getCallbackUrl(providerId, redirectUri || credentials.redirectUri); - const effectiveScopes = scopes || provider.scopes; - - const url = new URL(provider.authorizationUrl); - url.searchParams.set('client_id', credentials.clientId); - url.searchParams.set('redirect_uri', callbackUrl); - url.searchParams.set('response_type', 'code'); - url.searchParams.set('scope', effectiveScopes.join(' ')); - url.searchParams.set('state', state); - - return { url: url.toString(), state }; - } - - 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 body: Record = { - client_id: credentials.clientId, - client_secret: credentials.clientSecret, - code, - redirect_uri: callbackUrl, - grant_type: 'authorization_code', - }; - - const headers: Record = { - Accept: 'application/json', - }; - - let requestBody: string; - if (provider.tokenRequestContentType === 'json') { - headers['Content-Type'] = 'application/json'; - requestBody = JSON.stringify(body); - } else { - headers['Content-Type'] = 'application/x-www-form-urlencoded'; - requestBody = new URLSearchParams(body).toString(); - } - - const response = await fetch(provider.tokenUrl, { - method: 'POST', - headers, - body: requestBody, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw createOAuthError( - `Token exchange failed: ${errorText}`, - 'TOKEN_EXCHANGE_FAILED', - providerId, - response.status - ); - } - - const data = await response.json(); - - if (data.error) { - throw createOAuthError( - `Token exchange error: ${data.error_description || data.error}`, - 'TOKEN_EXCHANGE_ERROR', - providerId - ); - } - - return data as TokenResponse; - } - - async getUserProfile(providerId: string, accessToken: string): Promise { - const provider = getProvider(providerId); - if (!provider) { - throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId); - } - - const headers: Record = { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }; - - if (providerId === 'github') { - headers['User-Agent'] = 'Constructive-OAuth'; - } - - const response = await fetch(provider.userInfoUrl, { - method: provider.userInfoMethod || 'GET', - headers, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw createOAuthError( - `Failed to fetch user profile: ${errorText}`, - 'USER_PROFILE_FAILED', - providerId, - response.status - ); - } - - const data = await response.json(); - let profile = provider.mapProfile(data); - - if (providerId === 'github' && !profile.email) { - profile = await this.fetchGitHubEmail(accessToken, profile); - } - - return profile; - } - - async handleCallback(params: CallbackParams): Promise { - const tokens = await this.exchangeCode(params); - return this.getUserProfile(params.provider, tokens.access_token); - } - - private async fetchGitHubEmail( - accessToken: string, - profile: OAuthProfile - ): Promise { - try { - const response = await fetch(GITHUB_EMAILS_URL, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - 'User-Agent': 'Constructive-OAuth', - }, - }); - - if (response.ok) { - const emails = await response.json(); - const email = extractPrimaryEmail(emails); - if (email) { - return { ...profile, email }; - } - } - } catch { - // Ignore email fetch errors, return profile without email - } - return profile; - } - - private getCallbackUrl(providerId: string, customRedirectUri?: string): string { - if (customRedirectUri) { - return customRedirectUri; - } - const path = this.config.callbackPath!.replace('{provider}', providerId); - return `${this.config.baseUrl}${path}`; - } - - getConfig(): OAuthClientConfig { - return this.config; - } -} - -export function createOAuthClient(config: OAuthClientConfig): OAuthClient { - return new OAuthClient(config); -} diff --git a/packages/oauth/src/primitives.ts b/packages/oauth/src/primitives.ts new file mode 100644 index 0000000000..c510da7e00 --- /dev/null +++ b/packages/oauth/src/primitives.ts @@ -0,0 +1,35 @@ +import { createHash, randomBytes, timingSafeEqual } from 'crypto'; + +import { ProviderAdapterError } from './types'; + +const BASE64URL_VALUE = /^[A-Za-z0-9_-]+$/; +const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/; + +export const generateOpaqueState = (): string => randomBytes(32).toString('base64url'); + +export const generateCodeVerifier = (): string => randomBytes(32).toString('base64url'); + +export const generateOidcNonce = (): string => randomBytes(32).toString('base64url'); + +export const deriveS256CodeChallenge = (verifier: string): string => { + if (!PKCE_VERIFIER.test(verifier)) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'The PKCE verifier does not satisfy RFC 7636.' + ); + } + return createHash('sha256').update(verifier, 'ascii').digest('base64url'); +}; + +export const isOpaqueOAuthValue = (value: string, byteLength = 32): boolean => + value.length === Math.ceil((byteLength * 4) / 3) && + BASE64URL_VALUE.test(value); + +export const constantTimeEqual = (expected: string, actual: string): boolean => { + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(actual); + return ( + expectedBuffer.length === actualBuffer.length && + timingSafeEqual(expectedBuffer, actualBuffer) + ); +}; diff --git a/packages/oauth/src/providers/common.ts b/packages/oauth/src/providers/common.ts new file mode 100644 index 0000000000..387483e8f8 --- /dev/null +++ b/packages/oauth/src/providers/common.ts @@ -0,0 +1,105 @@ +import { + type IdentityProviderConfiguration, + ProviderAdapterError, + type SafeExternalProfile, + type ValidatedEndpoint, + type ValidatedProviderConfiguration +} from '../types'; + +export const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const optionalString = ( + input: Record, + key: string +): string | undefined => { + const value = input[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +}; + +export const requiredString = ( + input: Record, + key: string +): string => { + const value = optionalString(input, key); + if (!value) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider response is missing a required value.' + ); + } + return value; +}; + +export const configurationValue = ( + config: IdentityProviderConfiguration, + direct: string | null, + discoveryKey: string +): string | null => { + if (direct) return direct; + const discovered = config.discoveryDoc?.[discoveryKey]; + return typeof discovered === 'string' ? discovered : null; +}; + +export const validateCommonConfiguration = ( + input: IdentityProviderConfiguration, + adapterKind: string, + authorizationEndpoint: ValidatedEndpoint, + tokenEndpoint: ValidatedEndpoint +): ValidatedProviderConfiguration => { + if (!input.enabled || !input.clientId || !input.clientSecret) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The selected Provider is not enabled or is missing credentials.' + ); + } + if (!input.pkceEnabled) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'S256 PKCE is required for every Provider.' + ); + } + if (!input.scopes.length) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The selected Provider has no configured scopes.' + ); + } + return { + adapterKind, + providerKey: input.slug, + displayName: input.displayName, + clientId: input.clientId, + clientSecret: input.clientSecret, + authorizationEndpoint, + tokenEndpoint, + scopes: [...input.scopes], + extraAuthorizationParams: { ...input.extraAuthorizationParams } + }; +}; + +export const safeProfileValue = ( + value: unknown, + maxLength = 512 +): string | undefined => + typeof value === 'string' && value.length > 0 && value.length <= maxLength + ? value + : undefined; + +export const safeAvatarUrl = (value: unknown): string | undefined => { + const candidate = safeProfileValue(value, 2048); + if (!candidate) return undefined; + try { + const url = new URL(candidate); + return url.protocol === 'https:' && !url.username && !url.password + ? url.toString() + : undefined; + } catch { + return undefined; + } +}; + +export const compactProfile = (profile: SafeExternalProfile): SafeExternalProfile => + Object.fromEntries( + Object.entries(profile).filter(([, value]) => value !== undefined) + ) as SafeExternalProfile; diff --git a/packages/oauth/src/providers/facebook.ts b/packages/oauth/src/providers/facebook.ts deleted file mode 100644 index 41ed451bff..0000000000 --- a/packages/oauth/src/providers/facebook.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { OAuthProfile,OAuthProviderConfig } from '../types'; - -interface FacebookProfile { - id: string; - name?: string; - email?: string; - picture?: { - data?: { - url?: string; - }; - }; -} - -const FACEBOOK_API_VERSION = 'v18.0'; - -export const facebookProvider: OAuthProviderConfig = { - id: 'facebook', - name: 'Facebook', - 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`, - scopes: ['email', 'public_profile'], - tokenRequestContentType: 'form', - mapProfile: (data: unknown): OAuthProfile => { - const profile = data as FacebookProfile; - return { - provider: 'facebook', - providerId: profile.id, - email: profile.email || null, - name: profile.name || null, - picture: profile.picture?.data?.url || null, - raw: data, - }; - }, -}; diff --git a/packages/oauth/src/providers/github.ts b/packages/oauth/src/providers/github.ts index 72ec51c4aa..f59047ce0c 100644 --- a/packages/oauth/src/providers/github.ts +++ b/packages/oauth/src/providers/github.ts @@ -1,46 +1,207 @@ -import { OAuthProfile,OAuthProviderConfig } from '../types'; - -interface GitHubProfile { - id: number; - login: string; - name?: string; - email?: string; - avatar_url?: string; -} +import type { ProviderAdapter } from '../adapter'; +import { + createAuthorizationUrl, + validateProviderCallbackUri +} from '../authorization'; +import { validateProviderEndpoint } from '../endpoint'; +import { requestProviderJson } from '../http'; +import { deriveS256CodeChallenge } from '../primitives'; +import { + type IdentityProviderConfiguration, + type NormalizedExternalIdentity, + ProviderAdapterError, + type ValidatedEndpoint, + type ValidatedProviderConfiguration +} from '../types'; +import { + compactProfile, + configurationValue, + isRecord, + requiredString, + safeAvatarUrl, + safeProfileValue, + validateCommonConfiguration +} from './common'; + +const GITHUB_AUTHORIZATION_ENDPOINTS = [ + 'https://github.com/login/oauth/authorize' +] as const; +const GITHUB_TOKEN_ENDPOINTS = [ + 'https://github.com/login/oauth/access_token' +] as const; +const GITHUB_USER_ENDPOINTS = ['https://api.github.com/user'] as const; +const GITHUB_EMAIL_ENDPOINTS = ['https://api.github.com/user/emails'] as const; -interface GitHubEmail { - email: string; - primary: boolean; - verified: boolean; +export interface ValidatedGitHubConfiguration + extends ValidatedProviderConfiguration { + userEndpoint: ValidatedEndpoint; + emailEndpoint: ValidatedEndpoint; } -export const githubProvider: OAuthProviderConfig = { - id: 'github', - name: 'GitHub', - authorizationUrl: 'https://github.com/login/oauth/authorize', - tokenUrl: 'https://github.com/login/oauth/access_token', - userInfoUrl: 'https://api.github.com/user', - scopes: ['user:email', 'read:user'], - tokenRequestContentType: 'json', - mapProfile: (data: unknown): OAuthProfile => { - const profile = data as GitHubProfile; - return { - provider: 'github', - providerId: String(profile.id), - email: profile.email || null, - name: profile.name || profile.login || null, - picture: profile.avatar_url || null, - raw: data, - }; - }, +const validateGitHubConfiguration = ( + input: IdentityProviderConfiguration +): ValidatedGitHubConfiguration => { + const authorizationEndpoint = validateProviderEndpoint( + configurationValue(input, input.authorizationUrl, 'authorization_endpoint'), + GITHUB_AUTHORIZATION_ENDPOINTS + ); + const tokenEndpoint = validateProviderEndpoint( + configurationValue(input, input.tokenUrl, 'token_endpoint'), + GITHUB_TOKEN_ENDPOINTS + ); + const userEndpoint = validateProviderEndpoint( + configurationValue(input, input.userinfoUrl, 'userinfo_endpoint'), + GITHUB_USER_ENDPOINTS + ); + const configuredEmailEndpoint = configurationValue( + input, + null, + 'emails_endpoint' + ); + const emailEndpoint = validateProviderEndpoint( + configuredEmailEndpoint ?? `${userEndpoint}/emails`, + GITHUB_EMAIL_ENDPOINTS + ); + + return { + ...validateCommonConfiguration( + input, + 'github', + authorizationEndpoint, + tokenEndpoint + ), + userEndpoint, + emailEndpoint + }; }; -export const GITHUB_EMAILS_URL = 'https://api.github.com/user/emails'; +const githubHeaders = (accessToken?: string): Record => ({ + Accept: 'application/vnd.github+json', + 'User-Agent': 'Constructive-OAuth', + ...(accessToken && { Authorization: `Bearer ${accessToken}` }) +}); -export function extractPrimaryEmail(emails: GitHubEmail[]): string | null { - const primary = emails.find((e) => e.primary && e.verified); - if (primary) return primary.email; - const verified = emails.find((e) => e.verified); - if (verified) return verified.email; - return emails[0]?.email || null; -} +const findEmail = ( + response: unknown +): { email?: string; verified?: boolean } => { + if (!Array.isArray(response)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'GitHub returned an invalid email response.' + ); + } + const emails = response.filter(isRecord); + const selected = + emails.find(value => value.primary === true && value.verified === true) ?? + emails.find(value => value.verified === true) ?? + emails.find(value => typeof value.email === 'string'); + return selected + ? { + email: safeProfileValue(selected.email), + verified: + typeof selected.verified === 'boolean' ? selected.verified : undefined + } + : {}; +}; + +export const githubAdapter: ProviderAdapter = { + kind: 'github', + + validateConfiguration: validateGitHubConfiguration, + + createAuthorizationRequest: input => ({ + url: createAuthorizationUrl({ + endpoint: input.config.authorizationEndpoint, + clientId: input.config.clientId, + redirectUri: input.redirectUri, + scopes: input.config.scopes, + state: input.state, + codeChallenge: input.codeChallenge, + extraParameters: input.config.extraAuthorizationParams + }) + }), + + completeAuthorization: async input => { + deriveS256CodeChallenge(input.codeVerifier); + validateProviderCallbackUri(input.redirectUri); + if (!input.code) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'GitHub callback verification requires an authorization code.' + ); + } + const tokenResponse = await requestProviderJson( + input.config.tokenEndpoint, + { + method: 'POST', + headers: { + ...githubHeaders(), + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: input.config.clientId, + client_secret: input.config.clientSecret, + code: input.code, + code_verifier: input.codeVerifier, + redirect_uri: input.redirectUri + }).toString() + }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ); + if (!isRecord(tokenResponse)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'GitHub returned an invalid token response.' + ); + } + const accessToken = requiredString(tokenResponse, 'access_token'); + + const user = await requestProviderJson( + input.config.userEndpoint, + { headers: githubHeaders(accessToken) }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ); + if (!isRecord(user)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'GitHub returned an invalid profile response.' + ); + } + const id = user.id; + if ( + (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) && + (typeof id !== 'string' || !id) + ) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'GitHub returned an invalid stable identifier.' + ); + } + + let email = safeProfileValue(user.email); + let emailVerified: boolean | undefined; + if (!email) { + const emailResult = findEmail( + await requestProviderJson( + input.config.emailEndpoint, + { headers: githubHeaders(accessToken) }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ) + ); + email = emailResult.email; + emailVerified = emailResult.verified; + } + + return { + providerKey: input.config.providerKey, + subject: String(id), + email, + profile: compactProfile({ + name: safeProfileValue(user.name), + username: safeProfileValue(user.login), + avatarUrl: safeAvatarUrl(user.avatar_url), + emailVerified + }) + } satisfies NormalizedExternalIdentity; + } +}; diff --git a/packages/oauth/src/providers/google.ts b/packages/oauth/src/providers/google.ts index dd1c399bb8..8e87f5db0a 100644 --- a/packages/oauth/src/providers/google.ts +++ b/packages/oauth/src/providers/google.ts @@ -1,32 +1,235 @@ -import { OAuthProfile,OAuthProviderConfig } from '../types'; - -interface GoogleProfile { - sub: string; - email?: string; - email_verified?: boolean; - name?: string; - given_name?: string; - family_name?: string; - picture?: string; +import { + createLocalJWKSet, + type JSONWebKeySet, + jwtVerify +} from 'jose'; + +import type { ProviderAdapter } from '../adapter'; +import { + createAuthorizationUrl, + validateProviderCallbackUri +} from '../authorization'; +import { validateProviderEndpoint } from '../endpoint'; +import { requestProviderJson } from '../http'; +import { deriveS256CodeChallenge } from '../primitives'; +import { + type IdentityProviderConfiguration, + type NormalizedExternalIdentity, + ProviderAdapterError, + type ValidatedEndpoint, + type ValidatedProviderConfiguration +} from '../types'; +import { + compactProfile, + configurationValue, + isRecord, + optionalString, + safeAvatarUrl, + safeProfileValue, + validateCommonConfiguration +} from './common'; + +const GOOGLE_AUTHORIZATION_ENDPOINTS = [ + 'https://accounts.google.com/o/oauth2/v2/auth' +] as const; +const GOOGLE_TOKEN_ENDPOINTS = ['https://oauth2.googleapis.com/token'] as const; +const GOOGLE_ISSUERS = ['https://accounts.google.com'] as const; +const GOOGLE_JWKS_ENDPOINTS = [ + 'https://www.googleapis.com/oauth2/v3/certs', + 'https://www.googleapis.com/oauth2/v1/certs' +] as const; + +export interface ValidatedGoogleConfiguration + extends ValidatedProviderConfiguration { + issuer: string; + acceptableAudiences: readonly string[]; + jwks?: JSONWebKeySet; + jwksEndpoint?: ValidatedEndpoint; } -export const googleProvider: OAuthProviderConfig = { - id: 'google', - name: 'Google', - authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', - tokenUrl: 'https://oauth2.googleapis.com/token', - userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo', - scopes: ['openid', 'email', 'profile'], - tokenRequestContentType: 'form', - mapProfile: (data: unknown): OAuthProfile => { - const profile = data as GoogleProfile; +const validateJwks = (value: Record | null): JSONWebKeySet | undefined => { + if (!value) return undefined; + if (!Array.isArray(value.keys)) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Google Provider JWKS configuration is invalid.' + ); + } + return value as unknown as JSONWebKeySet; +}; + +const validateGoogleConfiguration = ( + input: IdentityProviderConfiguration +): ValidatedGoogleConfiguration => { + if (input.skipNonceCheck) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'OIDC nonce verification is required for Google.' + ); + } + if (!input.scopes.includes('openid')) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Google Provider must include the openid scope.' + ); + } + + const authorizationEndpoint = validateProviderEndpoint( + configurationValue(input, input.authorizationUrl, 'authorization_endpoint'), + GOOGLE_AUTHORIZATION_ENDPOINTS + ); + const tokenEndpoint = validateProviderEndpoint( + configurationValue(input, input.tokenUrl, 'token_endpoint'), + GOOGLE_TOKEN_ENDPOINTS + ); + const issuerEndpoint = validateProviderEndpoint( + configurationValue(input, input.issuerUrl, 'issuer'), + GOOGLE_ISSUERS + ); + const jwks = validateJwks(input.jwks); + const jwksValue = configurationValue(input, null, 'jwks_uri'); + const jwksEndpoint = jwksValue + ? validateProviderEndpoint(jwksValue, GOOGLE_JWKS_ENDPOINTS) + : undefined; + if (!jwks && !jwksEndpoint) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Google Provider has no configured JWKS source.' + ); + } + + return { + ...validateCommonConfiguration( + input, + 'google', + authorizationEndpoint, + tokenEndpoint + ), + issuer: issuerEndpoint.replace(/\/$/, ''), + acceptableAudiences: [input.clientId, ...input.acceptableClientIds], + jwks, + jwksEndpoint + }; +}; + +export const googleAdapter: ProviderAdapter = { + kind: 'google', + + validateConfiguration: validateGoogleConfiguration, + + createAuthorizationRequest: input => { + if (!input.nonce) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'Google authorization requires an OIDC nonce.' + ); + } return { - provider: 'google', - providerId: profile.sub, - email: profile.email || null, - name: profile.name || null, - picture: profile.picture || null, - raw: data, + url: createAuthorizationUrl({ + endpoint: input.config.authorizationEndpoint, + clientId: input.config.clientId, + redirectUri: input.redirectUri, + scopes: input.config.scopes, + state: input.state, + codeChallenge: input.codeChallenge, + nonce: input.nonce, + extraParameters: input.config.extraAuthorizationParams + }) }; }, + + completeAuthorization: async input => { + deriveS256CodeChallenge(input.codeVerifier); + validateProviderCallbackUri(input.redirectUri); + if (!input.code || !input.nonce) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'Google callback verification requires a code and the original nonce.' + ); + } + + const tokenResponse = await requestProviderJson( + input.config.tokenEndpoint, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: input.config.clientId, + client_secret: input.config.clientSecret, + code: input.code, + code_verifier: input.codeVerifier, + grant_type: 'authorization_code', + redirect_uri: input.redirectUri + }).toString() + }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ); + if (!isRecord(tokenResponse)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'Google returned an invalid token response.' + ); + } + const identityToken = optionalString(tokenResponse, 'id_token'); + if (!identityToken) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'Google did not return an identity token.' + ); + } + + let jwks = input.config.jwks; + if (!jwks && input.config.jwksEndpoint) { + const remote = await requestProviderJson( + input.config.jwksEndpoint, + { headers: { Accept: 'application/json' } }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ); + if (!isRecord(remote) || !Array.isArray(remote.keys)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'Google returned an invalid JWKS response.' + ); + } + jwks = remote as unknown as JSONWebKeySet; + } + + try { + const { payload } = await jwtVerify(identityToken, createLocalJWKSet(jwks!), { + algorithms: ['RS256'], + audience: [...input.config.acceptableAudiences], + issuer: input.config.issuer + }); + if (payload.nonce !== input.nonce || typeof payload.sub !== 'string') { + throw new ProviderAdapterError( + 'IDENTITY_VERIFICATION_FAILED', + 'Google identity verification failed.' + ); + } + + return { + providerKey: input.config.providerKey, + subject: payload.sub, + email: safeProfileValue(payload.email), + profile: compactProfile({ + name: safeProfileValue(payload.name), + avatarUrl: safeAvatarUrl(payload.picture), + emailVerified: + typeof payload.email_verified === 'boolean' + ? payload.email_verified + : undefined + }) + } satisfies NormalizedExternalIdentity; + } catch (cause) { + if (cause instanceof ProviderAdapterError) throw cause; + throw new ProviderAdapterError( + 'IDENTITY_VERIFICATION_FAILED', + 'Google identity verification failed.', + { cause } + ); + } + } }; diff --git a/packages/oauth/src/providers/index.ts b/packages/oauth/src/providers/index.ts index 23927d410c..2415066ff9 100644 --- a/packages/oauth/src/providers/index.ts +++ b/packages/oauth/src/providers/index.ts @@ -1,29 +1,28 @@ -import { OAuthProviderConfig } from '../types'; -import { facebookProvider } from './facebook'; -import { extractPrimaryEmail,GITHUB_EMAILS_URL, githubProvider } from './github'; -import { googleProvider } from './google'; -import { linkedinProvider } from './linkedin'; +import type { ProviderAdapter } from '../adapter'; +import { ProviderAdapterError } from '../types'; +import { githubAdapter } from './github'; +import { googleAdapter } from './google'; -export const providers: Record = { - google: googleProvider, - github: githubProvider, - facebook: facebookProvider, - linkedin: linkedinProvider, -}; +const providerAdapters = new Map([ + [googleAdapter.kind, googleAdapter as ProviderAdapter], + [githubAdapter.kind, githubAdapter as ProviderAdapter] +]); -export function getProvider(id: string): OAuthProviderConfig | undefined { - return providers[id]; -} +export const getProviderAdapter = (providerKey: string): ProviderAdapter => { + const adapter = providerAdapters.get(providerKey); + if (!adapter) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The selected identity Provider is not supported.' + ); + } + return adapter; +}; -export function getProviderIds(): string[] { - return Object.keys(providers); -} +export const getProviderAdapterKinds = (): readonly string[] => + [...providerAdapters.keys()]; -export { - extractPrimaryEmail, - facebookProvider, - GITHUB_EMAILS_URL, - githubProvider, - googleProvider, - linkedinProvider, -}; +export type { ValidatedGitHubConfiguration } from './github'; +export { githubAdapter } from './github'; +export type { ValidatedGoogleConfiguration } from './google'; +export { googleAdapter } from './google'; diff --git a/packages/oauth/src/providers/linkedin.ts b/packages/oauth/src/providers/linkedin.ts deleted file mode 100644 index 9050c9be6a..0000000000 --- a/packages/oauth/src/providers/linkedin.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { OAuthProfile,OAuthProviderConfig } from '../types'; - -interface LinkedInProfile { - sub: string; - email?: string; - email_verified?: boolean; - name?: string; - given_name?: string; - family_name?: string; - picture?: string; -} - -export const linkedinProvider: OAuthProviderConfig = { - id: 'linkedin', - name: 'LinkedIn', - authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization', - tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken', - userInfoUrl: 'https://api.linkedin.com/v2/userinfo', - scopes: ['openid', 'profile', 'email'], - tokenRequestContentType: 'form', - mapProfile: (data: unknown): OAuthProfile => { - const profile = data as LinkedInProfile; - return { - provider: 'linkedin', - providerId: profile.sub, - email: profile.email || null, - name: profile.name || null, - picture: profile.picture || null, - raw: data, - }; - }, -}; diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index db4ef07437..2c99238a54 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -1,74 +1,108 @@ -export interface OAuthProviderConfig { - id: string; - name: string; - authorizationUrl: string; - tokenUrl: string; - userInfoUrl: string; +export interface IdentityProviderConfiguration { + 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; + discoveryDoc: Record | null; + jwks: Record | null; + acceptableClientIds: string[]; scopes: string[]; - tokenRequestContentType?: 'json' | 'form'; - userInfoMethod?: 'GET' | 'POST'; - mapProfile: (data: unknown) => OAuthProfile; + extraAuthorizationParams: Record; + emailOptional: boolean; + skipNonceCheck: boolean; + pkceEnabled: boolean; } -export interface OAuthProfile { - provider: string; - providerId: string; - email: string | null; - name: string | null; - picture: string | null; - raw: unknown; -} +declare const validatedEndpoint: unique symbol; -export interface OAuthCredentials { +/** An HTTPS endpoint that has passed a concrete adapter's exact allowlist. */ +export type ValidatedEndpoint = string & { + readonly [validatedEndpoint]: true; +}; + +export interface ValidatedProviderConfiguration { + adapterKind: string; + providerKey: string; + displayName: string; clientId: string; clientSecret: string; - redirectUri?: string; + authorizationEndpoint: ValidatedEndpoint; + tokenEndpoint: ValidatedEndpoint; + scopes: readonly string[]; + extraAuthorizationParams: Readonly>; } -export interface OAuthClientConfig { - providers: Record; - baseUrl: string; - callbackPath?: string; - stateCookieName?: string; - stateCookieMaxAge?: number; +export interface ProviderAuthorizationInput< + C extends ValidatedProviderConfiguration = ValidatedProviderConfiguration +> { + config: C; + redirectUri: string; + state: string; + codeChallenge: string; + nonce?: string; } -export interface TokenResponse { - access_token: string; - token_type: string; - expires_in?: number; - refresh_token?: string; - scope?: string; +export interface ProviderAuthorizationResult { + url: string; } -export interface AuthorizationUrlParams { - provider: string; - state?: string; - redirectUri?: string; - scopes?: string[]; +export interface ProviderCallbackInput< + C extends ValidatedProviderConfiguration = ValidatedProviderConfiguration +> { + config: C; + redirectUri: string; + code: string; + codeVerifier: string; + nonce?: string; + requestTimeoutMs: number; + fetch?: typeof fetch; } -export interface CallbackParams { - provider: string; - code: string; - redirectUri?: string; +export interface SafeExternalProfile { + name?: string; + username?: string; + avatarUrl?: string; + emailVerified?: boolean; } -export interface OAuthError extends Error { - code: string; - provider?: string; - statusCode?: number; +/** The only Provider result consumed by common Constructive orchestration. */ +export interface NormalizedExternalIdentity { + providerKey: string; + subject: string; + email?: string; + profile: SafeExternalProfile; } -export function createOAuthError( - message: string, - code: string, - provider?: string, - statusCode?: number -): OAuthError { - const error = new Error(message) as OAuthError; - error.code = code; - error.provider = provider; - error.statusCode = statusCode; - return error; +export type ProviderFailureReason = + | 'INVALID_CONFIGURATION' + | 'INVALID_AUTHORIZATION_INPUT' + | 'NETWORK_FAILURE' + | 'REQUEST_TIMEOUT' + | 'INVALID_RESPONSE' + | 'IDENTITY_VERIFICATION_FAILED'; + +/** + * Package-local failure classification. Transport owners map it to canonical + * Constructive errors and never expose Provider response bodies. + */ +export class ProviderAdapterError extends Error { + readonly reason: ProviderFailureReason; + readonly status?: number; + + constructor( + reason: ProviderFailureReason, + message: string, + options?: ErrorOptions & { status?: number } + ) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'ProviderAdapterError'; + this.reason = reason; + this.status = options?.status; + } } diff --git a/packages/oauth/src/utils/state.ts b/packages/oauth/src/utils/state.ts deleted file mode 100644 index c7a60f2818..0000000000 --- a/packages/oauth/src/utils/state.ts +++ /dev/null @@ -1 +0,0 @@ -export { generateToken as generateState, verifyToken as verifyState } from '@constructive-io/csrf'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 257ab13c58..b3a8802ef0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2586,9 +2586,9 @@ importers: packages/oauth: dependencies: - '@constructive-io/csrf': - specifier: workspace:^ - version: link:../csrf/dist + jose: + specifier: ^5.10.0 + version: 5.10.0 devDependencies: '@types/node': specifier: ^22.19.11 @@ -11645,6 +11645,12 @@ packages: } hasBin: true + jose@5.10.0: + resolution: + { + integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==, + } + js-tokens@4.0.0: resolution: { @@ -21173,6 +21179,8 @@ snapshots: jiti@2.7.0: {} + jose@5.10.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: From cb5f8cc31f19fb6b326bdcb5937770f45a78e091 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Sun, 9 Aug 2026 21:14:35 +0800 Subject: [PATCH 03/11] feat: add tenant SSO context surface --- packages/express-context/README.md | 18 +++++ .../__tests__/loaders/sso-surface.test.ts | 74 +++++++++++++++++++ packages/express-context/src/index.ts | 2 + packages/express-context/src/loaders/index.ts | 2 + .../src/loaders/sso-surface.ts | 45 +++++++++++ packages/express-context/src/types.ts | 6 ++ 6 files changed, 147 insertions(+) create mode 100644 packages/express-context/__tests__/loaders/sso-surface.test.ts create mode 100644 packages/express-context/src/loaders/sso-surface.ts diff --git a/packages/express-context/README.md b/packages/express-context/README.md index 72534d75cf..4927abda36 100644 --- a/packages/express-context/README.md +++ b/packages/express-context/README.md @@ -70,6 +70,24 @@ Each loader encapsulates a SQL query + type transform + per-databaseId LRU cache | `webauthnLoader` | `routing_public.webauthn_settings` | WebAuthn/passkey configuration | | `authSettingsLoader` | `metaschema_modules_public.sessions_module` | Cookie/captcha settings (two-step tenant DB discovery) | +### Opt-in authentication loaders + +`identityProvidersLoader` resolves enabled Tenant Provider configuration and +secrets. `ssoSurfaceLoader` resolves only the current database's provisioned +unified-auth private schema. Both are intentionally excluded from +`createDefaultRegistry()` and must be registered by the authentication service +that owns their cost and secret boundary: + +```typescript +const registry = createDefaultRegistry(); +registry.register(identityProvidersLoader); +registry.register(ssoSurfaceLoader); +``` + +`ssoSurfaceLoader` returns `undefined` when the current Tenant has no provisioned +unified-auth module. It never guesses a global `sso_private` schema or searches +another database. + ### Custom loaders ```typescript diff --git a/packages/express-context/__tests__/loaders/sso-surface.test.ts b/packages/express-context/__tests__/loaders/sso-surface.test.ts new file mode 100644 index 0000000000..92af9c3e39 --- /dev/null +++ b/packages/express-context/__tests__/loaders/sso-surface.test.ts @@ -0,0 +1,74 @@ +import type { Pool } from 'pg'; + +import { createDefaultRegistry } from '../../src/loaders'; +import { createLoaderRegistry } from '../../src/loaders/registry'; +import { ssoSurfaceLoader } from '../../src/loaders/sso-surface'; +import type { LoaderContext } from '../../src/loaders/types'; +import type { SsoSurface } from '../../src/types'; + +interface Call { + text: string; + values?: unknown[]; +} + +const fakePool = (rows: unknown[]) => { + const calls: Call[] = []; + const pool = { + query: jest.fn(async (text: string, values?: unknown[]) => { + calls.push({ text, values }); + return { rows }; + }) + } as unknown as Pool; + return { calls, pool }; +}; + +const ctx = (tenantPool: Pool, databaseId = 'db-1'): LoaderContext => ({ + routingPool: {} as Pool, + tenantPool, + databaseId, + dbname: 'tenant' +}); + +beforeEach(() => ssoSurfaceLoader.invalidate()); + +describe('ssoSurfaceLoader', () => { + it('resolves the database-scoped private schema from authoritative metadata', async () => { + const { calls, pool } = fakePool([ + { private_schema: 'tenant_a_sso_private' } + ]); + + const surface: SsoSurface | undefined = await ssoSurfaceLoader.resolve( + ctx(pool, 'db-a') + ); + + expect(surface).toEqual({ privateSchema: 'tenant_a_sso_private' }); + expect(calls).toHaveLength(1); + expect(calls[0].values).toEqual(['db-a']); + expect(calls[0].text).toMatch(/unified_auth\.database_id = \$1/); + expect(calls[0].text).toMatch(/unified_auth\.scope = 'database'/); + expect(calls[0].text).toMatch( + /private_schema\.id = unified_auth\.private_schema_id/ + ); + }); + + it('returns undefined when this Tenant has no provisioned module', async () => { + const { pool } = fakePool([]); + await expect(ssoSurfaceLoader.resolve(ctx(pool))).resolves.toBeUndefined(); + }); + + it('does not run an unkeyed lookup without a database ID', async () => { + const { calls, pool } = fakePool([]); + await expect(ssoSurfaceLoader.resolve(ctx(pool, ''))).rejects.toThrow( + /no databaseId/ + ); + expect(calls).toHaveLength(0); + }); + + it('is typed but remains explicitly opt-in', async () => { + expect(createDefaultRegistry().has('ssoSurface')).toBe(false); + + const registry = createLoaderRegistry(); + registry.register(ssoSurfaceLoader); + expect(registry.has('ssoSurface')).toBe(true); + }); +}); diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 013e195f5d..5fe55920a4 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -56,6 +56,7 @@ export type { LlmConfig, PubkeyChallengeSettings, RlsModule, + SsoSurface, WebauthnSettings, WithPgClient, } from './types'; @@ -103,6 +104,7 @@ export { requireDatabaseId, requireIdentityProvider, rlsLoader, + ssoSurfaceLoader, webauthnLoader, } from './loaders'; diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index a8a3203428..fc7081e870 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -16,6 +16,7 @@ * * Opt-in (not in the default registry, register it explicitly): * - identityProviders (three round trips, decrypts client secrets) + * - ssoSurface (current Tenant's provisioned unified-auth private schema) * * To add a new per-db lookup, implement a ModuleLoader and register it: * @@ -55,6 +56,7 @@ export { inferenceLogLoader } from './inference-log'; export { llmLoader } from './llm'; export { pubkeyLoader } from './pubkey'; export { rlsLoader } from './rls'; +export { ssoSurfaceLoader } from './sso-surface'; export { webauthnLoader } from './webauthn'; /** diff --git a/packages/express-context/src/loaders/sso-surface.ts b/packages/express-context/src/loaders/sso-surface.ts new file mode 100644 index 0000000000..212319214d --- /dev/null +++ b/packages/express-context/src/loaders/sso-surface.ts @@ -0,0 +1,45 @@ +/** + * Unified-auth SSO Surface Loader (Tier 2 — tenant DB) + * + * Resolves only the private schema provisioned for the current database's + * database-scoped unified_auth_module. Procedure names are fixed by the DB + * module contract; policy, Site configuration, and Provider secrets remain in + * their owning loaders/functions. + * + * This loader is opt-in and is not registered by createDefaultRegistry(). + */ + +import type { SsoSurface } from '../types'; +import { createModuleLoader } from './create-loader'; +import type { LoaderContext, ModuleLoader } from './types'; +import { requireDatabaseId } from './types'; + +const SSO_SURFACE_SQL = ` + SELECT private_schema.name AS private_schema + FROM metaschema_modules_public.unified_auth_module unified_auth + JOIN metaschema_public.schema private_schema + ON private_schema.id = unified_auth.private_schema_id + WHERE unified_auth.database_id = $1 + AND unified_auth.scope = 'database' + LIMIT 1 +`; + +interface SsoSurfaceRow { + private_schema: string; +} + +export const ssoSurfaceLoader: ModuleLoader = + createModuleLoader({ + name: 'ssoSurface', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + requireDatabaseId(databaseId, 'ssoSurface'); + + const result = await tenantPool.query(SSO_SURFACE_SQL, [ + databaseId + ]); + const row = result.rows[0]; + return row ? { privateSchema: row.private_schema } : undefined; + } + }); diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 4316018209..febf6a2f27 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -98,6 +98,11 @@ export interface AuthSurface { connectedAccountsView: string; } +/** Current Tenant's provisioned private unified-auth module surface. */ +export interface SsoSurface { + privateSchema: string; +} + /** One identity provider row, with its client secret resolved. */ export interface IdentityProviderConfig { id: string; @@ -241,6 +246,7 @@ export interface BuiltinModuleMap { databaseSettings: DatabaseSettings; authSettings: AuthSettings; authSurface: AuthSurface; + ssoSurface: SsoSurface; identityProviders: IdentityProvidersModule; pubkeyChallengeSettings: PubkeyChallengeSettings; webauthnSettings: WebauthnSettings; From a6b4bb025b9f11d66d1720900a4cc6d87a31a49c Mon Sep 17 00:00:00 2001 From: zetazzz Date: Sun, 9 Aug 2026 23:45:33 +0800 Subject: [PATCH 04/11] feat: add unified auth GraphQL integration --- graphql/server-test/src/get-connections.ts | 3 +- graphql/server-test/src/types.ts | 8 +- graphql/server/package.json | 1 + .../sso/__tests__/plugin.integration.test.ts | 60 ++++ .../src/auth/sso/__tests__/service.test.ts | 184 ++++++++++++ graphql/server/src/auth/sso/db-contract.ts | 276 ++++++++++++++++++ graphql/server/src/auth/sso/index.ts | 1 + graphql/server/src/auth/sso/plugin.ts | 138 +++++++++ graphql/server/src/auth/sso/service.ts | 202 +++++++++++++ graphql/server/src/auth/sso/types.ts | 68 +++++ .../__tests__/grafast-context.test.ts | 21 ++ .../server/src/middleware/grafast-context.ts | 13 + graphql/server/src/middleware/graphile.ts | 25 +- .../__tests__/auth-cookie-plugin.test.ts | 134 ++++++--- .../server/src/plugins/auth-cookie-plugin.ts | 265 ++++++++++------- graphql/server/src/server.ts | 15 +- pnpm-lock.yaml | 3 + 17 files changed, 1268 insertions(+), 149 deletions(-) create mode 100644 graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts create mode 100644 graphql/server/src/auth/sso/__tests__/service.test.ts create mode 100644 graphql/server/src/auth/sso/db-contract.ts create mode 100644 graphql/server/src/auth/sso/index.ts create mode 100644 graphql/server/src/auth/sso/plugin.ts create mode 100644 graphql/server/src/auth/sso/service.ts create mode 100644 graphql/server/src/auth/sso/types.ts create mode 100644 graphql/server/src/middleware/__tests__/grafast-context.test.ts create mode 100644 graphql/server/src/middleware/grafast-context.ts diff --git a/graphql/server-test/src/get-connections.ts b/graphql/server-test/src/get-connections.ts index 875c30b9cd..edda7c4a4a 100644 --- a/graphql/server-test/src/get-connections.ts +++ b/graphql/server-test/src/get-connections.ts @@ -55,7 +55,8 @@ export const getConnections = async ( exposedSchemas: input.schemas, ...(input.authRole && { anonRole: input.authRole, roleName: input.authRole }) }, - graphile: input.graphile + graphile: input.graphile, + oauth: input.server?.oauth }); // Start the HTTP server. Suites default to the production scoped-routing diff --git a/graphql/server-test/src/types.ts b/graphql/server-test/src/types.ts index 2d1119be2e..7736a97216 100644 --- a/graphql/server-test/src/types.ts +++ b/graphql/server-test/src/types.ts @@ -1,4 +1,8 @@ -import type { ApiOptions,GraphileOptions } from '@constructive-io/graphql-types'; +import type { + ApiOptions, + GraphileOptions, + OAuthServerOptions +} from '@constructive-io/graphql-types'; import type { DocumentNode, GraphQLError } from 'graphql'; import type { Server } from 'http'; import type { PgTestClient } from 'pgsql-test/test-client'; @@ -39,6 +43,8 @@ export interface ServerOptions { * ``` */ api?: Partial; + /** GraphQL-server OAuth options forwarded through the normal typed config path. */ + oauth?: OAuthServerOptions; } /** diff --git a/graphql/server/package.json b/graphql/server/package.json index a3676d1950..d6be1be707 100644 --- a/graphql/server/package.json +++ b/graphql/server/package.json @@ -48,6 +48,7 @@ "@constructive-io/graphql-env": "workspace:^", "@constructive-io/graphql-types": "workspace:^", "@constructive-io/llm-env": "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/auth/sso/__tests__/plugin.integration.test.ts b/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts new file mode 100644 index 0000000000..3cd493c5a6 --- /dev/null +++ b/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts @@ -0,0 +1,60 @@ +import path from 'node:path'; + +import { getConnections, seed } from 'graphile-test'; + +import { createUnifiedAuthPlugin } from '../plugin'; + +jest.setTimeout(60_000); + +type Connections = Awaited>; + +describe('UnifiedAuthPlugin schema integration', () => { + let db: Connections['db']; + let query: Connections['query']; + let teardown: () => Promise; + + beforeAll(async () => { + const connections = await getConnections( + { + schemas: ['app_public'], + authRole: 'anonymous', + preset: { plugins: [createUnifiedAuthPlugin(false)] } + }, + [ + seed.sqlfile([ + path.join(__dirname, '../../../../../server-test/sql/test.sql') + ]) + ] + ); + ({ db, query, teardown } = connections); + }); + + beforeEach(() => db.beforeEach()); + afterEach(() => db.afterEach()); + afterAll(() => teardown()); + + it('adds the stable unified-auth Query and Mutation fields', async () => { + const response = await query<{ + query: { fields: Array<{ name: string }> }; + mutation: { fields: Array<{ name: string }> }; + }>(` + query UnifiedAuthSchema { + query: __type(name: "Query") { fields { name } } + mutation: __type(name: "Mutation") { fields { name } } + } + `); + + expect(response.errors).toBeUndefined(); + expect(response.data?.query.fields.map(field => field.name)).toContain( + 'unifiedAuthProviders' + ); + expect(response.data?.mutation.fields.map(field => field.name)).toEqual( + expect.arrayContaining([ + 'startUnifiedLogin', + 'confirmUnifiedLogin', + 'signInUnifiedLogin', + 'signUpUnifiedLogin' + ]) + ); + }); +}); diff --git a/graphql/server/src/auth/sso/__tests__/service.test.ts b/graphql/server/src/auth/sso/__tests__/service.test.ts new file mode 100644 index 0000000000..d44767bc9f --- /dev/null +++ b/graphql/server/src/auth/sso/__tests__/service.test.ts @@ -0,0 +1,184 @@ +import type { + ConstructiveContext, + IdentityProviderConfig, + SsoSurface +} from '@constructive-io/express-context'; +import type { PoolClient, QueryResult } from 'pg'; + +import { createUnifiedAuthService } from '../service'; + +const opaque = 'a'.repeat(43); +const surface: SsoSurface = { privateSchema: 'tenant_acme_sso_private' }; + +const googleProvider: IdentityProviderConfig = { + id: 'provider-id', + slug: 'google-workspace', + kind: 'google', + displayName: 'Google Workspace', + enabled: true, + clientId: 'client-id', + clientSecret: 'client-secret', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + userinfoUrl: null, + issuerUrl: 'https://accounts.google.com', + discoveryUrlOverride: null, + discoveryDoc: null, + jwks: { keys: [] }, + jwksFetchedAt: null, + acceptableClientIds: [], + scopes: ['openid', 'email', 'profile'], + extraAuthorizationParams: {}, + emailOptional: false, + allowLinkByEmail: false, + skipNonceCheck: false, + pkceEnabled: true +}; + +const makeContext = ( + databaseResult?: Record, + options: { + userId?: string | null; + providers?: Record; + } = {} +): { context: ConstructiveContext; query: jest.Mock } => { + const query = jest.fn(async () => ({ + rows: databaseResult === undefined ? [] : [{ result: databaseResult }] + } as unknown as QueryResult)); + const client = { query } as unknown as PoolClient; + const context = { + userId: options.userId ?? null, + useModule: jest.fn(async (name: string) => { + if (name === 'ssoSurface') return surface; + if (name === 'identityProviders') { + return options.providers + ? { providers: options.providers, source: { schemaName: 'p', tableName: 'p' } } + : undefined; + } + return undefined; + }), + withPgClient: jest.fn(async (callback: (pg: PoolClient) => Promise) => + callback(client) + ) + } as unknown as ConstructiveContext; + return { context, query }; +}; + +describe('unified authentication GraphQL service', () => { + it('returns no Provider options without resolving secrets when OAuth is disabled', async () => { + const { context } = makeContext(undefined, { providers: { google: googleProvider } }); + const service = createUnifiedAuthService(false); + + await expect(service.providers({ constructive: context })).resolves.toEqual([]); + expect(context.useModule).not.toHaveBeenCalledWith('identityProviders'); + }); + + it('returns only safe dynamic Provider display fields', async () => { + const { context } = makeContext(undefined, { + providers: { + google: googleProvider, + custom: { ...googleProvider, slug: 'custom', kind: 'custom' } + } + }); + const service = createUnifiedAuthService(true); + + await expect(service.providers({ constructive: context })).resolves.toEqual([ + { key: 'google-workspace', displayName: 'Google Workspace' } + ]); + }); + + it('starts through the current Tenant SSO function and merges Provider options', async () => { + const { context, query } = makeContext({ + transaction_id: opaque, + site_id: '00000000-0000-0000-0000-000000000001', + site_display_name: 'Customer Portal', + site_icon_url: null, + site_theme_color: '#112233', + sign_in_mode: 'confirm', + reusable_authentication: false, + current_user_id: null + }, { providers: { google: googleProvider } }); + const service = createUnifiedAuthService(true); + + const result = await service.start( + { constructive: context }, + { + siteId: '00000000-0000-0000-0000-000000000001', + returnTo: '/approvals/42', + siteState: opaque, + csrfToken: opaque + } + ); + + expect(result.providers).toEqual([ + { key: 'google-workspace', displayName: 'Google Workspace' } + ]); + expect(result.site.displayName).toBe('Customer Portal'); + expect(query.mock.calls[0][0]).toContain( + '"tenant_acme_sso_private"."start_unified_login"' + ); + expect(query.mock.calls[0][1]).toEqual([ + '00000000-0000-0000-0000-000000000001', + null, + '/approvals/42', + opaque, + opaque + ]); + }); + + it('uses the fixed local-password wrapper contract once', async () => { + const { context, query } = makeContext({ + id: '00000000-0000-0000-0000-000000000010', + user_id: '00000000-0000-0000-0000-000000000011', + access_token: 'cnc_live_bt_secret', + access_token_expires_at: '2026-08-10T00:00:00.000Z', + is_verified: false, + totp_enabled: false, + mfa_required: false + }); + const service = createUnifiedAuthService(false); + + const result = await service.signIn( + { constructive: context }, + { + transactionId: opaque, + email: 'user@example.com', + password: 'correct horse battery staple', + rememberMe: true, + csrfToken: opaque + } + ); + + expect(result.accessToken).toBe('cnc_live_bt_secret'); + expect(result.continuationUrl).toBeNull(); + expect(query).toHaveBeenCalledTimes(1); + expect(query.mock.calls[0][0]).toContain( + '"tenant_acme_sso_private"."sign_in_unified_login"' + ); + expect(query.mock.calls[0][1]).toEqual([ + opaque, + 'user@example.com', + 'correct horse battery staple', + true, + 'bearer', + opaque, + null + ]); + }); + + it('rejects a cross-origin return target before database access', async () => { + const { context, query } = makeContext(); + const service = createUnifiedAuthService(false); + + await expect(service.start( + { constructive: context }, + { + siteId: '00000000-0000-0000-0000-000000000001', + returnTo: 'https://evil.example/steal', + siteState: opaque, + csrfToken: opaque + } + )).rejects.toMatchObject({ code: 'INVALID_SSO_RETURN_TARGET' }); + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/graphql/server/src/auth/sso/db-contract.ts b/graphql/server/src/auth/sso/db-contract.ts new file mode 100644 index 0000000000..ee6906db34 --- /dev/null +++ b/graphql/server/src/auth/sso/db-contract.ts @@ -0,0 +1,276 @@ +import { errors } from '@constructive-io/errors'; +import type { ConstructiveContext, SsoSurface } from '@constructive-io/express-context'; +import sql from 'pg-sql2'; + +import type { + ContinueUnifiedLoginInput, + StartUnifiedLoginInput, + UnifiedAuthAccount, + UnifiedAuthSite, + UnifiedLoginContinuationPayload, + UnifiedLoginCredentialPayload, + UnifiedPasswordInput +} from './types'; + +/** + * Stable Constructive/Constructive DB boundary for the GraphQL integration. + * + * These functions live in the current Tenant's provisioned SSO private schema. + * They own transaction locking, expiry, browser/Site/Tenant checks, calls to the + * unchanged local `sign_in`/`sign_up` primitives, and identity/session + * association. Constructive intentionally does not read the private tables. + * + * Exact v1 signatures fixed by this integration: + * + * - `start_unified_login(uuid, text, text, text, text)` returns + * `transaction_id`, safe Site display fields, `sign_in_mode`, + * `reusable_authentication`, and optional safe current-user display fields. + * - `confirm_unified_login(text, text)` returns the associated `user_id`. + * - `sign_in_unified_login(text, text, text, boolean, text, text, text)` and + * `sign_up_unified_login(...)` return the unchanged local credential columns. + * + * The final `text` arguments are the existing CSRF/browser binding and device + * token values. The transaction identifier is an opaque token whose digest is + * stored by DB; it is deliberately not modelled as a row UUID. + */ +export const SSO_DB_FUNCTIONS = { + start: 'start_unified_login', + confirm: 'confirm_unified_login', + signIn: 'sign_in_unified_login', + signUp: 'sign_up_unified_login' +} as const; + +type DatabaseRecord = Record; + +interface StartDatabaseResult { + transactionId: string; + site: UnifiedAuthSite; + signInMode: 'CONFIRM_BEFORE_SIGN_IN' | 'SILENT'; + reusableAuthentication: boolean; + currentAccount: UnifiedAuthAccount | null; +} + +const invalidDatabaseResult = (operation: string, cause?: unknown): Error => + errors.INTERNAL_FAILURE( + { details: `Invalid ${operation} result from the unified authentication database function.` }, + undefined, + cause === undefined ? undefined : { cause } + ); + +const asRecord = (value: unknown, operation: string): DatabaseRecord => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw invalidDatabaseResult(operation); + } + return value as DatabaseRecord; +}; + +const requiredString = ( + row: DatabaseRecord, + field: string, + operation: string +): string => { + const value = row[field]; + if (typeof value !== 'string' || value.length === 0) { + throw invalidDatabaseResult(operation); + } + return value; +}; + +const optionalString = ( + row: DatabaseRecord, + field: string, + operation: string +): string | null => { + const value = row[field]; + if (value === null || value === undefined) return null; + if (typeof value !== 'string') throw invalidDatabaseResult(operation); + return value; +}; + +const requiredBoolean = ( + row: DatabaseRecord, + field: string, + operation: string +): boolean => { + const value = row[field]; + if (typeof value !== 'boolean') throw invalidDatabaseResult(operation); + return value; +}; + +type SqlCast = 'boolean' | 'text' | 'uuid'; + +const castValue = ( + value: ReturnType, + cast: SqlCast +): ReturnType => { + switch (cast) { + case 'boolean': + return sql.fragment`${value}::boolean`; + case 'text': + return sql.fragment`${value}::text`; + case 'uuid': + return sql.fragment`${value}::uuid`; + } +}; + +const callFunction = async ( + context: ConstructiveContext, + surface: SsoSurface, + functionName: string, + args: ReturnType[], + casts: SqlCast[] +): Promise => { + const argumentSql = args.map((arg, index) => castValue(arg, casts[index])); + const query = sql.query` + SELECT to_jsonb(operation_result) AS result + FROM ${sql.identifier(surface.privateSchema, functionName)}( + ${sql.join(argumentSql, ', ')} + ) AS operation_result + `; + const compiled = sql.compile(query); + + return context.withPgClient(async client => { + const result = await client.query<{ result: unknown }>( + compiled.text, + compiled.values + ); + if (result.rows.length !== 1) { + throw invalidDatabaseResult(functionName); + } + return asRecord(result.rows[0].result, functionName); + }); +}; + +export const startUnifiedLogin = async ( + context: ConstructiveContext, + surface: SsoSurface, + input: StartUnifiedLoginInput +): Promise => { + const operation = SSO_DB_FUNCTIONS.start; + const row = await callFunction( + context, + surface, + operation, + [ + sql.value(input.siteId), + sql.value(input.callbackUrl ?? null), + sql.value(input.returnTo ?? '/'), + sql.value(input.siteState), + sql.value(input.csrfToken) + ], + ['uuid', 'text', 'text', 'text', 'text'] + ); + const signInMode = requiredString(row, 'sign_in_mode', operation); + if (signInMode !== 'confirm' && signInMode !== 'silent') { + throw invalidDatabaseResult(operation); + } + + const currentUserId = optionalString(row, 'current_user_id', operation); + const currentAccount = currentUserId + ? { + id: currentUserId, + displayName: requiredString(row, 'current_user_display_name', operation), + avatarUrl: optionalString(row, 'current_user_avatar_url', operation) + } + : null; + + return { + transactionId: requiredString(row, 'transaction_id', operation), + site: { + id: requiredString(row, 'site_id', operation), + displayName: requiredString(row, 'site_display_name', operation), + iconUrl: optionalString(row, 'site_icon_url', operation), + themeColor: optionalString(row, 'site_theme_color', operation) + }, + signInMode: signInMode === 'silent' ? 'SILENT' : 'CONFIRM_BEFORE_SIGN_IN', + reusableAuthentication: requiredBoolean( + row, + 'reusable_authentication', + operation + ), + currentAccount + }; +}; + +export const confirmUnifiedLogin = async ( + context: ConstructiveContext, + surface: SsoSurface, + input: ContinueUnifiedLoginInput +): Promise => { + const operation = SSO_DB_FUNCTIONS.confirm; + const row = await callFunction( + context, + surface, + operation, + [sql.value(input.transactionId), sql.value(input.csrfToken)], + ['text', 'text'] + ); + requiredString(row, 'user_id', operation); + return { + transactionId: input.transactionId, + authenticated: true, + // PR 6 adds the shared one-time handoff continuation. + continuationUrl: null + }; +}; + +const authenticateWithPassword = async ( + functionName: typeof SSO_DB_FUNCTIONS.signIn | typeof SSO_DB_FUNCTIONS.signUp, + context: ConstructiveContext, + surface: SsoSurface, + input: UnifiedPasswordInput +): Promise => { + const row = await callFunction( + context, + surface, + functionName, + [ + sql.value(input.transactionId), + sql.value(input.email), + sql.value(input.password), + sql.value(input.rememberMe ?? false), + sql.value('bearer'), + sql.value(input.csrfToken), + sql.value(input.deviceToken ?? null) + ], + ['text', 'text', 'text', 'boolean', 'text', 'text', 'text'] + ); + + // Strict-auth/MFA/step-up integration is explicitly outside v1. The DB + // wrapper must fail closed; this guard prevents an accidental partial result + // from being treated as a completed unified login. + if (row.mfa_required === true) { + throw errors.AUTH_METHOD_NOT_ALLOWED({}); + } + + return { + transactionId: input.transactionId, + authenticated: true, + credentialId: requiredString(row, 'id', functionName), + userId: requiredString(row, 'user_id', functionName), + accessToken: requiredString(row, 'access_token', functionName), + accessTokenExpiresAt: requiredString( + row, + 'access_token_expires_at', + functionName + ), + isVerified: requiredBoolean(row, 'is_verified', functionName), + totpEnabled: requiredBoolean(row, 'totp_enabled', functionName), + // PR 6 adds the shared one-time handoff continuation. + continuationUrl: null + }; +}; + +export const signInUnifiedLogin = ( + context: ConstructiveContext, + surface: SsoSurface, + input: UnifiedPasswordInput +): Promise => + authenticateWithPassword(SSO_DB_FUNCTIONS.signIn, context, surface, input); + +export const signUpUnifiedLogin = ( + context: ConstructiveContext, + surface: SsoSurface, + input: UnifiedPasswordInput +): Promise => + authenticateWithPassword(SSO_DB_FUNCTIONS.signUp, context, surface, input); diff --git a/graphql/server/src/auth/sso/index.ts b/graphql/server/src/auth/sso/index.ts new file mode 100644 index 0000000000..93c2eac3b1 --- /dev/null +++ b/graphql/server/src/auth/sso/index.ts @@ -0,0 +1 @@ +export { createUnifiedAuthPlugin } from './plugin'; diff --git a/graphql/server/src/auth/sso/plugin.ts b/graphql/server/src/auth/sso/plugin.ts new file mode 100644 index 0000000000..7ba36f200f --- /dev/null +++ b/graphql/server/src/auth/sso/plugin.ts @@ -0,0 +1,138 @@ +import type { GraphileConfig } from 'graphile-config'; +import { extendSchema, gql } from 'graphile-utils'; + +import { createUnifiedAuthService } from './service'; +import type { + ContinueUnifiedLoginInput, + StartUnifiedLoginInput, + UnifiedAuthGraphQLContext, + UnifiedPasswordInput +} from './types'; + +interface InputArguments { + input: T; +} + +export const createUnifiedAuthPlugin = ( + oauthEnabled: boolean +): GraphileConfig.Plugin => { + const service = createUnifiedAuthService(oauthEnabled); + + return extendSchema({ + typeDefs: gql` + enum UnifiedAuthSignInMode { + CONFIRM_BEFORE_SIGN_IN + SILENT + } + + type UnifiedAuthProvider { + key: String! + displayName: String! + } + + type UnifiedAuthSite { + id: UUID! + displayName: String! + iconUrl: String + themeColor: String + } + + type UnifiedAuthAccount { + id: UUID! + displayName: String! + avatarUrl: String + } + + type StartUnifiedLoginPayload { + transactionId: String! + site: UnifiedAuthSite! + signInMode: UnifiedAuthSignInMode! + reusableAuthentication: Boolean! + currentAccount: UnifiedAuthAccount + providers: [UnifiedAuthProvider!]! + } + + type UnifiedLoginContinuationPayload { + transactionId: String! + authenticated: Boolean! + continuationUrl: String + } + + type UnifiedLoginCredentialPayload { + transactionId: String! + authenticated: Boolean! + credentialId: UUID! + userId: UUID! + accessToken: String! + accessTokenExpiresAt: Datetime! + isVerified: Boolean! + totpEnabled: Boolean! + continuationUrl: String + } + + input StartUnifiedLoginInput { + siteId: UUID! + callbackUrl: String + returnTo: String + siteState: String! + csrfToken: String! + } + + input ContinueUnifiedLoginInput { + transactionId: String! + csrfToken: String! + } + + input UnifiedPasswordInput { + transactionId: String! + email: String! + password: String! + rememberMe: Boolean = false + csrfToken: String! + deviceToken: String + } + + extend type Query { + unifiedAuthProviders: [UnifiedAuthProvider!]! + } + + extend type Mutation { + startUnifiedLogin(input: StartUnifiedLoginInput!): StartUnifiedLoginPayload! + confirmUnifiedLogin(input: ContinueUnifiedLoginInput!): UnifiedLoginContinuationPayload! + signInUnifiedLogin(input: UnifiedPasswordInput!): UnifiedLoginCredentialPayload! + signUpUnifiedLogin(input: UnifiedPasswordInput!): UnifiedLoginCredentialPayload! + } + `, + resolvers: { + Query: { + unifiedAuthProviders: ( + _source: unknown, + _args: Record, + context: UnifiedAuthGraphQLContext + ) => service.providers(context) + }, + Mutation: { + startUnifiedLogin: ( + _source: unknown, + args: InputArguments, + context: UnifiedAuthGraphQLContext + ) => service.start(context, args.input), + confirmUnifiedLogin: ( + _source: unknown, + args: InputArguments, + context: UnifiedAuthGraphQLContext + ) => service.confirm(context, args.input), + signInUnifiedLogin: ( + _source: unknown, + args: InputArguments, + context: UnifiedAuthGraphQLContext + ) => service.signIn(context, args.input), + signUpUnifiedLogin: ( + _source: unknown, + args: InputArguments, + context: UnifiedAuthGraphQLContext + ) => service.signUp(context, args.input) + } + } + }, 'UnifiedAuthPlugin'); +}; diff --git a/graphql/server/src/auth/sso/service.ts b/graphql/server/src/auth/sso/service.ts new file mode 100644 index 0000000000..37d0158718 --- /dev/null +++ b/graphql/server/src/auth/sso/service.ts @@ -0,0 +1,202 @@ +import { errors } from '@constructive-io/errors'; +import type { + ConstructiveContext, + IdentityProviderConfig, + IdentityProvidersModule, + SsoSurface +} from '@constructive-io/express-context'; +import { + getProviderAdapter, + getProviderAdapterKinds, + type IdentityProviderConfiguration +} from '@constructive-io/oauth'; + +import { + confirmUnifiedLogin, + signInUnifiedLogin, + signUpUnifiedLogin, + startUnifiedLogin +} from './db-contract'; +import type { + ContinueUnifiedLoginInput, + ProviderDisplayOption, + StartUnifiedLoginInput, + StartUnifiedLoginPayload, + UnifiedAuthGraphQLContext, + UnifiedLoginContinuationPayload, + UnifiedLoginCredentialPayload, + UnifiedPasswordInput +} from './types'; + +const OPAQUE_VALUE = /^[A-Za-z0-9_-]{32,256}$/; +const SITE_STATE = /^[A-Za-z0-9_-]{32,128}$/; + +const requireContext = ( + graphQLContext: UnifiedAuthGraphQLContext +): ConstructiveContext => { + if (!graphQLContext.constructive) { + throw errors.INTERNAL_FAILURE({ + details: 'The Constructive request context is unavailable.' + }); + } + return graphQLContext.constructive; +}; + +const resolveSsoSurface = async ( + context: ConstructiveContext +): Promise => { + const surface = await context.useModule('ssoSurface'); + if (!surface) throw errors.SSO_SIGN_IN_DISABLED(); + return surface; +}; + +const validateTransactionInput = (input: ContinueUnifiedLoginInput): void => { + if (!OPAQUE_VALUE.test(input.transactionId) || !OPAQUE_VALUE.test(input.csrfToken)) { + throw errors.SSO_LOGIN_TRANSACTION_EXPIRED(); + } +}; + +const validateStartInput = (input: StartUnifiedLoginInput): void => { + if (!SITE_STATE.test(input.siteState)) { + throw errors.INVALID_SSO_SITE_STATE(); + } + if (!OPAQUE_VALUE.test(input.csrfToken)) { + throw errors.INVALID_SSO_SITE_STATE(); + } + const returnTo = input.returnTo ?? '/'; + if ( + returnTo.length > 2048 || + !returnTo.startsWith('/') || + returnTo.startsWith('//') || + /[\r\n]/.test(returnTo) + ) { + throw errors.INVALID_SSO_RETURN_TARGET(); + } + if (input.callbackUrl && input.callbackUrl.length > 2048) { + throw errors.INVALID_SSO_CALLBACK(); + } +}; + +const toOAuthConfiguration = ( + provider: IdentityProviderConfig +): IdentityProviderConfiguration => ({ + slug: provider.slug, + kind: provider.kind, + displayName: provider.displayName, + enabled: provider.enabled, + clientId: provider.clientId, + clientSecret: provider.clientSecret, + authorizationUrl: provider.authorizationUrl, + tokenUrl: provider.tokenUrl, + userinfoUrl: provider.userinfoUrl, + issuerUrl: provider.issuerUrl, + discoveryDoc: provider.discoveryDoc, + jwks: provider.jwks, + acceptableClientIds: provider.acceptableClientIds, + scopes: provider.scopes, + extraAuthorizationParams: provider.extraAuthorizationParams, + emailOptional: provider.emailOptional, + skipNonceCheck: provider.skipNonceCheck, + pkceEnabled: provider.pkceEnabled +}); + +const providerDisplayOptions = ( + module: IdentityProvidersModule | undefined +): ProviderDisplayOption[] => { + if (!module) return []; + const supportedKinds = new Set(getProviderAdapterKinds()); + const options: ProviderDisplayOption[] = []; + + for (const provider of Object.values(module.providers)) { + if (!provider.enabled || !supportedKinds.has(provider.kind)) continue; + try { + getProviderAdapter(provider.kind).validateConfiguration( + toOAuthConfiguration(provider) + ); + } catch (cause) { + throw errors.IDENTITY_PROVIDER_NOT_CONFIGURED( + {}, + undefined, + { cause } + ); + } + options.push({ key: provider.slug, displayName: provider.displayName }); + } + + return options.sort((left, right) => + left.displayName.localeCompare(right.displayName) || + left.key.localeCompare(right.key) + ); +}; + +const loadProviderDisplayOptions = async ( + context: ConstructiveContext, + oauthEnabled: boolean +): Promise => { + if (!oauthEnabled) return []; + const providers = await context.useModule('identityProviders'); + return providerDisplayOptions(providers); +}; + +export interface UnifiedAuthService { + providers(context: UnifiedAuthGraphQLContext): Promise; + start( + context: UnifiedAuthGraphQLContext, + input: StartUnifiedLoginInput + ): Promise; + confirm( + context: UnifiedAuthGraphQLContext, + input: ContinueUnifiedLoginInput + ): Promise; + signIn( + context: UnifiedAuthGraphQLContext, + input: UnifiedPasswordInput + ): Promise; + signUp( + context: UnifiedAuthGraphQLContext, + input: UnifiedPasswordInput + ): Promise; +} + +export const createUnifiedAuthService = (oauthEnabled: boolean): UnifiedAuthService => ({ + async providers(graphQLContext) { + const context = requireContext(graphQLContext); + const surface = await context.useModule('ssoSurface'); + if (!surface) return []; + return loadProviderDisplayOptions(context, oauthEnabled); + }, + + async start(graphQLContext, input) { + validateStartInput(input); + const context = requireContext(graphQLContext); + const surface = await resolveSsoSurface(context); + // Resolve and validate public Provider options before creating transient + // state so a malformed Tenant Provider cannot leave an unusable login + // transaction behind. + const providers = await loadProviderDisplayOptions(context, oauthEnabled); + const result = await startUnifiedLogin(context, surface, input); + return { ...result, providers }; + }, + + async confirm(graphQLContext, input) { + validateTransactionInput(input); + const context = requireContext(graphQLContext); + const surface = await resolveSsoSurface(context); + if (!context.userId) throw errors.UNAUTHENTICATED(); + return confirmUnifiedLogin(context, surface, input); + }, + + async signIn(graphQLContext, input) { + validateTransactionInput(input); + const context = requireContext(graphQLContext); + const surface = await resolveSsoSurface(context); + return signInUnifiedLogin(context, surface, input); + }, + + async signUp(graphQLContext, input) { + validateTransactionInput(input); + const context = requireContext(graphQLContext); + const surface = await resolveSsoSurface(context); + return signUpUnifiedLogin(context, surface, input); + } +}); diff --git a/graphql/server/src/auth/sso/types.ts b/graphql/server/src/auth/sso/types.ts new file mode 100644 index 0000000000..12a2aca4fc --- /dev/null +++ b/graphql/server/src/auth/sso/types.ts @@ -0,0 +1,68 @@ +import type { ConstructiveContext } from '@constructive-io/express-context'; + +export interface UnifiedAuthGraphQLContext { + constructive?: ConstructiveContext; +} + +export interface ProviderDisplayOption { + key: string; + displayName: string; +} + +export interface StartUnifiedLoginInput { + siteId: string; + callbackUrl?: string | null; + returnTo?: string | null; + siteState: string; + csrfToken: string; +} + +export interface ContinueUnifiedLoginInput { + transactionId: string; + csrfToken: string; +} + +export interface UnifiedPasswordInput extends ContinueUnifiedLoginInput { + email: string; + password: string; + rememberMe?: boolean | null; + deviceToken?: string | null; +} + +export interface UnifiedAuthSite { + id: string; + displayName: string; + iconUrl: string | null; + themeColor: string | null; +} + +export interface UnifiedAuthAccount { + id: string; + displayName: string; + avatarUrl: string | null; +} + +export interface StartUnifiedLoginPayload { + transactionId: string; + site: UnifiedAuthSite; + signInMode: 'CONFIRM_BEFORE_SIGN_IN' | 'SILENT'; + reusableAuthentication: boolean; + currentAccount: UnifiedAuthAccount | null; + providers: ProviderDisplayOption[]; +} + +export interface UnifiedLoginContinuationPayload { + transactionId: string; + authenticated: true; + continuationUrl: string | null; +} + +export interface UnifiedLoginCredentialPayload + extends UnifiedLoginContinuationPayload { + credentialId: string; + userId: string; + accessToken: string; + accessTokenExpiresAt: string; + isVerified: boolean; + totpEnabled: boolean; +} diff --git a/graphql/server/src/middleware/__tests__/grafast-context.test.ts b/graphql/server/src/middleware/__tests__/grafast-context.test.ts new file mode 100644 index 0000000000..7cdb1a9b38 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/grafast-context.test.ts @@ -0,0 +1,21 @@ +import type { ConstructiveContext } from '@constructive-io/express-context'; +import type { Request } from 'express'; + +import { createGrafastRequestContext } from '../grafast-context'; + +describe('createGrafastRequestContext', () => { + it('forwards the exact request Constructive Context object', () => { + const constructive = { requestId: 'request-1' } as ConstructiveContext; + const request = { constructive } as Request; + const pgSettings = { role: 'anonymous' }; + + const context = createGrafastRequestContext(request, pgSettings); + + expect(context.constructive).toBe(constructive); + expect(context.pgSettings).toBe(pgSettings); + }); + + it('does not invent a context when Express did not build one', () => { + expect(createGrafastRequestContext(undefined, {})).toEqual({ pgSettings: {} }); + }); +}); diff --git a/graphql/server/src/middleware/grafast-context.ts b/graphql/server/src/middleware/grafast-context.ts new file mode 100644 index 0000000000..350e2bb2ea --- /dev/null +++ b/graphql/server/src/middleware/grafast-context.ts @@ -0,0 +1,13 @@ +import type { Request } from 'express'; + +/** + * Forward the exact request-owned Constructive Context into Graphile. + * Resolvers must not reconstruct Tenant, route, session, or loader state. + */ +export const createGrafastRequestContext = ( + req: Request | undefined, + pgSettings: Record +): Record => ({ + pgSettings, + ...(req?.constructive ? { constructive: req.constructive } : {}) +}); diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index e6de98f7ad..fda0445522 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -16,11 +16,13 @@ import { createConstructivePreset, makePgService } from 'graphile-settings'; import { getPgPool } from 'pg-cache'; import { getPgEnvOptions } from 'pg-env'; +import { createUnifiedAuthPlugin } from '../auth/sso'; import { isGraphqlObservabilityEnabled } from '../diagnostics/observability'; import { HandlerCreationError } from '../errors/api-errors'; import { respondWithGraphQLError } from '../errors/graphql-response'; import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin'; import type { DatabaseSettings } from '../types'; +import { createGrafastRequestContext } from './grafast-context'; import { observeGraphileBuild } from './observability/graphile-build-stats'; const maskErrorLog = new Logger('graphile:maskError'); @@ -167,12 +169,14 @@ const buildPreset = ( roleName: string, databaseSettings?: DatabaseSettings, apiId?: string, - compute?: ComputeConfig + compute?: ComputeConfig, + oauthEnabled = false ): GraphileConfig.Preset => { return { extends: [createConstructivePreset(databaseSettings)], plugins: [ AuthCookiePlugin, + createUnifiedAuthPlugin(oauthEnabled), // Only registered when the compute module is provisioned for this // database — all schema/table names come from the constructive // metaschema (express-context compute module loader); the plugin has @@ -270,7 +274,7 @@ const buildPreset = ( pgSettings['request.id'] = req.requestId; } - return { pgSettings }; + return createGrafastRequestContext(req, pgSettings); } // Private (in-cluster) surface: there is no token — identity @@ -299,7 +303,7 @@ const buildPreset = ( if (req.requestId) { pgSettings['request.id'] = req.requestId; } - return { pgSettings }; + return createGrafastRequestContext(req, pgSettings); } } @@ -311,9 +315,7 @@ const buildPreset = ( anonSettings['request.id'] = req.requestId; } - return { - pgSettings: anonSettings - }; + return createGrafastRequestContext(req, anonSettings); } } }; @@ -403,7 +405,16 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { // Create promise and store in in-flight map BEFORE try block const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined; - const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute); + const preset = buildPreset( + pool, + schema || [], + anonRole, + roleName, + api.databaseSettings, + api.apiId, + compute, + opts.oauth?.enabled ?? false + ); const creationPromise = observeGraphileBuild( { cacheKey: key, diff --git a/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts b/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts index f4f3ba25bd..1890d9f797 100644 --- a/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts +++ b/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts @@ -1,4 +1,9 @@ import { DEVICE_TOKEN_COOKIE_NAME,SESSION_COOKIE_NAME } from '../../middleware/cookie'; +import { + AuthCookiePlugin, + extractMutationFields, + hasRememberMe +} from '../auth-cookie-plugin'; /** * Since the AuthCookiePlugin is a grafserv middleware plugin, we test @@ -6,32 +11,6 @@ import { DEVICE_TOKEN_COOKIE_NAME,SESSION_COOKIE_NAME } from '../../middleware/c * Full integration tests would require a running PostGraphile instance. */ -// Re-implement the testable functions here for unit testing -// (In a real codebase, these would be exported from a shared module) - -const extractMutationNames = (query: string): string[] => { - const mutations: string[] = []; - - if (!/^\s*mutation\b/i.test(query)) { - return mutations; - } - - const bodyStart = query.indexOf('{'); - if (bodyStart === -1) return mutations; - - const bodyContent = query.slice(bodyStart + 1); - const fieldPattern = /(\w+)\s*(?:\(|{)/g; - let match; - while ((match = fieldPattern.exec(bodyContent)) !== null) { - const name = match[1]; - if (name !== 'mutation' && name !== 'query' && name !== 'fragment') { - mutations.push(name); - } - } - - return mutations; -}; - const extractAccessToken = ( data: Record, mutationName: string @@ -70,11 +49,6 @@ const extractDeviceId = ( return undefined; }; -const hasRememberMe = (variables?: Record): boolean => { - if (!variables) return false; - return variables.rememberMe === true || variables.remember_me === true; -}; - interface CookieConfig { secure: boolean; sameSite: 'strict' | 'lax' | 'none'; @@ -131,25 +105,42 @@ const serializeClearCookie = (name: string, config: CookieConfig): string => { }; describe('AuthCookiePlugin utilities', () => { - describe('extractMutationNames', () => { + describe('extractMutationFields', () => { it('extracts mutation names from query', () => { const query = 'mutation { signIn(email: "test@example.com") { accessToken } }'; - expect(extractMutationNames(query)).toEqual(['signIn']); + expect(extractMutationFields(query)).toEqual([ + { fieldName: 'signIn', responseKey: 'signIn' } + ]); }); it('extracts multiple mutation names', () => { const query = 'mutation { signIn(email: "test") { token } signUp(email: "new") { token } }'; - expect(extractMutationNames(query)).toEqual(['signIn', 'signUp']); + expect(extractMutationFields(query)).toEqual([ + { fieldName: 'signIn', responseKey: 'signIn' }, + { fieldName: 'signUp', responseKey: 'signUp' } + ]); }); it('returns empty array for non-mutation queries', () => { const query = 'query { users { id } }'; - expect(extractMutationNames(query)).toEqual([]); + expect(extractMutationFields(query)).toEqual([]); }); it('handles mutations with no arguments', () => { const query = 'mutation { signOut { success } }'; - expect(extractMutationNames(query)).toEqual(['signOut']); + expect(extractMutationFields(query)).toEqual([ + { fieldName: 'signOut', responseKey: 'signOut' } + ]); + }); + + it('selects a named operation and preserves aliases', () => { + const query = ` + mutation Ignore { signOut { success } } + mutation Unified { auth: signInUnifiedLogin(input: $input) { accessToken } } + `; + expect(extractMutationFields(query, 'Unified')).toEqual([ + { fieldName: 'signInUnifiedLogin', responseKey: 'auth' } + ]); }); }); @@ -211,6 +202,10 @@ describe('AuthCookiePlugin utilities', () => { expect(hasRememberMe({ remember_me: true })).toBe(true); }); + it('detects rememberMe inside an input object', () => { + expect(hasRememberMe({ input: { rememberMe: true } })).toBe(true); + }); + it('returns false when not present', () => { expect(hasRememberMe({})).toBe(false); }); @@ -299,6 +294,73 @@ describe('AuthCookiePlugin utilities', () => { }); }); +describe('AuthCookiePlugin unified-auth cookie boundary', () => { + it('sets an aliased unified-login result as a host-only first-party cookie', async () => { + const setHeader = jest.fn(); + const getHeader = jest.fn(); + const query = ` + mutation Unified($input: UnifiedPasswordInput!) { + auth: signInUnifiedLogin(input: $input) { accessToken } + } + `; + const processRequest = AuthCookiePlugin.grafserv?.middleware?.processRequest; + const callback = typeof processRequest === 'function' + ? processRequest + : processRequest?.callback; + expect(callback).toBeDefined(); + + const next = Object.assign( + async () => ({ + type: 'buffer' as const, + statusCode: 200, + headers: { 'content-type': 'application/json' }, + buffer: Buffer.from(JSON.stringify({ + data: { auth: { accessToken: 'cnc_live_bt_secret' } } + })) + }), + { callback: jest.fn() } + ); + + await callback!( + next, + { + requestDigest: { + method: 'POST', + getBody: async () => ({ + type: 'buffer', + buffer: Buffer.from(JSON.stringify({ + query, + operationName: 'Unified', + variables: { input: { rememberMe: true } } + })) + }), + requestContext: { + expressv4: { + req: { + api: { + authSettings: { + cookieDomain: '.example.com', + cookieSecure: true, + cookieHttponly: true, + cookieSamesite: 'lax' + } + } + }, + res: { setHeader, getHeader } + } + } + } + } as never + ); + + const cookie = (setHeader.mock.calls[0][1] as string[])[0]; + expect(cookie).toContain('constructive_session=cnc_live_bt_secret'); + expect(cookie).toContain('Secure'); + expect(cookie).toContain('HttpOnly'); + expect(cookie).not.toContain('Domain='); + }); +}); + /** * P0 Tests: Auth failure scenarios, multiple mutations, cookie clearing */ diff --git a/graphql/server/src/plugins/auth-cookie-plugin.ts b/graphql/server/src/plugins/auth-cookie-plugin.ts index 6c5a7bce0b..4e5f628cb0 100644 --- a/graphql/server/src/plugins/auth-cookie-plugin.ts +++ b/graphql/server/src/plugins/auth-cookie-plugin.ts @@ -4,6 +4,12 @@ import { Logger } from '@pgpmjs/logger'; import type { Request } from 'express'; import type { BufferResult } from 'grafserv'; import type { GraphileConfig } from 'graphile-config'; +import { + type FragmentDefinitionNode, + Kind, + parse, + type SelectionSetNode +} from 'graphql'; import { CookieConfig, @@ -73,6 +79,8 @@ const serializeClearCookie = (name: string, config: CookieConfig): string => { const SIGN_IN_MUTATIONS = new Set([ 'signIn', 'signUp', + 'signInUnifiedLogin', + 'signUpUnifiedLogin', 'signInSso', 'signUpSso', 'signInMagicLink', @@ -85,6 +93,11 @@ const SIGN_IN_MUTATIONS = new Set([ 'signInCrossOrigin', ]); +const UNIFIED_AUTH_SIGN_IN_MUTATIONS = new Set([ + 'signInUnifiedLogin', + 'signUpUnifiedLogin' +]); + /** * Auth mutations that should clear the session cookie. */ @@ -105,30 +118,65 @@ interface GraphQLResponse { errors?: Array<{ message: string; extensions?: { code?: string } }>; } -/** - * Extract mutation names from a GraphQL query string. - */ -const extractMutationNames = (query: string): string[] => { - const mutations: string[] = []; - - if (!/^\s*mutation\b/i.test(query)) { - return mutations; - } - - const bodyStart = query.indexOf('{'); - if (bodyStart === -1) return mutations; +export interface MutationField { + fieldName: string; + responseKey: string; +} - const bodyContent = query.slice(bodyStart + 1); - const fieldPattern = /(\w+)\s*(?:\(|{)/g; - let match; - while ((match = fieldPattern.exec(bodyContent)) !== null) { - const name = match[1]; - if (name !== 'mutation' && name !== 'query' && name !== 'fragment') { - mutations.push(name); +const collectMutationFields = ( + selectionSet: SelectionSetNode, + fragments: ReadonlyMap, + visited: Set +): MutationField[] => { + const fields: MutationField[] = []; + for (const selection of selectionSet.selections) { + if (selection.kind === Kind.FIELD) { + fields.push({ + fieldName: selection.name.value, + responseKey: selection.alias?.value ?? selection.name.value + }); + continue; + } + if (selection.kind === Kind.INLINE_FRAGMENT) { + fields.push(...collectMutationFields(selection.selectionSet, fragments, visited)); + continue; + } + if (!visited.has(selection.name.value)) { + const fragment = fragments.get(selection.name.value); + if (fragment) { + visited.add(selection.name.value); + fields.push(...collectMutationFields(fragment.selectionSet, fragments, visited)); + } } } + return fields; +}; - return mutations; +/** Parse the selected operation and preserve aliases used as response keys. */ +export const extractMutationFields = ( + query: string, + operationName?: string +): MutationField[] => { + const document = parse(query); + const operations = document.definitions.filter( + definition => definition.kind === Kind.OPERATION_DEFINITION + ); + const operation = operationName + ? operations.find(definition => definition.name?.value === operationName) + : operations.length === 1 + ? operations[0] + : undefined; + if (!operation || operation.operation !== 'mutation') return []; + + const fragments = new Map( + document.definitions + .filter( + (definition): definition is FragmentDefinitionNode => + definition.kind === Kind.FRAGMENT_DEFINITION + ) + .map(fragment => [fragment.name.value, fragment]) + ); + return collectMutationFields(operation.selectionSet, fragments, new Set()); }; /** @@ -179,9 +227,17 @@ const extractDeviceId = ( /** * Check if request includes remember_me flag. */ -const hasRememberMe = (variables?: Record): boolean => { +export const hasRememberMe = (variables?: Record): boolean => { if (!variables) return false; - return variables.rememberMe === true || variables.remember_me === true; + if (variables.rememberMe === true || variables.remember_me === true) return true; + const input = variables.input; + return Boolean( + input && + typeof input === 'object' && + !Array.isArray(input) && + ((input as Record).rememberMe === true || + (input as Record).remember_me === true) + ); }; /** @@ -225,14 +281,10 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { // grafserv provides getBody() which returns { type: 'buffer', buffer: Buffer } let body: GraphQLRequestBody | undefined; if (typeof event.requestDigest.getBody === 'function') { - try { - const rawBody = await event.requestDigest.getBody() as { type?: string; buffer?: Buffer }; - if (rawBody?.type === 'buffer' && rawBody.buffer) { - const jsonStr = rawBody.buffer.toString('utf8'); - body = JSON.parse(jsonStr) as GraphQLRequestBody; - } - } catch (e) { - log.debug('[auth-cookie] Failed to parse body from requestDigest'); + const rawBody = await event.requestDigest.getBody() as { type?: string; buffer?: Buffer }; + if (rawBody?.type === 'buffer' && rawBody.buffer) { + const jsonStr = rawBody.buffer.toString('utf8'); + body = JSON.parse(jsonStr) as GraphQLRequestBody; } } body = body || (req.body as GraphQLRequestBody); @@ -241,99 +293,108 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { } // Extract mutation names - const mutationNames = extractMutationNames(body.query); - if (mutationNames.length === 0) { + const mutationFields = extractMutationFields(body.query, body.operationName); + if (mutationFields.length === 0) { return result; } // Check for auth mutations - const signInMutation = mutationNames.find((m) => SIGN_IN_MUTATIONS.has(m)); - const signOutMutation = mutationNames.find((m) => SIGN_OUT_MUTATIONS.has(m)); + const signInMutation = mutationFields.find(field => + SIGN_IN_MUTATIONS.has(field.fieldName) + ); + const signOutMutation = mutationFields.find(field => + SIGN_OUT_MUTATIONS.has(field.fieldName) + ); if (!signInMutation && !signOutMutation) { return result; } - log.debug(`[auth-cookie] Detected auth mutation: ${signInMutation || signOutMutation}`); + log.debug( + `[auth-cookie] Detected auth mutation: ${ + signInMutation?.fieldName ?? signOutMutation?.fieldName + }` + ); - try { - // Parse response body - const payload = bufferResult.buffer.toString('utf8'); - const graphqlResponse = JSON.parse(payload) as GraphQLResponse; + // Parse response body. Failures deliberately propagate; a logging or + // cookie fallback cannot replace the authentication result semantics. + const payload = bufferResult.buffer.toString('utf8'); + const graphqlResponse = JSON.parse(payload) as GraphQLResponse; - // Skip if there are GraphQL errors - if (graphqlResponse.errors?.length || !graphqlResponse.data) { - return result; - } + // Skip if there are GraphQL errors + if (graphqlResponse.errors?.length || !graphqlResponse.data) { + return result; + } - const data = graphqlResponse.data; - const authSettings = req.api?.authSettings; - const cookiesToSet: string[] = []; - - // Handle sign-out mutations - if (signOutMutation && data[signOutMutation]) { - log.info('[auth-cookie] Sign-out mutation succeeded, clearing session cookie'); - const config = getSessionCookieConfig(authSettings); - cookiesToSet.push(serializeClearCookie(SESSION_COOKIE_NAME, config)); - // Also clear device token on sign-out - const deviceConfig = getDeviceTokenCookieConfig(authSettings); - cookiesToSet.push(serializeClearCookie(DEVICE_TOKEN_COOKIE_NAME, deviceConfig)); - } + const data = graphqlResponse.data; + const authSettings = req.api?.authSettings; + const cookiesToSet: string[] = []; + + // Handle sign-out mutations + if (signOutMutation && data[signOutMutation.responseKey]) { + log.info('[auth-cookie] Sign-out mutation succeeded, clearing session cookie'); + const config = getSessionCookieConfig(authSettings); + cookiesToSet.push(serializeClearCookie(SESSION_COOKIE_NAME, config)); + // Also clear device token on sign-out + const deviceConfig = getDeviceTokenCookieConfig(authSettings); + cookiesToSet.push(serializeClearCookie(DEVICE_TOKEN_COOKIE_NAME, deviceConfig)); + } - // Handle sign-in mutations - if (signInMutation) { - const accessToken = extractAccessToken(data, signInMutation); - if (accessToken) { - const rememberMe = hasRememberMe(body.variables); - const config = getSessionCookieConfig(authSettings, rememberMe); - log.info(`[auth-cookie] Sign-in mutation succeeded, setting session cookie (rememberMe=${rememberMe})`); - cookiesToSet.push(serializeCookie(SESSION_COOKIE_NAME, accessToken, config)); - - const deviceId = extractDeviceId(data, signInMutation); - if (deviceId) { - log.info('[auth-cookie] Device ID returned, setting device token cookie'); - const deviceConfig = getDeviceTokenCookieConfig(authSettings); - cookiesToSet.push(serializeCookie(DEVICE_TOKEN_COOKIE_NAME, deviceId, deviceConfig)); - } + // Handle sign-in mutations + if (signInMutation) { + const accessToken = extractAccessToken(data, signInMutation.responseKey); + if (accessToken) { + const rememberMe = hasRememberMe(body.variables); + const baseConfig = getSessionCookieConfig(authSettings, rememberMe); + // The Tenant auth-center credential is first party and host only. + // A Site receives its own credential during handoff redemption. + const config = UNIFIED_AUTH_SIGN_IN_MUTATIONS.has(signInMutation.fieldName) + ? { ...baseConfig, domain: undefined } + : baseConfig; + log.info(`[auth-cookie] Sign-in mutation succeeded, setting session cookie (rememberMe=${rememberMe})`); + cookiesToSet.push(serializeCookie(SESSION_COOKIE_NAME, accessToken, config)); + + const deviceId = extractDeviceId(data, signInMutation.responseKey); + if (deviceId) { + log.info('[auth-cookie] Device ID returned, setting device token cookie'); + const deviceConfig = getDeviceTokenCookieConfig(authSettings); + cookiesToSet.push(serializeCookie(DEVICE_TOKEN_COOKIE_NAME, deviceId, deviceConfig)); } } + } - // Set cookies directly on Express response and return modified headers - if (cookiesToSet.length > 0) { - const res = (event.requestDigest.requestContext as { expressv4?: { res?: { setHeader: (name: string, value: string[]) => void; getHeader: (name: string) => string | string[] | undefined } } })?.expressv4?.res; - - if (res?.setHeader) { - // Get existing Set-Cookie headers from Express response - const existingCookies = res.getHeader('Set-Cookie'); - const allCookies: string[] = []; - - if (existingCookies) { - if (Array.isArray(existingCookies)) { - allCookies.push(...existingCookies); - } else { - allCookies.push(existingCookies); - } - } - allCookies.push(...cookiesToSet); + // Set cookies directly on Express response and return modified headers + if (cookiesToSet.length > 0) { + const res = (event.requestDigest.requestContext as { expressv4?: { res?: { setHeader: (name: string, value: string[]) => void; getHeader: (name: string) => string | string[] | undefined } } })?.expressv4?.res; + + if (res?.setHeader) { + // Get existing Set-Cookie headers from Express response + const existingCookies = res.getHeader('Set-Cookie'); + const allCookies: string[] = []; - // Set as array to get multiple Set-Cookie headers - res.setHeader('Set-Cookie', allCookies); + if (existingCookies) { + if (Array.isArray(existingCookies)) { + allCookies.push(...existingCookies); + } else { + allCookies.push(existingCookies); + } } + allCookies.push(...cookiesToSet); - // Also update the BufferResult headers for grafserv to pass through - const existingBufferCookie = bufferResult.headers['set-cookie']; - const updatedHeaders = { ...bufferResult.headers }; + // Set as array to get multiple Set-Cookie headers + res.setHeader('Set-Cookie', allCookies); + } - // Remove set-cookie from grafserv headers since we set it on Express - delete updatedHeaders['set-cookie']; + // Also update the BufferResult headers for grafserv to pass through + const updatedHeaders = { ...bufferResult.headers }; - return { - ...bufferResult, - headers: updatedHeaders, - }; - } - } catch (err) { - log.error('[auth-cookie] Error processing auth response:', err); + // Remove set-cookie from grafserv headers since we set it on Express + delete updatedHeaders['set-cookie']; + + return { + ...bufferResult, + headers: updatedHeaders, + }; } return result; diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 8ddd11c483..20eb62d20d 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -1,5 +1,11 @@ import { createCsrfMiddleware } from '@constructive-io/csrf'; -import { createContextMiddleware, createDefaultRegistry, requestIdMiddleware } from '@constructive-io/express-context'; +import { + createContextMiddleware, + createDefaultRegistry, + identityProvidersLoader, + requestIdMiddleware, + ssoSurfaceLoader +} from '@constructive-io/express-context'; import { getEnvOptions } from '@constructive-io/graphql-env'; import type { ConstructiveOptions } from '@constructive-io/graphql-types'; import { middleware as parseDomains } from '@constructive-io/url-domains'; @@ -93,6 +99,11 @@ class Server { const api = createApiMiddleware(effectiveOpts); const authenticate = createAuthenticateMiddleware(effectiveOpts); const requestLogger = createRequestLogger({ observabilityEnabled }); + const contextLoaders = createDefaultRegistry(); + contextLoaders.register(ssoSurfaceLoader); + if (effectiveOpts.oauth?.enabled) { + contextLoaders.register(identityProvidersLoader); + } // Log startup configuration (non-sensitive values only) const apiOpts = (effectiveOpts as any).api || {}; @@ -165,7 +176,7 @@ class Server { app.use(authenticate); app.use(createContextMiddleware({ pg: effectiveOpts.pg, - loaders: createDefaultRegistry(), + loaders: contextLoaders, routingSchema: getRoutingSchema(effectiveOpts) })); app.use(createCaptchaMiddleware()); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3a8802ef0..e316102d2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1960,6 +1960,9 @@ importers: '@constructive-io/llm-env': specifier: workspace:^ version: link:../../packages/llm-env/dist + '@constructive-io/oauth': + specifier: workspace:^ + version: link:../../packages/oauth/dist '@constructive-io/query-builder': specifier: workspace:^ version: link:../../postgres/query-builder/dist From d5b445d8d94951b6b94239ae7597e76bde84a277 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Sun, 9 Aug 2026 23:53:57 +0800 Subject: [PATCH 05/11] fix: bind unified login to request browser --- .../src/auth/sso/__tests__/service.test.ts | 29 +++++++--- graphql/server/src/auth/sso/db-contract.ts | 56 ++++++++++++------- graphql/server/src/auth/sso/plugin.ts | 3 - graphql/server/src/auth/sso/service.ts | 26 ++++++--- graphql/server/src/auth/sso/types.ts | 4 +- .../__tests__/grafast-context.test.ts | 13 ++++- .../server/src/middleware/grafast-context.ts | 6 +- packages/csrf/src/index.ts | 1 + packages/csrf/src/middleware.ts | 4 +- 9 files changed, 98 insertions(+), 44 deletions(-) diff --git a/graphql/server/src/auth/sso/__tests__/service.test.ts b/graphql/server/src/auth/sso/__tests__/service.test.ts index d44767bc9f..b3eb344ba9 100644 --- a/graphql/server/src/auth/sso/__tests__/service.test.ts +++ b/graphql/server/src/auth/sso/__tests__/service.test.ts @@ -101,12 +101,11 @@ describe('unified authentication GraphQL service', () => { const service = createUnifiedAuthService(true); const result = await service.start( - { constructive: context }, + { constructive: context, browserBinding: opaque }, { siteId: '00000000-0000-0000-0000-000000000001', returnTo: '/approvals/42', - siteState: opaque, - csrfToken: opaque + siteState: opaque } ); @@ -139,13 +138,12 @@ describe('unified authentication GraphQL service', () => { const service = createUnifiedAuthService(false); const result = await service.signIn( - { constructive: context }, + { constructive: context, browserBinding: opaque }, { transactionId: opaque, email: 'user@example.com', password: 'correct horse battery staple', - rememberMe: true, - csrfToken: opaque + rememberMe: true } ); @@ -171,14 +169,27 @@ describe('unified authentication GraphQL service', () => { const service = createUnifiedAuthService(false); await expect(service.start( - { constructive: context }, + { constructive: context, browserBinding: opaque }, { siteId: '00000000-0000-0000-0000-000000000001', returnTo: 'https://evil.example/steal', - siteState: opaque, - csrfToken: opaque + siteState: opaque } )).rejects.toMatchObject({ code: 'INVALID_SSO_RETURN_TARGET' }); expect(query).not.toHaveBeenCalled(); }); + + it('requires the server-read first-party browser binding', async () => { + const { context, query } = makeContext(); + const service = createUnifiedAuthService(false); + + await expect(service.start( + { constructive: context }, + { + siteId: '00000000-0000-0000-0000-000000000001', + siteState: opaque + } + )).rejects.toMatchObject({ code: 'INVALID_SSO_SITE_STATE' }); + expect(query).not.toHaveBeenCalled(); + }); }); diff --git a/graphql/server/src/auth/sso/db-contract.ts b/graphql/server/src/auth/sso/db-contract.ts index ee6906db34..ed958b4286 100644 --- a/graphql/server/src/auth/sso/db-contract.ts +++ b/graphql/server/src/auth/sso/db-contract.ts @@ -29,9 +29,10 @@ import type { * - `sign_in_unified_login(text, text, text, boolean, text, text, text)` and * `sign_up_unified_login(...)` return the unchanged local credential columns. * - * The final `text` arguments are the existing CSRF/browser binding and device - * token values. The transaction identifier is an opaque token whose digest is - * stored by DB; it is deliberately not modelled as a row UUID. + * The final `text` arguments are the server-read authentication-center browser + * binding and device-token values. The transaction identifier is an opaque + * token whose digest is stored by DB; it is deliberately not modelled as a row + * UUID. */ export const SSO_DB_FUNCTIONS = { start: 'start_unified_login', @@ -104,12 +105,12 @@ const castValue = ( cast: SqlCast ): ReturnType => { switch (cast) { - case 'boolean': - return sql.fragment`${value}::boolean`; - case 'text': - return sql.fragment`${value}::text`; - case 'uuid': - return sql.fragment`${value}::uuid`; + case 'boolean': + return sql.fragment`${value}::boolean`; + case 'text': + return sql.fragment`${value}::text`; + case 'uuid': + return sql.fragment`${value}::uuid`; } }; @@ -144,7 +145,8 @@ const callFunction = async ( export const startUnifiedLogin = async ( context: ConstructiveContext, surface: SsoSurface, - input: StartUnifiedLoginInput + input: StartUnifiedLoginInput, + browserBinding: string ): Promise => { const operation = SSO_DB_FUNCTIONS.start; const row = await callFunction( @@ -156,7 +158,7 @@ export const startUnifiedLogin = async ( sql.value(input.callbackUrl ?? null), sql.value(input.returnTo ?? '/'), sql.value(input.siteState), - sql.value(input.csrfToken) + sql.value(browserBinding) ], ['uuid', 'text', 'text', 'text', 'text'] ); @@ -195,14 +197,15 @@ export const startUnifiedLogin = async ( export const confirmUnifiedLogin = async ( context: ConstructiveContext, surface: SsoSurface, - input: ContinueUnifiedLoginInput + input: ContinueUnifiedLoginInput, + browserBinding: string ): Promise => { const operation = SSO_DB_FUNCTIONS.confirm; const row = await callFunction( context, surface, operation, - [sql.value(input.transactionId), sql.value(input.csrfToken)], + [sql.value(input.transactionId), sql.value(browserBinding)], ['text', 'text'] ); requiredString(row, 'user_id', operation); @@ -218,7 +221,8 @@ const authenticateWithPassword = async ( functionName: typeof SSO_DB_FUNCTIONS.signIn | typeof SSO_DB_FUNCTIONS.signUp, context: ConstructiveContext, surface: SsoSurface, - input: UnifiedPasswordInput + input: UnifiedPasswordInput, + browserBinding: string ): Promise => { const row = await callFunction( context, @@ -230,7 +234,7 @@ const authenticateWithPassword = async ( sql.value(input.password), sql.value(input.rememberMe ?? false), sql.value('bearer'), - sql.value(input.csrfToken), + sql.value(browserBinding), sql.value(input.deviceToken ?? null) ], ['text', 'text', 'text', 'boolean', 'text', 'text', 'text'] @@ -264,13 +268,27 @@ const authenticateWithPassword = async ( export const signInUnifiedLogin = ( context: ConstructiveContext, surface: SsoSurface, - input: UnifiedPasswordInput + input: UnifiedPasswordInput, + browserBinding: string ): Promise => - authenticateWithPassword(SSO_DB_FUNCTIONS.signIn, context, surface, input); + authenticateWithPassword( + SSO_DB_FUNCTIONS.signIn, + context, + surface, + input, + browserBinding + ); export const signUpUnifiedLogin = ( context: ConstructiveContext, surface: SsoSurface, - input: UnifiedPasswordInput + input: UnifiedPasswordInput, + browserBinding: string ): Promise => - authenticateWithPassword(SSO_DB_FUNCTIONS.signUp, context, surface, input); + authenticateWithPassword( + SSO_DB_FUNCTIONS.signUp, + context, + surface, + input, + browserBinding + ); diff --git a/graphql/server/src/auth/sso/plugin.ts b/graphql/server/src/auth/sso/plugin.ts index 7ba36f200f..1f86fca6aa 100644 --- a/graphql/server/src/auth/sso/plugin.ts +++ b/graphql/server/src/auth/sso/plugin.ts @@ -75,12 +75,10 @@ export const createUnifiedAuthPlugin = ( callbackUrl: String returnTo: String siteState: String! - csrfToken: String! } input ContinueUnifiedLoginInput { transactionId: String! - csrfToken: String! } input UnifiedPasswordInput { @@ -88,7 +86,6 @@ export const createUnifiedAuthPlugin = ( email: String! password: String! rememberMe: Boolean = false - csrfToken: String! deviceToken: String } diff --git a/graphql/server/src/auth/sso/service.ts b/graphql/server/src/auth/sso/service.ts index 37d0158718..77b5e58948 100644 --- a/graphql/server/src/auth/sso/service.ts +++ b/graphql/server/src/auth/sso/service.ts @@ -51,16 +51,22 @@ const resolveSsoSurface = async ( }; const validateTransactionInput = (input: ContinueUnifiedLoginInput): void => { - if (!OPAQUE_VALUE.test(input.transactionId) || !OPAQUE_VALUE.test(input.csrfToken)) { + if (!OPAQUE_VALUE.test(input.transactionId)) { throw errors.SSO_LOGIN_TRANSACTION_EXPIRED(); } }; -const validateStartInput = (input: StartUnifiedLoginInput): void => { - if (!SITE_STATE.test(input.siteState)) { +const requireBrowserBinding = ( + graphQLContext: UnifiedAuthGraphQLContext +): string => { + if (!graphQLContext.browserBinding || !OPAQUE_VALUE.test(graphQLContext.browserBinding)) { throw errors.INVALID_SSO_SITE_STATE(); } - if (!OPAQUE_VALUE.test(input.csrfToken)) { + return graphQLContext.browserBinding; +}; + +const validateStartInput = (input: StartUnifiedLoginInput): void => { + if (!SITE_STATE.test(input.siteState)) { throw errors.INVALID_SSO_SITE_STATE(); } const returnTo = input.returnTo ?? '/'; @@ -169,34 +175,38 @@ export const createUnifiedAuthService = (oauthEnabled: boolean): UnifiedAuthServ async start(graphQLContext, input) { validateStartInput(input); const context = requireContext(graphQLContext); + const browserBinding = requireBrowserBinding(graphQLContext); const surface = await resolveSsoSurface(context); // Resolve and validate public Provider options before creating transient // state so a malformed Tenant Provider cannot leave an unusable login // transaction behind. const providers = await loadProviderDisplayOptions(context, oauthEnabled); - const result = await startUnifiedLogin(context, surface, input); + const result = await startUnifiedLogin(context, surface, input, browserBinding); return { ...result, providers }; }, async confirm(graphQLContext, input) { validateTransactionInput(input); const context = requireContext(graphQLContext); + const browserBinding = requireBrowserBinding(graphQLContext); const surface = await resolveSsoSurface(context); if (!context.userId) throw errors.UNAUTHENTICATED(); - return confirmUnifiedLogin(context, surface, input); + return confirmUnifiedLogin(context, surface, input, browserBinding); }, async signIn(graphQLContext, input) { validateTransactionInput(input); const context = requireContext(graphQLContext); + const browserBinding = requireBrowserBinding(graphQLContext); const surface = await resolveSsoSurface(context); - return signInUnifiedLogin(context, surface, input); + return signInUnifiedLogin(context, surface, input, browserBinding); }, async signUp(graphQLContext, input) { validateTransactionInput(input); const context = requireContext(graphQLContext); + const browserBinding = requireBrowserBinding(graphQLContext); const surface = await resolveSsoSurface(context); - return signUpUnifiedLogin(context, surface, input); + return signUpUnifiedLogin(context, surface, input, browserBinding); } }); diff --git a/graphql/server/src/auth/sso/types.ts b/graphql/server/src/auth/sso/types.ts index 12a2aca4fc..a5166c5692 100644 --- a/graphql/server/src/auth/sso/types.ts +++ b/graphql/server/src/auth/sso/types.ts @@ -2,6 +2,8 @@ import type { ConstructiveContext } from '@constructive-io/express-context'; export interface UnifiedAuthGraphQLContext { constructive?: ConstructiveContext; + /** Server-read authentication-center first-party browser binding. */ + browserBinding?: string; } export interface ProviderDisplayOption { @@ -14,12 +16,10 @@ export interface StartUnifiedLoginInput { callbackUrl?: string | null; returnTo?: string | null; siteState: string; - csrfToken: string; } export interface ContinueUnifiedLoginInput { transactionId: string; - csrfToken: string; } export interface UnifiedPasswordInput extends ContinueUnifiedLoginInput { diff --git a/graphql/server/src/middleware/__tests__/grafast-context.test.ts b/graphql/server/src/middleware/__tests__/grafast-context.test.ts index 7cdb1a9b38..127a7cdf25 100644 --- a/graphql/server/src/middleware/__tests__/grafast-context.test.ts +++ b/graphql/server/src/middleware/__tests__/grafast-context.test.ts @@ -6,16 +6,27 @@ import { createGrafastRequestContext } from '../grafast-context'; describe('createGrafastRequestContext', () => { it('forwards the exact request Constructive Context object', () => { const constructive = { requestId: 'request-1' } as ConstructiveContext; - const request = { constructive } as Request; + const request = { + constructive, + cookies: { csrf_token: 'browser-binding' } + } as unknown as Request; const pgSettings = { role: 'anonymous' }; const context = createGrafastRequestContext(request, pgSettings); expect(context.constructive).toBe(constructive); expect(context.pgSettings).toBe(pgSettings); + expect(context.browserBinding).toBe('browser-binding'); }); it('does not invent a context when Express did not build one', () => { expect(createGrafastRequestContext(undefined, {})).toEqual({ pgSettings: {} }); }); + + it('does not accept a non-string browser binding', () => { + const request = { + cookies: { csrf_token: ['not', 'a', 'token'] } + } as unknown as Request; + expect(createGrafastRequestContext(request, {})).toEqual({ pgSettings: {} }); + }); }); diff --git a/graphql/server/src/middleware/grafast-context.ts b/graphql/server/src/middleware/grafast-context.ts index 350e2bb2ea..d7140699c1 100644 --- a/graphql/server/src/middleware/grafast-context.ts +++ b/graphql/server/src/middleware/grafast-context.ts @@ -1,3 +1,4 @@ +import { DEFAULT_CSRF_COOKIE_NAME } from '@constructive-io/csrf'; import type { Request } from 'express'; /** @@ -9,5 +10,8 @@ export const createGrafastRequestContext = ( pgSettings: Record ): Record => ({ pgSettings, - ...(req?.constructive ? { constructive: req.constructive } : {}) + ...(req?.constructive ? { constructive: req.constructive } : {}), + ...(typeof req?.cookies?.[DEFAULT_CSRF_COOKIE_NAME] === 'string' + ? { browserBinding: req.cookies[DEFAULT_CSRF_COOKIE_NAME] } + : {}) }); diff --git a/packages/csrf/src/index.ts b/packages/csrf/src/index.ts index 5be66dcd2e..735c7d5da5 100644 --- a/packages/csrf/src/index.ts +++ b/packages/csrf/src/index.ts @@ -4,6 +4,7 @@ export { CsrfMiddlewareResult, CsrfRequest, CsrfResponse, + DEFAULT_CSRF_COOKIE_NAME, } from './middleware'; export { generateToken, verifyToken } from './token'; export { CookieOptions, createCsrfError,CsrfConfig, CsrfError } from './types'; diff --git a/packages/csrf/src/middleware.ts b/packages/csrf/src/middleware.ts index c8dbe74fca..772b9d725d 100644 --- a/packages/csrf/src/middleware.ts +++ b/packages/csrf/src/middleware.ts @@ -1,8 +1,10 @@ import { generateToken, verifyToken } from './token'; import { CookieOptions, createCsrfError,CsrfConfig } from './types'; +export const DEFAULT_CSRF_COOKIE_NAME = 'csrf_token'; + const DEFAULT_CONFIG: Required = { - cookieName: 'csrf_token', + cookieName: DEFAULT_CSRF_COOKIE_NAME, headerName: 'x-csrf-token', fieldName: '_csrf', cookieOptions: { From be459815a4ee0f9a540183d98a5365577a2bfc30 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Sun, 9 Aug 2026 23:57:33 +0800 Subject: [PATCH 06/11] fix: enforce auth center cookie minimums --- .../src/plugins/__tests__/auth-cookie-plugin.test.ts | 4 ++-- graphql/server/src/plugins/auth-cookie-plugin.ts | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts b/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts index 1890d9f797..79cf1d3e32 100644 --- a/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts +++ b/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts @@ -340,8 +340,8 @@ describe('AuthCookiePlugin unified-auth cookie boundary', () => { api: { authSettings: { cookieDomain: '.example.com', - cookieSecure: true, - cookieHttponly: true, + cookieSecure: false, + cookieHttponly: false, cookieSamesite: 'lax' } } diff --git a/graphql/server/src/plugins/auth-cookie-plugin.ts b/graphql/server/src/plugins/auth-cookie-plugin.ts index 4e5f628cb0..15b45f5639 100644 --- a/graphql/server/src/plugins/auth-cookie-plugin.ts +++ b/graphql/server/src/plugins/auth-cookie-plugin.ts @@ -349,7 +349,12 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { // The Tenant auth-center credential is first party and host only. // A Site receives its own credential during handoff redemption. const config = UNIFIED_AUTH_SIGN_IN_MUTATIONS.has(signInMutation.fieldName) - ? { ...baseConfig, domain: undefined } + ? { + ...baseConfig, + domain: undefined, + httpOnly: true, + secure: true + } : baseConfig; log.info(`[auth-cookie] Sign-in mutation succeeded, setting session cookie (rememberMe=${rememberMe})`); cookiesToSet.push(serializeCookie(SESSION_COOKIE_NAME, accessToken, config)); From f38c91470f54d4d230e5981bd7e00611881987e4 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 10 Aug 2026 00:19:04 +0800 Subject: [PATCH 07/11] feat: add OAuth provider HTTP flow --- .../src/auth/oauth/__tests__/router.test.ts | 106 +++++++++ .../src/auth/oauth/__tests__/service.test.ts | 175 +++++++++++++++ graphql/server/src/auth/oauth/index.ts | 1 + graphql/server/src/auth/oauth/page.ts | 34 +++ graphql/server/src/auth/oauth/router.ts | 138 ++++++++++++ graphql/server/src/auth/oauth/service.ts | 128 +++++++++++ .../sso/__tests__/plugin.integration.test.ts | 3 +- .../src/auth/sso/__tests__/service.test.ts | 45 +++- graphql/server/src/auth/sso/db-contract.ts | 19 +- graphql/server/src/auth/sso/plugin.ts | 18 +- .../server/src/auth/sso/provider-config.ts | 106 +++++++++ .../src/auth/sso/provider-db-contract.ts | 207 ++++++++++++++++++ graphql/server/src/auth/sso/service.ts | 131 ++++++----- graphql/server/src/auth/sso/types.ts | 9 + .../request-logger-redaction.test.ts | 23 ++ .../observability/request-logger.ts | 34 ++- graphql/server/src/server.ts | 6 + packages/errors/__tests__/sso.test.ts | 2 + packages/errors/src/registry.ts | 12 + .../express-context/__tests__/context.test.ts | 21 ++ packages/express-context/src/context.ts | 21 +- packages/express-context/src/index.ts | 6 +- packages/express-context/src/types.ts | 2 + 23 files changed, 1165 insertions(+), 82 deletions(-) create mode 100644 graphql/server/src/auth/oauth/__tests__/router.test.ts create mode 100644 graphql/server/src/auth/oauth/__tests__/service.test.ts create mode 100644 graphql/server/src/auth/oauth/index.ts create mode 100644 graphql/server/src/auth/oauth/page.ts create mode 100644 graphql/server/src/auth/oauth/router.ts create mode 100644 graphql/server/src/auth/oauth/service.ts create mode 100644 graphql/server/src/auth/sso/provider-config.ts create mode 100644 graphql/server/src/auth/sso/provider-db-contract.ts create mode 100644 graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts create mode 100644 packages/express-context/__tests__/context.test.ts diff --git a/graphql/server/src/auth/oauth/__tests__/router.test.ts b/graphql/server/src/auth/oauth/__tests__/router.test.ts new file mode 100644 index 0000000000..3abe3b5b04 --- /dev/null +++ b/graphql/server/src/auth/oauth/__tests__/router.test.ts @@ -0,0 +1,106 @@ +import { errors } from '@constructive-io/errors'; +import type { ConstructiveContext } from '@constructive-io/express-context'; +import express from 'express'; +import supertest from 'supertest'; + +import { createOAuthRouter } from '../router'; +import { + completeProviderAuthentication, + createProviderAuthorizationUrl +} from '../service'; + +jest.mock('../service', () => ({ + completeProviderAuthentication: jest.fn(), + createProviderAuthorizationUrl: jest.fn() +})); + +const mockedAuthorize = jest.mocked(createProviderAuthorizationUrl); +const mockedComplete = jest.mocked(completeProviderAuthentication); +const opaqueState = 's'.repeat(43); + +const makeApp = () => { + const app = express(); + const context = { + useModule: jest.fn(async () => ({ privateSchema: 'tenant_sso_private' })) + } as unknown as ConstructiveContext; + app.use((req, _res, next) => { + req.constructive = context; + req.cookies = { csrf_token: 'b'.repeat(64) }; + req.api = { + dbname: 'tenant', + anonRole: 'anonymous', + roleName: 'anonymous', + schema: [], + authSettings: { + cookieDomain: '.example.com', + cookieSecure: false, + cookieHttponly: false + } + }; + next(); + }); + app.use('/auth/oauth', createOAuthRouter({ requestTimeoutMs: 1000 })); + return app; +}; + +describe('OAuth HTTP routes', () => { + beforeEach(() => jest.clearAllMocks()); + + it('redirects authorize using only the server-restored adapter URL', async () => { + mockedAuthorize.mockResolvedValue( + 'https://github.com/login/oauth/authorize?state=provider-state' + ); + + const response = await supertest(makeApp()) + .get(`/auth/oauth/authorize?state=${opaqueState}`) + .expect(303); + + expect(response.headers.location).toBe( + 'https://github.com/login/oauth/authorize?state=provider-state' + ); + expect(response.headers['cache-control']).toBe('no-store'); + expect(response.headers['referrer-policy']).toBe('no-referrer'); + }); + + it('sets a Secure HttpOnly host-only auth-center cookie after callback', async () => { + mockedComplete.mockResolvedValue({ + credentialId: '00000000-0000-0000-0000-000000000001', + userId: '00000000-0000-0000-0000-000000000002', + accessToken: 'cnc_auth_center_token', + accessTokenExpiresAt: '2026-08-10T12:00:00.000Z', + isVerified: true, + totpEnabled: false, + continuationUrl: null + }); + + const response = await supertest(makeApp()) + .get(`/auth/oauth/callback?state=${opaqueState}&code=provider-code`) + .expect(200); + + const cookie = response.headers['set-cookie'][0] as string; + expect(cookie).toContain('constructive_session=cnc_auth_center_token'); + expect(cookie).toContain('Secure'); + expect(cookie).toContain('HttpOnly'); + expect(cookie).not.toContain('Domain='); + expect(response.text).not.toContain('cnc_auth_center_token'); + }); + + it('returns only a stable safe cancellation classification', async () => { + mockedComplete.mockRejectedValue(errors.OAUTH_AUTHORIZATION_CANCELLED()); + + const response = await supertest(makeApp()) + .get( + `/auth/oauth/callback?state=${opaqueState}` + + '&error=access_denied&error_description=provider-secret-detail' + ) + .expect(400); + + expect(response.text).toContain('OAUTH_AUTHORIZATION_CANCELLED'); + expect(response.text).not.toContain('provider-secret-detail'); + expect(mockedComplete).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ providerReturnedError: true }) + ); + }); +}); diff --git a/graphql/server/src/auth/oauth/__tests__/service.test.ts b/graphql/server/src/auth/oauth/__tests__/service.test.ts new file mode 100644 index 0000000000..fe44bd4cb2 --- /dev/null +++ b/graphql/server/src/auth/oauth/__tests__/service.test.ts @@ -0,0 +1,175 @@ +import type { + ConstructiveContext, + IdentityProviderConfig, + SsoSurface +} from '@constructive-io/express-context'; +import type { PoolClient, QueryResult } from 'pg'; + +import { + completeProviderAuthentication, + createProviderAuthorizationUrl +} from '../service'; + +const opaqueState = 's'.repeat(43); +const browserBinding = 'b'.repeat(64); +const verifier = 'v'.repeat(43); +const surface: SsoSurface = { privateSchema: 'tenant_acme_sso_private' }; + +const githubProvider: IdentityProviderConfig = { + id: 'provider-id', + slug: 'github-enterprise', + kind: 'github', + displayName: 'GitHub', + enabled: true, + clientId: 'client-id', + clientSecret: 'client-secret', + authorizationUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + userinfoUrl: 'https://api.github.com/user', + issuerUrl: null, + discoveryUrlOverride: null, + discoveryDoc: null, + jwks: null, + jwksFetchedAt: null, + acceptableClientIds: [], + scopes: ['read:user', 'user:email'], + extraAuthorizationParams: {}, + emailOptional: false, + allowLinkByEmail: false, + skipNonceCheck: false, + pkceEnabled: true +}; + +const createContext = (results: Record[]) => { + const query = jest.fn(async (..._args: unknown[]) => ({ + rows: [{ result: results.shift() }] + } as unknown as QueryResult)); + const client = { query } as unknown as PoolClient; + const context = { + useModule: jest.fn(async (name: string) => name === 'identityProviders' + ? { + providers: { [githubProvider.slug]: githubProvider }, + source: { schemaName: 'private', tableName: 'identity_providers' } + } + : undefined), + withPgClient: jest.fn(async (callback: (pg: PoolClient) => Promise) => + callback(client) + ) + } as unknown as ConstructiveContext; + return { context, query }; +}; + +describe('Provider OAuth orchestration', () => { + it('rejects malformed state before database access', async () => { + const { context, query } = createContext([]); + await expect(createProviderAuthorizationUrl( + context, + surface, + 'not-a-state', + browserBinding + )).rejects.toMatchObject({ code: 'INVALID_OAUTH_STATE' }); + expect(query).not.toHaveBeenCalled(); + }); + + it('builds authorization through the configured adapter without exposing verifier', async () => { + const { context } = createContext([{ + oauth_request_id: '00000000-0000-0000-0000-000000000001', + provider_key: githubProvider.slug, + code_verifier: verifier, + nonce: null, + redirect_uri: 'https://auth.example.com/auth/oauth/callback' + }]); + + const url = await createProviderAuthorizationUrl( + context, + surface, + opaqueState, + browserBinding + ); + const parsed = new URL(url); + expect(parsed.origin).toBe('https://github.com'); + expect(parsed.searchParams.get('state')).toBe(opaqueState); + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + expect(parsed.searchParams.get('code_challenge')).not.toBe(verifier); + expect(url).not.toContain(verifier); + }); + + it('consumes state, mocks only Provider HTTP, and applies normalized identity', async () => { + const { context, query } = createContext([ + { + oauth_request_id: '00000000-0000-0000-0000-000000000001', + provider_key: githubProvider.slug, + code_verifier: verifier, + nonce: null, + redirect_uri: 'https://auth.example.com/auth/oauth/callback' + }, + { + id: '00000000-0000-0000-0000-000000000002', + user_id: '00000000-0000-0000-0000-000000000003', + access_token: 'cnc_auth_center_token', + access_token_expires_at: '2026-08-10T12:00:00.000Z', + is_verified: true, + totp_enabled: false, + mfa_required: false, + continuation_url: null + } + ]); + const providerFetch = jest.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ + access_token: 'github-server-token' + }), { status: 200, headers: { 'content-type': 'application/json' } })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 12345, + login: 'octocat', + name: 'Octo Cat', + email: 'octo@example.com', + avatar_url: 'https://avatars.githubusercontent.com/u/12345' + }), { status: 200, headers: { 'content-type': 'application/json' } })); + + const result = await completeProviderAuthentication(context, surface, { + state: opaqueState, + code: 'provider-authorization-code', + providerReturnedError: false, + browserBinding, + requestTimeoutMs: 1000, + fetch: providerFetch as typeof fetch + }); + + expect(result.accessToken).toBe('cnc_auth_center_token'); + expect(providerFetch).toHaveBeenCalledTimes(2); + expect(query).toHaveBeenCalledTimes(2); + expect(query.mock.calls[1]?.[1]).toEqual([ + '00000000-0000-0000-0000-000000000001', + githubProvider.slug, + '12345', + 'octo@example.com', + JSON.stringify({ + name: 'Octo Cat', + username: 'octocat', + avatarUrl: 'https://avatars.githubusercontent.com/u/12345' + }), + 'bearer', + false, + browserBinding + ]); + }); + + it('consumes a cancelled Provider callback before returning a safe error', async () => { + const { context, query } = createContext([{ + oauth_request_id: '00000000-0000-0000-0000-000000000001', + provider_key: githubProvider.slug, + code_verifier: verifier, + nonce: null, + redirect_uri: 'https://auth.example.com/auth/oauth/callback' + }]); + + await expect(completeProviderAuthentication(context, surface, { + state: opaqueState, + providerReturnedError: true, + browserBinding, + requestTimeoutMs: 1000 + })).rejects.toMatchObject({ code: 'OAUTH_AUTHORIZATION_CANCELLED' }); + expect(query).toHaveBeenCalledTimes(1); + expect(context.useModule).not.toHaveBeenCalledWith('identityProviders'); + }); +}); diff --git a/graphql/server/src/auth/oauth/index.ts b/graphql/server/src/auth/oauth/index.ts new file mode 100644 index 0000000000..cf9900cf86 --- /dev/null +++ b/graphql/server/src/auth/oauth/index.ts @@ -0,0 +1 @@ +export { createOAuthRouter, type OAuthRouterOptions } from './router'; diff --git a/graphql/server/src/auth/oauth/page.ts b/graphql/server/src/auth/oauth/page.ts new file mode 100644 index 0000000000..c31554a296 --- /dev/null +++ b/graphql/server/src/auth/oauth/page.ts @@ -0,0 +1,34 @@ +import type { ConstructiveError } from '@constructive-io/errors'; + +const escapeHtml = (value: string): string => + value.replace(/[&<>'"]/g, character => ({ + '&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"' + })[character] ?? character); + +const page = (title: string, body: string): string => ` + + + + + ${escapeHtml(title)} + + +
+

${escapeHtml(title)}

+

${escapeHtml(body)}

+
+ +`; + +export const renderOAuthFailurePage = (error: ConstructiveError): string => + page('External sign in failed', `${error.message} (${error.code})`); + +export const renderOAuthSuccessPage = (): string => + page( + 'External sign in completed', + 'Authentication succeeded. You may close this page.' + ); diff --git a/graphql/server/src/auth/oauth/router.ts b/graphql/server/src/auth/oauth/router.ts new file mode 100644 index 0000000000..8530ab0dcc --- /dev/null +++ b/graphql/server/src/auth/oauth/router.ts @@ -0,0 +1,138 @@ +import { DEFAULT_CSRF_COOKIE_NAME } from '@constructive-io/csrf'; +import { + ConstructiveError, + errors, + toError +} from '@constructive-io/errors'; +import { Logger } from '@pgpmjs/logger'; +import { type Request, type Response,Router } from 'express'; + +import { + type CookieConfig, + getSessionCookieConfig, + setSessionCookie +} from '../../middleware/cookie'; +import { + renderOAuthFailurePage, + renderOAuthSuccessPage +} from './page'; +import { + completeProviderAuthentication, + createProviderAuthorizationUrl +} from './service'; + +const log = new Logger('oauth-routes'); + +export interface OAuthRouterOptions { + requestTimeoutMs: number; +} + +const queryString = (req: Request, name: string): string | undefined => { + const value = req.query[name]; + return typeof value === 'string' ? value : undefined; +}; + +const requireRequestBoundary = async (req: Request) => { + const context = req.constructive; + if (!context) { + throw errors.INTERNAL_FAILURE({ + details: 'The Constructive request context is unavailable.' + }); + } + const surface = await context.useModule('ssoSurface'); + if (!surface) throw errors.SSO_SIGN_IN_DISABLED(); + const browserBinding = req.cookies?.[DEFAULT_CSRF_COOKIE_NAME]; + if (typeof browserBinding !== 'string') { + throw errors.INVALID_OAUTH_STATE(); + } + return { context, surface, browserBinding }; +}; + +const asSafeOAuthError = (cause: unknown): ConstructiveError => { + const error = toError(cause); + return error.isPublic + ? error + : errors.IDENTITY_PROVIDER_AUTHENTICATION_FAILED( + {}, + undefined, + { cause: error } + ); +}; + +const sendFailure = (req: Request, res: Response, cause: unknown): void => { + const error = asSafeOAuthError(cause); + log.warn({ + event: 'oauth_failure', + code: error.code, + requestId: req.requestId, + causeName: cause instanceof Error ? cause.name : typeof cause + }); + res.status(error.http).type('html').send(renderOAuthFailurePage(error)); +}; + +const setSecurityHeaders = (_req: Request, res: Response, next: () => void) => { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader( + 'Content-Security-Policy', + "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + ); + next(); +}; + +export const createOAuthRouter = (options: OAuthRouterOptions): Router => { + const router = Router(); + router.use(setSecurityHeaders); + + router.get('/authorize', async (req, res) => { + try { + const state = queryString(req, 'state') ?? ''; + const { context, surface, browserBinding } = await requireRequestBoundary(req); + const authorizationUrl = await createProviderAuthorizationUrl( + context, + surface, + state, + browserBinding + ); + res.redirect(303, authorizationUrl); + } catch (cause) { + sendFailure(req, res, cause); + } + }); + + router.get('/callback', async (req, res) => { + try { + const state = queryString(req, 'state') ?? ''; + const code = queryString(req, 'code'); + const providerReturnedError = req.query.error !== undefined; + const { context, surface, browserBinding } = await requireRequestBoundary(req); + const result = await completeProviderAuthentication(context, surface, { + state, + code, + providerReturnedError, + browserBinding, + requestTimeoutMs: options.requestTimeoutMs + }); + + const cookieConfig: CookieConfig = { + ...getSessionCookieConfig(req.api?.authSettings), + domain: undefined, + httpOnly: true, + secure: true + }; + setSessionCookie(res, result.accessToken, cookieConfig); + if (result.continuationUrl) { + res.redirect(303, result.continuationUrl); + return; + } + res.status(200).type('html').send(renderOAuthSuccessPage()); + } catch (cause) { + sendFailure(req, res, cause); + } + }); + + return router; +}; diff --git a/graphql/server/src/auth/oauth/service.ts b/graphql/server/src/auth/oauth/service.ts new file mode 100644 index 0000000000..6d2d2539bb --- /dev/null +++ b/graphql/server/src/auth/oauth/service.ts @@ -0,0 +1,128 @@ +import { errors } from '@constructive-io/errors'; +import type { + ConstructiveContext, + SsoSurface +} from '@constructive-io/express-context'; +import { + deriveS256CodeChallenge, + isOpaqueOAuthValue, + ProviderAdapterError +} from '@constructive-io/oauth'; + +import { resolveConfiguredProvider } from '../sso/provider-config'; +import { + completeProviderUnifiedLogin, + consumeProviderOAuthRequest, + type ProviderCredentialResult, + readProviderOAuthRequest +} from '../sso/provider-db-contract'; + +const mapAdapterError = (cause: unknown): never => { + if (!(cause instanceof ProviderAdapterError)) { + throw errors.IDENTITY_PROVIDER_AUTHENTICATION_FAILED( + {}, + undefined, + { cause } + ); + } + if (cause.reason === 'INVALID_CONFIGURATION') { + throw errors.IDENTITY_PROVIDER_NOT_CONFIGURED({}, undefined, { cause }); + } + if (cause.reason === 'INVALID_AUTHORIZATION_INPUT') { + throw errors.INVALID_OAUTH_PKCE({}, undefined, { cause }); + } + throw errors.IDENTITY_PROVIDER_AUTHENTICATION_FAILED( + {}, + undefined, + { cause } + ); +}; + +const validateState = (state: string): void => { + if (!isOpaqueOAuthValue(state)) throw errors.INVALID_OAUTH_STATE(); +}; + +export const createProviderAuthorizationUrl = async ( + context: ConstructiveContext, + surface: SsoSurface, + state: string, + browserBinding: string +): Promise => { + validateState(state); + const request = await readProviderOAuthRequest( + context, + surface, + state, + browserBinding + ); + const { adapter, configuration } = await resolveConfiguredProvider( + context, + request.providerKey + ); + try { + return adapter.createAuthorizationRequest({ + config: configuration, + redirectUri: request.redirectUri, + state, + codeChallenge: deriveS256CodeChallenge(request.codeVerifier), + ...(request.nonce ? { nonce: request.nonce } : {}) + }).url; + } catch (cause) { + return mapAdapterError(cause); + } +}; + +export const completeProviderAuthentication = async ( + context: ConstructiveContext, + surface: SsoSurface, + input: { + state: string; + code?: string; + providerReturnedError: boolean; + browserBinding: string; + requestTimeoutMs: number; + fetch?: typeof fetch; + } +): Promise => { + validateState(input.state); + + // Consume and restore server-held state before inspecting code/error. A + // cancellation, malformed callback, expiry, or replay never remains usable. + const request = await consumeProviderOAuthRequest( + context, + surface, + input.state, + input.browserBinding + ); + if (input.providerReturnedError) { + throw errors.OAUTH_AUTHORIZATION_CANCELLED(); + } + if (!input.code || input.code.length > 4096) { + throw errors.IDENTITY_PROVIDER_AUTHENTICATION_FAILED(); + } + + const { adapter, configuration } = await resolveConfiguredProvider( + context, + request.providerKey + ); + let identity; + try { + identity = await adapter.completeAuthorization({ + config: configuration, + redirectUri: request.redirectUri, + code: input.code, + codeVerifier: request.codeVerifier, + ...(request.nonce ? { nonce: request.nonce } : {}), + requestTimeoutMs: input.requestTimeoutMs, + ...(input.fetch ? { fetch: input.fetch } : {}) + }); + } catch (cause) { + return mapAdapterError(cause); + } + + return completeProviderUnifiedLogin(context, surface, { + requestId: request.requestId, + identity, + browserBinding: input.browserBinding + }); +}; diff --git a/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts b/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts index 3cd493c5a6..9ef22edec9 100644 --- a/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts +++ b/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts @@ -53,7 +53,8 @@ describe('UnifiedAuthPlugin schema integration', () => { 'startUnifiedLogin', 'confirmUnifiedLogin', 'signInUnifiedLogin', - 'signUpUnifiedLogin' + 'signUpUnifiedLogin', + 'startProviderAuthentication' ]) ); }); diff --git a/graphql/server/src/auth/sso/__tests__/service.test.ts b/graphql/server/src/auth/sso/__tests__/service.test.ts index b3eb344ba9..857916d487 100644 --- a/graphql/server/src/auth/sso/__tests__/service.test.ts +++ b/graphql/server/src/auth/sso/__tests__/service.test.ts @@ -47,6 +47,7 @@ const makeContext = ( } as unknown as QueryResult)); const client = { query } as unknown as PoolClient; const context = { + requestOrigin: 'https://auth.example.com', userId: options.userId ?? null, useModule: jest.fn(async (name: string) => { if (name === 'ssoSurface') return surface; @@ -97,7 +98,7 @@ describe('unified authentication GraphQL service', () => { sign_in_mode: 'confirm', reusable_authentication: false, current_user_id: null - }, { providers: { google: googleProvider } }); + }, { providers: { [googleProvider.slug]: googleProvider } }); const service = createUnifiedAuthService(true); const result = await service.start( @@ -164,6 +165,48 @@ describe('unified authentication GraphQL service', () => { ]); }); + it('starts Provider authentication without exposing transaction or PKCE secrets', async () => { + const { context, query } = makeContext({ + oauth_request_id: '00000000-0000-0000-0000-000000000099' + }, { providers: { [googleProvider.slug]: googleProvider } }); + const service = createUnifiedAuthService(true); + + const result = await service.startProvider( + { + constructive: context, + browserBinding: opaque + }, + { transactionId: opaque, providerKey: googleProvider.slug } + ); + + expect(result.authorizationUrl).toMatch( + /^\/auth\/oauth\/authorize\?state=[A-Za-z0-9_-]{43}$/ + ); + expect(result.authorizationUrl).not.toContain(opaque); + expect(query.mock.calls[0][0]).toContain( + '"tenant_acme_sso_private"."start_provider_oauth_request"' + ); + expect(query.mock.calls[0][1]).toEqual([ + opaque, + googleProvider.slug, + expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + 'https://auth.example.com/auth/oauth/callback', + opaque + ]); + }); + + it('keeps the Provider-start field stable but fails while OAuth is disabled', async () => { + const { context, query } = makeContext(); + const service = createUnifiedAuthService(false); + await expect(service.startProvider( + { constructive: context, browserBinding: opaque }, + { transactionId: opaque, providerKey: googleProvider.slug } + )).rejects.toMatchObject({ code: 'OAUTH_SIGN_IN_DISABLED' }); + expect(query).not.toHaveBeenCalled(); + }); + it('rejects a cross-origin return target before database access', async () => { const { context, query } = makeContext(); const service = createUnifiedAuthService(false); diff --git a/graphql/server/src/auth/sso/db-contract.ts b/graphql/server/src/auth/sso/db-contract.ts index ed958b4286..b96c7d23f4 100644 --- a/graphql/server/src/auth/sso/db-contract.ts +++ b/graphql/server/src/auth/sso/db-contract.ts @@ -41,7 +41,7 @@ export const SSO_DB_FUNCTIONS = { signUp: 'sign_up_unified_login' } as const; -type DatabaseRecord = Record; +export type DatabaseRecord = Record; interface StartDatabaseResult { transactionId: string; @@ -58,14 +58,14 @@ const invalidDatabaseResult = (operation: string, cause?: unknown): Error => cause === undefined ? undefined : { cause } ); -const asRecord = (value: unknown, operation: string): DatabaseRecord => { +export const asRecord = (value: unknown, operation: string): DatabaseRecord => { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw invalidDatabaseResult(operation); } return value as DatabaseRecord; }; -const requiredString = ( +export const requiredString = ( row: DatabaseRecord, field: string, operation: string @@ -77,7 +77,7 @@ const requiredString = ( return value; }; -const optionalString = ( +export const optionalString = ( row: DatabaseRecord, field: string, operation: string @@ -88,7 +88,7 @@ const optionalString = ( return value; }; -const requiredBoolean = ( +export const requiredBoolean = ( row: DatabaseRecord, field: string, operation: string @@ -98,7 +98,7 @@ const requiredBoolean = ( return value; }; -type SqlCast = 'boolean' | 'text' | 'uuid'; +export type SqlCast = 'boolean' | 'jsonb' | 'text' | 'uuid'; const castValue = ( value: ReturnType, @@ -107,6 +107,8 @@ const castValue = ( switch (cast) { case 'boolean': return sql.fragment`${value}::boolean`; + case 'jsonb': + return sql.fragment`${value}::jsonb`; case 'text': return sql.fragment`${value}::text`; case 'uuid': @@ -114,7 +116,7 @@ const castValue = ( } }; -const callFunction = async ( +export const callFunction = async ( context: ConstructiveContext, surface: SsoSurface, functionName: string, @@ -243,7 +245,8 @@ const authenticateWithPassword = async ( // Strict-auth/MFA/step-up integration is explicitly outside v1. The DB // wrapper must fail closed; this guard prevents an accidental partial result // from being treated as a completed unified login. - if (row.mfa_required === true) { + const mfaRequired = requiredBoolean(row, 'mfa_required', functionName); + if (mfaRequired) { throw errors.AUTH_METHOD_NOT_ALLOWED({}); } diff --git a/graphql/server/src/auth/sso/plugin.ts b/graphql/server/src/auth/sso/plugin.ts index 1f86fca6aa..74cf134332 100644 --- a/graphql/server/src/auth/sso/plugin.ts +++ b/graphql/server/src/auth/sso/plugin.ts @@ -4,6 +4,7 @@ import { extendSchema, gql } from 'graphile-utils'; import { createUnifiedAuthService } from './service'; import type { ContinueUnifiedLoginInput, + StartProviderAuthenticationInput, StartUnifiedLoginInput, UnifiedAuthGraphQLContext, UnifiedPasswordInput @@ -43,6 +44,10 @@ export const createUnifiedAuthPlugin = ( avatarUrl: String } + type StartProviderAuthenticationPayload { + authorizationUrl: String! + } + type StartUnifiedLoginPayload { transactionId: String! site: UnifiedAuthSite! @@ -89,6 +94,11 @@ export const createUnifiedAuthPlugin = ( deviceToken: String } + input StartProviderAuthenticationInput { + transactionId: String! + providerKey: String! + } + extend type Query { unifiedAuthProviders: [UnifiedAuthProvider!]! } @@ -98,6 +108,7 @@ export const createUnifiedAuthPlugin = ( confirmUnifiedLogin(input: ContinueUnifiedLoginInput!): UnifiedLoginContinuationPayload! signInUnifiedLogin(input: UnifiedPasswordInput!): UnifiedLoginCredentialPayload! signUpUnifiedLogin(input: UnifiedPasswordInput!): UnifiedLoginCredentialPayload! + startProviderAuthentication(input: StartProviderAuthenticationInput!): StartProviderAuthenticationPayload! } `, resolvers: { @@ -128,7 +139,12 @@ export const createUnifiedAuthPlugin = ( _source: unknown, args: InputArguments, context: UnifiedAuthGraphQLContext - ) => service.signUp(context, args.input) + ) => service.signUp(context, args.input), + startProviderAuthentication: ( + _source: unknown, + args: InputArguments, + context: UnifiedAuthGraphQLContext + ) => service.startProvider(context, args.input) } } }, 'UnifiedAuthPlugin'); diff --git a/graphql/server/src/auth/sso/provider-config.ts b/graphql/server/src/auth/sso/provider-config.ts new file mode 100644 index 0000000000..15229448ec --- /dev/null +++ b/graphql/server/src/auth/sso/provider-config.ts @@ -0,0 +1,106 @@ +import { errors } from '@constructive-io/errors'; +import type { + ConstructiveContext, + IdentityProviderConfig, + IdentityProvidersModule +} from '@constructive-io/express-context'; +import { + getProviderAdapter, + getProviderAdapterKinds, + type IdentityProviderConfiguration, + type ProviderAdapter, + type ValidatedProviderConfiguration +} from '@constructive-io/oauth'; + +import type { ProviderDisplayOption } from './types'; + +export const toOAuthConfiguration = ( + provider: IdentityProviderConfig +): IdentityProviderConfiguration => ({ + slug: provider.slug, + kind: provider.kind, + displayName: provider.displayName, + enabled: provider.enabled, + clientId: provider.clientId, + clientSecret: provider.clientSecret, + authorizationUrl: provider.authorizationUrl, + tokenUrl: provider.tokenUrl, + userinfoUrl: provider.userinfoUrl, + issuerUrl: provider.issuerUrl, + discoveryDoc: provider.discoveryDoc, + jwks: provider.jwks, + acceptableClientIds: provider.acceptableClientIds, + scopes: provider.scopes, + extraAuthorizationParams: provider.extraAuthorizationParams, + emailOptional: provider.emailOptional, + skipNonceCheck: provider.skipNonceCheck, + pkceEnabled: provider.pkceEnabled +}); + +const validateProvider = ( + provider: IdentityProviderConfig +): { + adapter: ProviderAdapter; + configuration: ValidatedProviderConfiguration; +} => { + let adapter: ProviderAdapter; + try { + adapter = getProviderAdapter(provider.kind); + } catch (cause) { + throw errors.IDENTITY_PROVIDER_UNSUPPORTED({}, undefined, { cause }); + } + + try { + return { + adapter, + configuration: adapter.validateConfiguration( + toOAuthConfiguration(provider) + ) + }; + } catch (cause) { + throw errors.IDENTITY_PROVIDER_NOT_CONFIGURED({}, undefined, { cause }); + } +}; + +export const resolveConfiguredProvider = async ( + context: ConstructiveContext, + providerKey: string +): Promise<{ + adapter: ProviderAdapter; + configuration: ValidatedProviderConfiguration; +}> => { + const module = await context.useModule('identityProviders'); + const provider = module?.providers[providerKey]; + if (!provider || !provider.enabled) { + throw errors.IDENTITY_PROVIDER_NOT_CONFIGURED(); + } + return validateProvider(provider); +}; + +const providerDisplayOptions = ( + module: IdentityProvidersModule | undefined +): ProviderDisplayOption[] => { + if (!module) return []; + const supportedKinds = new Set(getProviderAdapterKinds()); + const options: ProviderDisplayOption[] = []; + + for (const provider of Object.values(module.providers)) { + if (!provider.enabled || !supportedKinds.has(provider.kind)) continue; + validateProvider(provider); + options.push({ key: provider.slug, displayName: provider.displayName }); + } + + return options.sort((left, right) => + left.displayName.localeCompare(right.displayName) || + left.key.localeCompare(right.key) + ); +}; + +export const loadProviderDisplayOptions = async ( + context: ConstructiveContext, + oauthEnabled: boolean +): Promise => { + if (!oauthEnabled) return []; + const providers = await context.useModule('identityProviders'); + return providerDisplayOptions(providers); +}; diff --git a/graphql/server/src/auth/sso/provider-db-contract.ts b/graphql/server/src/auth/sso/provider-db-contract.ts new file mode 100644 index 0000000000..784b4b3cdc --- /dev/null +++ b/graphql/server/src/auth/sso/provider-db-contract.ts @@ -0,0 +1,207 @@ +import { errors } from '@constructive-io/errors'; +import type { + ConstructiveContext, + SsoSurface +} from '@constructive-io/express-context'; +import type { NormalizedExternalIdentity } from '@constructive-io/oauth'; +import sql from 'pg-sql2'; + +import { + callFunction, + optionalString, + requiredBoolean, + requiredString +} from './db-contract'; + +export const PROVIDER_DB_FUNCTIONS = { + start: 'start_provider_oauth_request', + read: 'read_provider_oauth_request', + consume: 'consume_provider_oauth_request', + complete: 'complete_provider_unified_login' +} as const; + +/** + * Fixed Constructive/DB signatures for the Provider subflow: + * + * - `start_provider_oauth_request(text, text, text, text, text, text, text)` + * accepts unified transaction token, Provider key, state, verifier, nonce, + * redirect URI, and browser binding; returns `oauth_request_id`. + * - `read_provider_oauth_request(text, text)` and + * `consume_provider_oauth_request(text, text)` accept state plus browser + * binding and return the request fields parsed below. Consume atomically + * marks the state used before Provider callback handling. + * - `complete_provider_unified_login(uuid, text, text, text, jsonb, text, + * boolean, text)` accepts request ID plus normalized identity, existing + * credential options, and browser binding; it returns the unchanged + * identity-auth credential result and optional shared continuation. + */ + +export interface ProviderOAuthRequest { + requestId: string; + providerKey: string; + codeVerifier: string; + nonce: string | null; + redirectUri: string; +} + +export interface ProviderCredentialResult { + credentialId: string; + userId: string; + accessToken: string; + accessTokenExpiresAt: string; + isVerified: boolean; + totpEnabled: boolean; + continuationUrl: string | null; +} + +/** + * Persist server-owned OAuth state before any browser redirect. + * + * The matching Tenant-private DB function validates the opaque unified login + * transaction and browser binding, links the existing OAuth request relation + * to that transaction, and enforces its ten-minute expiry. Only the opaque + * OAuth state crosses browser navigation; the verifier, nonce, transaction + * link, and Provider configuration identity remain server-side. + */ +export const startProviderOAuthRequest = async ( + context: ConstructiveContext, + surface: SsoSurface, + input: { + transactionId: string; + providerKey: string; + state: string; + codeVerifier: string; + nonce: string; + redirectUri: string; + browserBinding: string; + } +): Promise => { + const operation = PROVIDER_DB_FUNCTIONS.start; + const row = await callFunction( + context, + surface, + operation, + [ + sql.value(input.transactionId), + sql.value(input.providerKey), + sql.value(input.state), + sql.value(input.codeVerifier), + sql.value(input.nonce), + sql.value(input.redirectUri), + sql.value(input.browserBinding) + ], + ['text', 'text', 'text', 'text', 'text', 'text', 'text'] + ); + requiredString(row, 'oauth_request_id', operation); +}; + +const restoreProviderOAuthRequest = async ( + functionName: + | typeof PROVIDER_DB_FUNCTIONS.read + | typeof PROVIDER_DB_FUNCTIONS.consume, + context: ConstructiveContext, + surface: SsoSurface, + state: string, + browserBinding: string +): Promise => { + const row = await callFunction( + context, + surface, + functionName, + [sql.value(state), sql.value(browserBinding)], + ['text', 'text'] + ); + return { + requestId: requiredString(row, 'oauth_request_id', functionName), + providerKey: requiredString(row, 'provider_key', functionName), + codeVerifier: requiredString(row, 'code_verifier', functionName), + nonce: optionalString(row, 'nonce', functionName), + redirectUri: requiredString(row, 'redirect_uri', functionName) + }; +}; + +export const readProviderOAuthRequest = ( + context: ConstructiveContext, + surface: SsoSurface, + state: string, + browserBinding: string +): Promise => + restoreProviderOAuthRequest( + PROVIDER_DB_FUNCTIONS.read, + context, + surface, + state, + browserBinding + ); + +export const consumeProviderOAuthRequest = ( + context: ConstructiveContext, + surface: SsoSurface, + state: string, + browserBinding: string +): Promise => + restoreProviderOAuthRequest( + PROVIDER_DB_FUNCTIONS.consume, + context, + surface, + state, + browserBinding + ); + +/** + * Apply only the normalized Provider identity to the current Tenant. + * Account matching/provisioning, connected_accounts ownership, conflict rules, + * and association with the linked unified transaction stay inside the DB + * function and its unchanged sign_in_identity/sign_up_identity primitives. + */ +export const completeProviderUnifiedLogin = async ( + context: ConstructiveContext, + surface: SsoSurface, + input: { + requestId: string; + identity: NormalizedExternalIdentity; + browserBinding: string; + } +): Promise => { + const operation = PROVIDER_DB_FUNCTIONS.complete; + const row = await callFunction( + context, + surface, + operation, + [ + sql.value(input.requestId), + sql.value(input.identity.providerKey), + sql.value(input.identity.subject), + sql.value(input.identity.email ?? null), + sql.value(JSON.stringify(input.identity.profile)), + sql.value('bearer'), + sql.value(false), + sql.value(input.browserBinding) + ], + ['uuid', 'text', 'text', 'text', 'jsonb', 'text', 'boolean', 'text'] + ); + + const mfaRequired = requiredBoolean( + row, + 'mfa_required', + operation + ); + if (mfaRequired) { + // strict-auth/MFA/step-up integration is outside v1 and must fail closed. + throw errors.AUTH_METHOD_NOT_ALLOWED({}); + } + + return { + credentialId: requiredString(row, 'id', operation), + userId: requiredString(row, 'user_id', operation), + accessToken: requiredString(row, 'access_token', operation), + accessTokenExpiresAt: requiredString( + row, + 'access_token_expires_at', + operation + ), + isVerified: requiredBoolean(row, 'is_verified', operation), + totpEnabled: requiredBoolean(row, 'totp_enabled', operation), + continuationUrl: optionalString(row, 'continuation_url', operation) + }; +}; diff --git a/graphql/server/src/auth/sso/service.ts b/graphql/server/src/auth/sso/service.ts index 77b5e58948..e49285f984 100644 --- a/graphql/server/src/auth/sso/service.ts +++ b/graphql/server/src/auth/sso/service.ts @@ -1,14 +1,13 @@ import { errors } from '@constructive-io/errors'; import type { ConstructiveContext, - IdentityProviderConfig, - IdentityProvidersModule, SsoSurface } from '@constructive-io/express-context'; import { - getProviderAdapter, - getProviderAdapterKinds, - type IdentityProviderConfiguration + generateCodeVerifier, + generateOidcNonce, + generateOpaqueState, + validateProviderCallbackUri } from '@constructive-io/oauth'; import { @@ -17,9 +16,16 @@ import { signUpUnifiedLogin, startUnifiedLogin } from './db-contract'; +import { + loadProviderDisplayOptions, + resolveConfiguredProvider +} from './provider-config'; +import { startProviderOAuthRequest } from './provider-db-contract'; import type { ContinueUnifiedLoginInput, ProviderDisplayOption, + StartProviderAuthenticationInput, + StartProviderAuthenticationPayload, StartUnifiedLoginInput, StartUnifiedLoginPayload, UnifiedAuthGraphQLContext, @@ -65,6 +71,15 @@ const requireBrowserBinding = ( return graphQLContext.browserBinding; }; +const requireRequestOrigin = (context: ConstructiveContext): string => { + if (!context.requestOrigin) { + throw errors.INTERNAL_FAILURE({ + details: 'The routed authentication-center origin is unavailable.' + }); + } + return context.requestOrigin; +}; + const validateStartInput = (input: StartUnifiedLoginInput): void => { if (!SITE_STATE.test(input.siteState)) { throw errors.INVALID_SSO_SITE_STATE(); @@ -83,67 +98,6 @@ const validateStartInput = (input: StartUnifiedLoginInput): void => { } }; -const toOAuthConfiguration = ( - provider: IdentityProviderConfig -): IdentityProviderConfiguration => ({ - slug: provider.slug, - kind: provider.kind, - displayName: provider.displayName, - enabled: provider.enabled, - clientId: provider.clientId, - clientSecret: provider.clientSecret, - authorizationUrl: provider.authorizationUrl, - tokenUrl: provider.tokenUrl, - userinfoUrl: provider.userinfoUrl, - issuerUrl: provider.issuerUrl, - discoveryDoc: provider.discoveryDoc, - jwks: provider.jwks, - acceptableClientIds: provider.acceptableClientIds, - scopes: provider.scopes, - extraAuthorizationParams: provider.extraAuthorizationParams, - emailOptional: provider.emailOptional, - skipNonceCheck: provider.skipNonceCheck, - pkceEnabled: provider.pkceEnabled -}); - -const providerDisplayOptions = ( - module: IdentityProvidersModule | undefined -): ProviderDisplayOption[] => { - if (!module) return []; - const supportedKinds = new Set(getProviderAdapterKinds()); - const options: ProviderDisplayOption[] = []; - - for (const provider of Object.values(module.providers)) { - if (!provider.enabled || !supportedKinds.has(provider.kind)) continue; - try { - getProviderAdapter(provider.kind).validateConfiguration( - toOAuthConfiguration(provider) - ); - } catch (cause) { - throw errors.IDENTITY_PROVIDER_NOT_CONFIGURED( - {}, - undefined, - { cause } - ); - } - options.push({ key: provider.slug, displayName: provider.displayName }); - } - - return options.sort((left, right) => - left.displayName.localeCompare(right.displayName) || - left.key.localeCompare(right.key) - ); -}; - -const loadProviderDisplayOptions = async ( - context: ConstructiveContext, - oauthEnabled: boolean -): Promise => { - if (!oauthEnabled) return []; - const providers = await context.useModule('identityProviders'); - return providerDisplayOptions(providers); -}; - export interface UnifiedAuthService { providers(context: UnifiedAuthGraphQLContext): Promise; start( @@ -162,6 +116,10 @@ export interface UnifiedAuthService { context: UnifiedAuthGraphQLContext, input: UnifiedPasswordInput ): Promise; + startProvider( + context: UnifiedAuthGraphQLContext, + input: StartProviderAuthenticationInput + ): Promise; } export const createUnifiedAuthService = (oauthEnabled: boolean): UnifiedAuthService => ({ @@ -208,5 +166,46 @@ export const createUnifiedAuthService = (oauthEnabled: boolean): UnifiedAuthServ const browserBinding = requireBrowserBinding(graphQLContext); const surface = await resolveSsoSurface(context); return signUpUnifiedLogin(context, surface, input, browserBinding); + }, + + async startProvider(graphQLContext, input) { + if (!oauthEnabled) throw errors.OAUTH_SIGN_IN_DISABLED(); + validateTransactionInput(input); + if (!input.providerKey || input.providerKey.length > 128) { + throw errors.IDENTITY_PROVIDER_NOT_CONFIGURED(); + } + const context = requireContext(graphQLContext); + const browserBinding = requireBrowserBinding(graphQLContext); + const requestOrigin = requireRequestOrigin(context); + const surface = await resolveSsoSurface(context); + await resolveConfiguredProvider(context, input.providerKey); + + let redirectUri: string; + try { + redirectUri = validateProviderCallbackUri( + new URL('/auth/oauth/callback', requestOrigin).toString() + ); + } catch (cause) { + throw errors.INTERNAL_FAILURE( + { details: 'The authentication-center Provider callback is invalid.' }, + undefined, + { cause } + ); + } + + const state = generateOpaqueState(); + await startProviderOAuthRequest(context, surface, { + transactionId: input.transactionId, + providerKey: input.providerKey, + state, + codeVerifier: generateCodeVerifier(), + nonce: generateOidcNonce(), + redirectUri, + browserBinding + }); + + return { + authorizationUrl: `/auth/oauth/authorize?state=${encodeURIComponent(state)}` + }; } }); diff --git a/graphql/server/src/auth/sso/types.ts b/graphql/server/src/auth/sso/types.ts index a5166c5692..422774e796 100644 --- a/graphql/server/src/auth/sso/types.ts +++ b/graphql/server/src/auth/sso/types.ts @@ -11,6 +11,15 @@ export interface ProviderDisplayOption { displayName: string; } +export interface StartProviderAuthenticationInput { + transactionId: string; + providerKey: string; +} + +export interface StartProviderAuthenticationPayload { + authorizationUrl: string; +} + export interface StartUnifiedLoginInput { siteId: string; callbackUrl?: string | null; diff --git a/graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts b/graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts new file mode 100644 index 0000000000..5f44391ba5 --- /dev/null +++ b/graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts @@ -0,0 +1,23 @@ +import { redactSensitiveRequestUrl } from '../request-logger'; + +describe('redactSensitiveRequestUrl', () => { + it('redacts OAuth and handoff query secrets while preserving safe routing facts', () => { + expect(redactSensitiveRequestUrl( + '/auth/oauth/callback?code=secret-code&state=secret-state&safe=value' + )).toBe( + '/auth/oauth/callback?code=%5BREDACTED%5D&state=%5BREDACTED%5D&safe=value' + ); + expect(redactSensitiveRequestUrl('/callback?error=access_denied')).toBe( + '/callback?error=%5BREDACTED%5D' + ); + expect(redactSensitiveRequestUrl('/callback?handoff=secret&site_state=public')).toBe( + '/callback?handoff=%5BREDACTED%5D&site_state=public' + ); + }); + + it('does not alter requests without sensitive query parameters', () => { + expect(redactSensitiveRequestUrl('/graphql?operation=PublicQuery')).toBe( + '/graphql?operation=PublicQuery' + ); + }); +}); diff --git a/graphql/server/src/middleware/observability/request-logger.ts b/graphql/server/src/middleware/observability/request-logger.ts index 5280a682ab..c6b92cb231 100644 --- a/graphql/server/src/middleware/observability/request-logger.ts +++ b/graphql/server/src/middleware/observability/request-logger.ts @@ -4,6 +4,33 @@ import type { RequestHandler } from 'express'; const log = new Logger('server'); const SAFE_REQUEST_ID = /^[a-zA-Z0-9\-_]{1,128}$/; +const SENSITIVE_QUERY_PARAMETERS = new Set([ + 'access_token', + 'code', + 'error', + 'error_description', + 'handoff', + 'id_token', + 'state', + 'token' +]); + +export const redactSensitiveRequestUrl = (originalUrl: string): string => { + try { + const parsed = new URL(originalUrl, 'http://constructive.invalid'); + for (const name of [...parsed.searchParams.keys()]) { + if (SENSITIVE_QUERY_PARAMETERS.has(name.toLowerCase())) { + parsed.searchParams.set(name, '[REDACTED]'); + } + } + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + const queryStart = originalUrl.indexOf('?'); + return queryStart === -1 + ? originalUrl + : `${originalUrl.slice(0, queryStart)}?[REDACTED]`; + } +}; interface RequestLoggerOptions { observabilityEnabled: boolean; @@ -22,8 +49,9 @@ export const createRequestLogger = ({ observabilityEnabled }: RequestLoggerOptio const host = req.hostname || req.headers.host || 'unknown'; const ip = req.clientIp ?? req.ip ?? 'unknown'; + const safeUrl = redactSensitiveRequestUrl(req.originalUrl); - log.debug(`[${reqId}] -> ${req.method} ${req.originalUrl} host=${host} ip=${ip}`); + log.debug(`[${reqId}] -> ${req.method} ${safeUrl} host=${host} ip=${ip}`); res.on('finish', () => { finished = true; @@ -35,7 +63,7 @@ export const createRequestLogger = ({ observabilityEnabled }: RequestLoggerOptio const svcInfo = req.svc_key ? `svc=${req.svc_key}` : 'svc=unset'; log.debug( - `[${reqId}] <- ${res.statusCode} ${req.method} ${req.originalUrl} (${durationMs.toFixed( + `[${reqId}] <- ${res.statusCode} ${req.method} ${safeUrl} (${durationMs.toFixed( 1, )} ms) ${apiInfo} ${svcInfo} ${authInfo}`, ); @@ -54,7 +82,7 @@ export const createRequestLogger = ({ observabilityEnabled }: RequestLoggerOptio log.warn( `[${reqId}] connection closed before response completed ` + - `${req.method} ${req.originalUrl} (${durationMs.toFixed(1)} ms) ${apiInfo}`, + `${req.method} ${safeUrl} (${durationMs.toFixed(1)} ms) ${apiInfo}`, ); }); } diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 20eb62d20d..c30e6d06e6 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -22,6 +22,7 @@ import { getPgPool } from 'pg-cache'; import requestIp from 'request-ip'; import { createAgenticRouter } from './agentic'; +import { createOAuthRouter } from './auth/oauth'; import { closeDebugDatabasePools } from './diagnostics/debug-db-snapshot'; import type { DebugSamplerHandle } from './diagnostics/debug-sampler'; import { startDebugSampler } from './diagnostics/debug-sampler'; @@ -209,6 +210,11 @@ class Server { }; app.use(csrfSetToken); // Set CSRF token cookie on all requests app.use('/graphql', csrfProtect); // Enforce CSRF on GraphQL mutations + if (effectiveOpts.oauth?.enabled) { + app.use('/auth/oauth', createOAuthRouter({ + requestTimeoutMs: effectiveOpts.oauth.providerRequestTimeoutMs + })); + } // LLM Agent REST API — mounted before graphile so SSE streaming // routes are handled without going through PostGraphile diff --git a/packages/errors/__tests__/sso.test.ts b/packages/errors/__tests__/sso.test.ts index 36eea0d62f..39a8270493 100644 --- a/packages/errors/__tests__/sso.test.ts +++ b/packages/errors/__tests__/sso.test.ts @@ -7,10 +7,12 @@ const PUBLIC_SSO_CODES = [ 'SSO_LOGIN_TRANSACTION_EXPIRED', 'SSO_LOGIN_TRANSACTION_ALREADY_USED', 'OAUTH_SIGN_IN_DISABLED', + 'OAUTH_AUTHORIZATION_CANCELLED', 'INVALID_OAUTH_STATE', 'INVALID_OAUTH_PKCE', 'IDENTITY_PROVIDER_NOT_CONFIGURED', 'IDENTITY_PROVIDER_UNSUPPORTED', + 'IDENTITY_PROVIDER_AUTHENTICATION_FAILED', 'SSO_ACCOUNT_CONFLICT', 'INVALID_SSO_HANDOFF', 'SSO_HANDOFF_EXPIRED', diff --git a/packages/errors/src/registry.ts b/packages/errors/src/registry.ts index 6886dbd34a..222539a2f2 100644 --- a/packages/errors/src/registry.ts +++ b/packages/errors/src/registry.ts @@ -154,6 +154,12 @@ export const registry = { http: 403, message: 'OAuth sign in is not enabled.' }), + OAUTH_AUTHORIZATION_CANCELLED: defineError({ + code: 'OAUTH_AUTHORIZATION_CANCELLED', + class: 'public', + http: 400, + message: 'External sign in was cancelled. Please restart sign in.' + }), INVALID_SSO_SITE_STATE: defineError({ code: 'INVALID_SSO_SITE_STATE', class: 'public', @@ -208,6 +214,12 @@ export const registry = { http: 400, message: 'This identity provider is not supported.' }), + IDENTITY_PROVIDER_AUTHENTICATION_FAILED: defineError({ + code: 'IDENTITY_PROVIDER_AUTHENTICATION_FAILED', + class: 'public', + http: 401, + message: 'External sign in failed. Please restart sign in.' + }), SSO_ACCOUNT_CONFLICT: defineError({ code: 'SSO_ACCOUNT_CONFLICT', class: 'public', diff --git a/packages/express-context/__tests__/context.test.ts b/packages/express-context/__tests__/context.test.ts new file mode 100644 index 0000000000..b73c4252c2 --- /dev/null +++ b/packages/express-context/__tests__/context.test.ts @@ -0,0 +1,21 @@ +import type { Request } from 'express'; + +import { resolveRequestOrigin } from '../src'; + +describe('resolveRequestOrigin', () => { + it('derives the routed HTTPS request origin', () => { + const request = { + protocol: 'https', + get: (name: string) => name === 'host' ? 'auth.example.com:8443' : undefined + } as unknown as Request; + expect(resolveRequestOrigin(request)).toBe('https://auth.example.com:8443'); + }); + + it('rejects malformed request hosts', () => { + const request = { + protocol: 'https', + get: () => 'bad host' + } as unknown as Request; + expect(resolveRequestOrigin(request)).toBeNull(); + }); +}); diff --git a/packages/express-context/src/context.ts b/packages/express-context/src/context.ts index 82d87de9d3..d35bf17970 100644 --- a/packages/express-context/src/context.ts +++ b/packages/express-context/src/context.ts @@ -7,7 +7,7 @@ * - pgSettings (role, claims, request_id, database_id) * - Tenant database pool (via pg-cache) * - withPgClient (transaction-scoped RLS helper) - * - Convenience fields (userId, databaseId, requestId) + * - Convenience/request fact fields (userId, databaseId, requestId, origin) * - useModule (lazy, on-demand per-database module resolution) * * The result is a single `req.constructive` object that any downstream @@ -36,6 +36,24 @@ export interface ContextMiddlewareOptions { routingSchema?: string; } +/** Derive an origin from the already-routed Express request. */ +export function resolveRequestOrigin(req: Request): string | null { + const host = req.get('host'); + if (!host || (req.protocol !== 'http' && req.protocol !== 'https')) return null; + try { + const url = new URL(`${req.protocol}://${host}`); + return url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ? null + : url.origin; + } catch { + return null; + } +} + /** * Create a `useModule` function bound to the given loader context. * @@ -115,6 +133,7 @@ export function buildContext( databaseId: api.databaseId ?? null, userId: token?.user_id ?? null, requestId, + requestOrigin: resolveRequestOrigin(req), pool: tenantPool, withPgClient, useModule, diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 5fe55920a4..5a9ec6cb45 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -77,7 +77,11 @@ export { requestIdMiddleware } from './request-id'; // Context middleware export type { ContextMiddlewareOptions } from './context'; -export { buildContext, createContextMiddleware } from './context'; +export { + buildContext, + createContextMiddleware, + resolveRequestOrigin +} from './context'; // Module loaders export type { diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index febf6a2f27..2819cb2492 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -284,6 +284,8 @@ export interface ConstructiveContext { userId: string | null; /** Per-request correlation ID for distributed tracing */ requestId: string; + /** Server-derived origin of the routed HTTP request. */ + requestOrigin: string | null; /** Tenant database connection pool */ pool: Pool; /** Execute a function within a tenant-scoped RLS transaction */ From 96c53e6dff36332c763db8d1031c655dc11ae731 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 10 Aug 2026 00:36:58 +0800 Subject: [PATCH 08/11] feat: add unified auth handoff flow --- .../src/auth/oauth/__tests__/router.test.ts | 8 +- .../src/auth/oauth/__tests__/service.test.ts | 10 +- graphql/server/src/auth/oauth/page.ts | 6 - graphql/server/src/auth/oauth/router.ts | 11 +- graphql/server/src/auth/oauth/service.ts | 4 +- .../src/auth/sso/__tests__/handoff.test.ts | 49 +++++++ .../sso/__tests__/plugin.integration.test.ts | 3 +- .../src/auth/sso/__tests__/service.test.ts | 125 +++++++++++++++++- graphql/server/src/auth/sso/db-contract.ts | 69 +++++++--- .../src/auth/sso/handoff-db-contract.ts | 66 +++++++++ graphql/server/src/auth/sso/handoff.ts | 67 ++++++++++ graphql/server/src/auth/sso/plugin.ts | 27 +++- .../src/auth/sso/provider-db-contract.ts | 23 +++- graphql/server/src/auth/sso/service.ts | 52 +++++++- graphql/server/src/auth/sso/types.ts | 16 ++- .../request-logger-redaction.test.ts | 2 +- .../observability/request-logger.ts | 1 + .../__tests__/auth-cookie-plugin.test.ts | 52 +++++++- .../server/src/plugins/auth-cookie-plugin.ts | 51 ++++++- .../__tests__/pg-settings.test.ts | 32 +++++ packages/express-context/src/pg-settings.ts | 23 ++-- 21 files changed, 621 insertions(+), 76 deletions(-) create mode 100644 graphql/server/src/auth/sso/__tests__/handoff.test.ts create mode 100644 graphql/server/src/auth/sso/handoff-db-contract.ts create mode 100644 graphql/server/src/auth/sso/handoff.ts diff --git a/graphql/server/src/auth/oauth/__tests__/router.test.ts b/graphql/server/src/auth/oauth/__tests__/router.test.ts index 3abe3b5b04..8b6130fb82 100644 --- a/graphql/server/src/auth/oauth/__tests__/router.test.ts +++ b/graphql/server/src/auth/oauth/__tests__/router.test.ts @@ -70,18 +70,22 @@ describe('OAuth HTTP routes', () => { accessTokenExpiresAt: '2026-08-10T12:00:00.000Z', isVerified: true, totpEnabled: false, - continuationUrl: null + continuationUrl: + 'https://portal.example.com/auth/complete?handoff=handoff-code&site_state=site-state' }); const response = await supertest(makeApp()) .get(`/auth/oauth/callback?state=${opaqueState}&code=provider-code`) - .expect(200); + .expect(303); const cookie = response.headers['set-cookie'][0] as string; expect(cookie).toContain('constructive_session=cnc_auth_center_token'); expect(cookie).toContain('Secure'); expect(cookie).toContain('HttpOnly'); expect(cookie).not.toContain('Domain='); + expect(response.headers.location).toBe( + 'https://portal.example.com/auth/complete?handoff=handoff-code&site_state=site-state' + ); expect(response.text).not.toContain('cnc_auth_center_token'); }); diff --git a/graphql/server/src/auth/oauth/__tests__/service.test.ts b/graphql/server/src/auth/oauth/__tests__/service.test.ts index fe44bd4cb2..180da2f95d 100644 --- a/graphql/server/src/auth/oauth/__tests__/service.test.ts +++ b/graphql/server/src/auth/oauth/__tests__/service.test.ts @@ -111,7 +111,9 @@ describe('Provider OAuth orchestration', () => { is_verified: true, totp_enabled: false, mfa_required: false, - continuation_url: null + callback_url: 'https://portal.example.com/auth/complete', + site_state: 't'.repeat(43), + handoff_expires_at: '2026-08-10T12:01:00.000Z' } ]); const providerFetch = jest.fn() @@ -136,6 +138,9 @@ describe('Provider OAuth orchestration', () => { }); expect(result.accessToken).toBe('cnc_auth_center_token'); + expect(result.continuationUrl).toMatch( + /^https:\/\/portal\.example\.com\/auth\/complete\?handoff=/ + ); expect(providerFetch).toHaveBeenCalledTimes(2); expect(query).toHaveBeenCalledTimes(2); expect(query.mock.calls[1]?.[1]).toEqual([ @@ -150,7 +155,8 @@ describe('Provider OAuth orchestration', () => { }), 'bearer', false, - browserBinding + browserBinding, + expect.stringMatching(/^\\x[0-9a-f]{64}$/) ]); }); diff --git a/graphql/server/src/auth/oauth/page.ts b/graphql/server/src/auth/oauth/page.ts index c31554a296..0257e9b265 100644 --- a/graphql/server/src/auth/oauth/page.ts +++ b/graphql/server/src/auth/oauth/page.ts @@ -26,9 +26,3 @@ const page = (title: string, body: string): string => ` export const renderOAuthFailurePage = (error: ConstructiveError): string => page('External sign in failed', `${error.message} (${error.code})`); - -export const renderOAuthSuccessPage = (): string => - page( - 'External sign in completed', - 'Authentication succeeded. You may close this page.' - ); diff --git a/graphql/server/src/auth/oauth/router.ts b/graphql/server/src/auth/oauth/router.ts index 8530ab0dcc..ae07f38b6f 100644 --- a/graphql/server/src/auth/oauth/router.ts +++ b/graphql/server/src/auth/oauth/router.ts @@ -12,10 +12,7 @@ import { getSessionCookieConfig, setSessionCookie } from '../../middleware/cookie'; -import { - renderOAuthFailurePage, - renderOAuthSuccessPage -} from './page'; +import { renderOAuthFailurePage } from './page'; import { completeProviderAuthentication, createProviderAuthorizationUrl @@ -124,11 +121,7 @@ export const createOAuthRouter = (options: OAuthRouterOptions): Router => { secure: true }; setSessionCookie(res, result.accessToken, cookieConfig); - if (result.continuationUrl) { - res.redirect(303, result.continuationUrl); - return; - } - res.status(200).type('html').send(renderOAuthSuccessPage()); + res.redirect(303, result.continuationUrl); } catch (cause) { sendFailure(req, res, cause); } diff --git a/graphql/server/src/auth/oauth/service.ts b/graphql/server/src/auth/oauth/service.ts index 6d2d2539bb..77b276ec13 100644 --- a/graphql/server/src/auth/oauth/service.ts +++ b/graphql/server/src/auth/oauth/service.ts @@ -9,6 +9,7 @@ import { ProviderAdapterError } from '@constructive-io/oauth'; +import { createHandoffMaterial } from '../sso/handoff'; import { resolveConfiguredProvider } from '../sso/provider-config'; import { completeProviderUnifiedLogin, @@ -123,6 +124,7 @@ export const completeProviderAuthentication = async ( return completeProviderUnifiedLogin(context, surface, { requestId: request.requestId, identity, - browserBinding: input.browserBinding + browserBinding: input.browserBinding, + handoff: createHandoffMaterial() }); }; diff --git a/graphql/server/src/auth/sso/__tests__/handoff.test.ts b/graphql/server/src/auth/sso/__tests__/handoff.test.ts new file mode 100644 index 0000000000..77341ae8db --- /dev/null +++ b/graphql/server/src/auth/sso/__tests__/handoff.test.ts @@ -0,0 +1,49 @@ +import { + buildHandoffContinuationUrl, + createHandoffMaterial, + hashHandoffCode +} from '../handoff'; + +describe('SSO handoff primitives', () => { + it('creates 256-bit plaintext and keeps only its SHA-256 bytea digest', () => { + const first = createHandoffMaterial(); + const second = createHandoffMaterial(); + + expect(first.code).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(first.hash).toMatch(/^\\x[0-9a-f]{64}$/); + expect(first.hash).toBe(hashHandoffCode(first.code)); + expect(first.code).not.toBe(second.code); + }); + + it('adds only handoff and Site state to an exact HTTPS callback', () => { + const result = buildHandoffContinuationUrl( + 'https://portal.example.com/auth/complete?locale=en', + 's'.repeat(43), + 'h'.repeat(43) + ); + const callback = new URL(result); + + expect(callback.origin).toBe('https://portal.example.com'); + expect(callback.pathname).toBe('/auth/complete'); + expect(callback.searchParams.get('locale')).toBe('en'); + expect(callback.searchParams.get('handoff')).toBe('h'.repeat(43)); + expect(callback.searchParams.get('site_state')).toBe('s'.repeat(43)); + }); + + it('fails closed for non-HTTPS or reserved callback parameters', () => { + expect(() => buildHandoffContinuationUrl( + 'http://portal.example.com/auth/complete', + 's'.repeat(43), + 'h'.repeat(43) + )).toThrow(); + expect(() => buildHandoffContinuationUrl( + 'https://portal.example.com/auth/complete?handoff=attacker', + 's'.repeat(43), + 'h'.repeat(43) + )).toThrow(); + }); + + it('rejects malformed redemption codes before hashing', () => { + expect(() => hashHandoffCode('short')).toThrow(); + }); +}); diff --git a/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts b/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts index 9ef22edec9..f0ebd69641 100644 --- a/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts +++ b/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts @@ -54,7 +54,8 @@ describe('UnifiedAuthPlugin schema integration', () => { 'confirmUnifiedLogin', 'signInUnifiedLogin', 'signUpUnifiedLogin', - 'startProviderAuthentication' + 'startProviderAuthentication', + 'redeemUnifiedLoginHandoff' ]) ); }); diff --git a/graphql/server/src/auth/sso/__tests__/service.test.ts b/graphql/server/src/auth/sso/__tests__/service.test.ts index 857916d487..d335092dbc 100644 --- a/graphql/server/src/auth/sso/__tests__/service.test.ts +++ b/graphql/server/src/auth/sso/__tests__/service.test.ts @@ -40,6 +40,7 @@ const makeContext = ( options: { userId?: string | null; providers?: Record; + runtime?: boolean; } = {} ): { context: ConstructiveContext; query: jest.Mock } => { const query = jest.fn(async () => ({ @@ -47,6 +48,20 @@ const makeContext = ( } as unknown as QueryResult)); const client = { query } as unknown as PoolClient; const context = { + api: { + apiId: options.runtime + ? '00000000-0000-0000-0000-000000000020' + : undefined + }, + token: options.runtime + ? { + id: '00000000-0000-0000-0000-000000000021', + user_id: '00000000-0000-0000-0000-000000000022', + principal_id: '00000000-0000-0000-0000-000000000023', + kind: 'api_key', + access_level: 'full_access' + } + : null, requestOrigin: 'https://auth.example.com', userId: options.userId ?? null, useModule: jest.fn(async (name: string) => { @@ -134,7 +149,10 @@ describe('unified authentication GraphQL service', () => { access_token_expires_at: '2026-08-10T00:00:00.000Z', is_verified: false, totp_enabled: false, - mfa_required: false + mfa_required: false, + callback_url: 'https://portal.example.com/auth/complete', + site_state: opaque, + handoff_expires_at: '2026-08-10T00:01:00.000Z' }); const service = createUnifiedAuthService(false); @@ -149,7 +167,9 @@ describe('unified authentication GraphQL service', () => { ); expect(result.accessToken).toBe('cnc_live_bt_secret'); - expect(result.continuationUrl).toBeNull(); + expect(result.continuationUrl).toMatch( + /^https:\/\/portal\.example\.com\/auth\/complete\?handoff=[A-Za-z0-9_-]{43}&site_state=/ + ); expect(query).toHaveBeenCalledTimes(1); expect(query.mock.calls[0][0]).toContain( '"tenant_acme_sso_private"."sign_in_unified_login"' @@ -161,10 +181,109 @@ describe('unified authentication GraphQL service', () => { true, 'bearer', opaque, - null + null, + expect.stringMatching(/^\\x[0-9a-f]{64}$/) + ]); + }); + + it('creates the same handoff continuation for reusable authentication', async () => { + const { context, query } = makeContext({ + user_id: '00000000-0000-0000-0000-000000000011', + callback_url: 'https://portal.example.com/auth/complete', + site_state: opaque, + handoff_expires_at: '2026-08-10T00:01:00.000Z' + }, { userId: '00000000-0000-0000-0000-000000000011' }); + const service = createUnifiedAuthService(false); + + const result = await service.confirm( + { constructive: context, browserBinding: opaque }, + { transactionId: opaque } + ); + + expect(result.continuationUrl).toMatch( + /^https:\/\/portal\.example\.com\/auth\/complete\?handoff=/ + ); + expect(query.mock.calls[0][0]).toContain( + '"tenant_acme_sso_private"."confirm_unified_login"' + ); + expect(query.mock.calls[0][1]).toEqual([ + opaque, + opaque, + expect.stringMatching(/^\\x[0-9a-f]{64}$/) + ]); + }); + + it('creates the shared handoff through the registration wrapper', async () => { + const { context, query } = makeContext({ + id: '00000000-0000-0000-0000-000000000010', + user_id: '00000000-0000-0000-0000-000000000011', + access_token: 'cnc_live_bt_registration', + access_token_expires_at: '2026-08-10T00:00:00.000Z', + is_verified: false, + totp_enabled: false, + mfa_required: false, + callback_url: 'https://portal.example.com/auth/complete', + site_state: opaque, + handoff_expires_at: '2026-08-10T00:01:00.000Z' + }); + const service = createUnifiedAuthService(false); + + await expect(service.signUp( + { constructive: context, browserBinding: opaque }, + { + transactionId: opaque, + email: 'new@example.com', + password: 'correct horse battery staple' + } + )).resolves.toMatchObject({ + accessToken: 'cnc_live_bt_registration', + continuationUrl: expect.stringMatching(/handoff=/) + }); + expect(query.mock.calls[0][0]).toContain( + '"tenant_acme_sso_private"."sign_up_unified_login"' + ); + }); + + it('redeems through an authenticated routed Site runtime API key', async () => { + const { context, query } = makeContext({ + id: '00000000-0000-0000-0000-000000000030', + user_id: '00000000-0000-0000-0000-000000000031', + access_token: 'cnc_live_bt_site', + access_token_expires_at: '2026-08-10T01:00:00.000Z', + is_verified: true, + totp_enabled: false, + mfa_required: false, + return_to: '/approvals/42' + }, { runtime: true }); + const service = createUnifiedAuthService(false); + const handoffCode = 'h'.repeat(43); + + await expect(service.redeem( + { constructive: context }, + { handoffCode } + )).resolves.toMatchObject({ + accessToken: 'cnc_live_bt_site', + returnTo: '/approvals/42' + }); + expect(query.mock.calls[0][0]).toContain( + '"tenant_acme_sso_private"."redeem_sso_handoff"' + ); + expect(query.mock.calls[0][1]).toEqual([ + expect.stringMatching(/^\\x[0-9a-f]{64}$/) ]); }); + it('does not let an auth-center browser credential redeem a Site handoff', async () => { + const { context, query } = makeContext(); + const service = createUnifiedAuthService(false); + + await expect(service.redeem( + { constructive: context }, + { handoffCode: 'h'.repeat(43) } + )).rejects.toMatchObject({ code: 'UNAUTHENTICATED' }); + expect(query).not.toHaveBeenCalled(); + }); + it('starts Provider authentication without exposing transaction or PKCE secrets', async () => { const { context, query } = makeContext({ oauth_request_id: '00000000-0000-0000-0000-000000000099' diff --git a/graphql/server/src/auth/sso/db-contract.ts b/graphql/server/src/auth/sso/db-contract.ts index b96c7d23f4..ba8e66b5ab 100644 --- a/graphql/server/src/auth/sso/db-contract.ts +++ b/graphql/server/src/auth/sso/db-contract.ts @@ -2,6 +2,10 @@ import { errors } from '@constructive-io/errors'; import type { ConstructiveContext, SsoSurface } from '@constructive-io/express-context'; import sql from 'pg-sql2'; +import { + buildHandoffContinuationUrl, + type HandoffMaterial +} from './handoff'; import type { ContinueUnifiedLoginInput, StartUnifiedLoginInput, @@ -25,9 +29,11 @@ import type { * - `start_unified_login(uuid, text, text, text, text)` returns * `transaction_id`, safe Site display fields, `sign_in_mode`, * `reusable_authentication`, and optional safe current-user display fields. - * - `confirm_unified_login(text, text)` returns the associated `user_id`. - * - `sign_in_unified_login(text, text, text, boolean, text, text, text)` and - * `sign_up_unified_login(...)` return the unchanged local credential columns. + * - `confirm_unified_login(text, text, bytea)` returns the associated `user_id` + * and the transaction-bound Site callback continuation fields. + * - `sign_in_unified_login(text, text, text, boolean, text, text, text, bytea)` + * and `sign_up_unified_login(...)` return the unchanged local credential + * columns and the same continuation fields. * * The final `text` arguments are the server-read authentication-center browser * binding and device-token values. The transaction identifier is an opaque @@ -98,7 +104,7 @@ export const requiredBoolean = ( return value; }; -export type SqlCast = 'boolean' | 'jsonb' | 'text' | 'uuid'; +export type SqlCast = 'boolean' | 'bytea' | 'jsonb' | 'text' | 'uuid'; const castValue = ( value: ReturnType, @@ -107,6 +113,8 @@ const castValue = ( switch (cast) { case 'boolean': return sql.fragment`${value}::boolean`; + case 'bytea': + return sql.fragment`${value}::bytea`; case 'jsonb': return sql.fragment`${value}::jsonb`; case 'text': @@ -116,6 +124,22 @@ const castValue = ( } }; +export const continuationFromDatabaseResult = ( + row: DatabaseRecord, + operation: string, + handoff: HandoffMaterial +): string => { + const expiresAt = requiredString(row, 'handoff_expires_at', operation); + if (!Number.isFinite(Date.parse(expiresAt))) { + throw invalidDatabaseResult(operation); + } + return buildHandoffContinuationUrl( + requiredString(row, 'callback_url', operation), + requiredString(row, 'site_state', operation), + handoff.code + ); +}; + export const callFunction = async ( context: ConstructiveContext, surface: SsoSurface, @@ -200,22 +224,26 @@ export const confirmUnifiedLogin = async ( context: ConstructiveContext, surface: SsoSurface, input: ContinueUnifiedLoginInput, - browserBinding: string + browserBinding: string, + handoff: HandoffMaterial ): Promise => { const operation = SSO_DB_FUNCTIONS.confirm; const row = await callFunction( context, surface, operation, - [sql.value(input.transactionId), sql.value(browserBinding)], - ['text', 'text'] + [ + sql.value(input.transactionId), + sql.value(browserBinding), + sql.value(handoff.hash) + ], + ['text', 'text', 'bytea'] ); requiredString(row, 'user_id', operation); return { transactionId: input.transactionId, authenticated: true, - // PR 6 adds the shared one-time handoff continuation. - continuationUrl: null + continuationUrl: continuationFromDatabaseResult(row, operation, handoff) }; }; @@ -224,7 +252,8 @@ const authenticateWithPassword = async ( context: ConstructiveContext, surface: SsoSurface, input: UnifiedPasswordInput, - browserBinding: string + browserBinding: string, + handoff: HandoffMaterial ): Promise => { const row = await callFunction( context, @@ -237,9 +266,10 @@ const authenticateWithPassword = async ( sql.value(input.rememberMe ?? false), sql.value('bearer'), sql.value(browserBinding), - sql.value(input.deviceToken ?? null) + sql.value(input.deviceToken ?? null), + sql.value(handoff.hash) ], - ['text', 'text', 'text', 'boolean', 'text', 'text', 'text'] + ['text', 'text', 'text', 'boolean', 'text', 'text', 'text', 'bytea'] ); // Strict-auth/MFA/step-up integration is explicitly outside v1. The DB @@ -263,8 +293,7 @@ const authenticateWithPassword = async ( ), isVerified: requiredBoolean(row, 'is_verified', functionName), totpEnabled: requiredBoolean(row, 'totp_enabled', functionName), - // PR 6 adds the shared one-time handoff continuation. - continuationUrl: null + continuationUrl: continuationFromDatabaseResult(row, functionName, handoff) }; }; @@ -272,26 +301,30 @@ export const signInUnifiedLogin = ( context: ConstructiveContext, surface: SsoSurface, input: UnifiedPasswordInput, - browserBinding: string + browserBinding: string, + handoff: HandoffMaterial ): Promise => authenticateWithPassword( SSO_DB_FUNCTIONS.signIn, context, surface, input, - browserBinding + browserBinding, + handoff ); export const signUpUnifiedLogin = ( context: ConstructiveContext, surface: SsoSurface, input: UnifiedPasswordInput, - browserBinding: string + browserBinding: string, + handoff: HandoffMaterial ): Promise => authenticateWithPassword( SSO_DB_FUNCTIONS.signUp, context, surface, input, - browserBinding + browserBinding, + handoff ); diff --git a/graphql/server/src/auth/sso/handoff-db-contract.ts b/graphql/server/src/auth/sso/handoff-db-contract.ts new file mode 100644 index 0000000000..996be82bb9 --- /dev/null +++ b/graphql/server/src/auth/sso/handoff-db-contract.ts @@ -0,0 +1,66 @@ +import { errors } from '@constructive-io/errors'; +import type { + ConstructiveContext, + SsoSurface +} from '@constructive-io/express-context'; +import sql from 'pg-sql2'; + +import { + callFunction, + requiredBoolean, + requiredString +} from './db-contract'; +import { hashHandoffCode } from './handoff'; +import type { RedeemUnifiedLoginHandoffPayload } from './types'; + +export const SSO_HANDOFF_DB_FUNCTION = 'redeem_sso_handoff'; + +/** + * Redeem through the current routed API and authenticated service principal. + * The DB function reads the authoritative api_id, token kind/id, principal, + * Tenant, and role from the existing request pgSettings. Possession of the + * handoff digest is deliberately insufficient by itself. + */ +export const redeemUnifiedLoginHandoff = async ( + context: ConstructiveContext, + surface: SsoSurface, + handoffCode: string +): Promise => { + const operation = SSO_HANDOFF_DB_FUNCTION; + const row = await callFunction( + context, + surface, + operation, + [sql.value(hashHandoffCode(handoffCode))], + ['bytea'] + ); + + const mfaRequired = requiredBoolean(row, 'mfa_required', operation); + if (mfaRequired) throw errors.AUTH_METHOD_NOT_ALLOWED({}); + + const returnTo = requiredString(row, 'return_to', operation); + if ( + returnTo.length > 2048 || + !returnTo.startsWith('/') || + returnTo.startsWith('//') || + /[\r\n]/.test(returnTo) + ) { + throw errors.INTERNAL_FAILURE({ + details: 'The database returned an invalid Site return target.' + }); + } + + return { + credentialId: requiredString(row, 'id', operation), + userId: requiredString(row, 'user_id', operation), + accessToken: requiredString(row, 'access_token', operation), + accessTokenExpiresAt: requiredString( + row, + 'access_token_expires_at', + operation + ), + isVerified: requiredBoolean(row, 'is_verified', operation), + totpEnabled: requiredBoolean(row, 'totp_enabled', operation), + returnTo + }; +}; diff --git a/graphql/server/src/auth/sso/handoff.ts b/graphql/server/src/auth/sso/handoff.ts new file mode 100644 index 0000000000..21224d359a --- /dev/null +++ b/graphql/server/src/auth/sso/handoff.ts @@ -0,0 +1,67 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { errors } from '@constructive-io/errors'; + +const HANDOFF_BYTES = 32; +const HANDOFF_CODE = /^[A-Za-z0-9_-]{43}$/; +const SITE_STATE = /^[A-Za-z0-9_-]{32,128}$/; + +export interface HandoffMaterial { + code: string; + /** PostgreSQL bytea hex input; plaintext is never passed to persistence. */ + hash: string; +} + +export const createHandoffMaterial = (): HandoffMaterial => { + const code = randomBytes(HANDOFF_BYTES).toString('base64url'); + return { code, hash: hashHandoffCode(code) }; +}; + +export const hashHandoffCode = (code: string): string => { + if (!HANDOFF_CODE.test(code)) throw errors.INVALID_SSO_HANDOFF(); + return `\\x${createHash('sha256').update(code, 'utf8').digest('hex')}`; +}; + +/** + * Add only the approved one-time callback artifacts to the exact callback + * restored from the Tenant-owned login transaction. + */ +export const buildHandoffContinuationUrl = ( + callbackUrl: string, + siteState: string, + handoffCode: string +): string => { + let callback: URL; + try { + callback = new URL(callbackUrl); + } catch (cause) { + throw errors.INTERNAL_FAILURE( + { details: 'The database returned an invalid unified login callback.' }, + undefined, + { cause } + ); + } + + if ( + callback.protocol !== 'https:' || + callback.username || + callback.password || + callback.hash || + callback.searchParams.has('handoff') || + callback.searchParams.has('site_state') + ) { + throw errors.INTERNAL_FAILURE({ + details: 'The database returned an unsafe unified login callback.' + }); + } + if (!HANDOFF_CODE.test(handoffCode)) { + throw errors.INTERNAL_FAILURE({ details: 'The generated SSO handoff is invalid.' }); + } + if (!SITE_STATE.test(siteState)) { + throw errors.INTERNAL_FAILURE({ details: 'The database returned an invalid Site state.' }); + } + + callback.searchParams.set('handoff', handoffCode); + callback.searchParams.set('site_state', siteState); + return callback.toString(); +}; diff --git a/graphql/server/src/auth/sso/plugin.ts b/graphql/server/src/auth/sso/plugin.ts index 74cf134332..4158e68fb3 100644 --- a/graphql/server/src/auth/sso/plugin.ts +++ b/graphql/server/src/auth/sso/plugin.ts @@ -4,6 +4,7 @@ import { extendSchema, gql } from 'graphile-utils'; import { createUnifiedAuthService } from './service'; import type { ContinueUnifiedLoginInput, + RedeemUnifiedLoginHandoffInput, StartProviderAuthenticationInput, StartUnifiedLoginInput, UnifiedAuthGraphQLContext, @@ -60,7 +61,7 @@ export const createUnifiedAuthPlugin = ( type UnifiedLoginContinuationPayload { transactionId: String! authenticated: Boolean! - continuationUrl: String + continuationUrl: String! } type UnifiedLoginCredentialPayload { @@ -72,7 +73,17 @@ export const createUnifiedAuthPlugin = ( accessTokenExpiresAt: Datetime! isVerified: Boolean! totpEnabled: Boolean! - continuationUrl: String + continuationUrl: String! + } + + type RedeemUnifiedLoginHandoffPayload { + credentialId: UUID! + userId: UUID! + accessToken: String! + accessTokenExpiresAt: Datetime! + isVerified: Boolean! + totpEnabled: Boolean! + returnTo: String! } input StartUnifiedLoginInput { @@ -99,6 +110,10 @@ export const createUnifiedAuthPlugin = ( providerKey: String! } + input RedeemUnifiedLoginHandoffInput { + handoffCode: String! + } + extend type Query { unifiedAuthProviders: [UnifiedAuthProvider!]! } @@ -109,6 +124,7 @@ export const createUnifiedAuthPlugin = ( signInUnifiedLogin(input: UnifiedPasswordInput!): UnifiedLoginCredentialPayload! signUpUnifiedLogin(input: UnifiedPasswordInput!): UnifiedLoginCredentialPayload! startProviderAuthentication(input: StartProviderAuthenticationInput!): StartProviderAuthenticationPayload! + redeemUnifiedLoginHandoff(input: RedeemUnifiedLoginHandoffInput!): RedeemUnifiedLoginHandoffPayload! } `, resolvers: { @@ -144,7 +160,12 @@ export const createUnifiedAuthPlugin = ( _source: unknown, args: InputArguments, context: UnifiedAuthGraphQLContext - ) => service.startProvider(context, args.input) + ) => service.startProvider(context, args.input), + redeemUnifiedLoginHandoff: ( + _source: unknown, + args: InputArguments, + context: UnifiedAuthGraphQLContext + ) => service.redeem(context, args.input) } } }, 'UnifiedAuthPlugin'); diff --git a/graphql/server/src/auth/sso/provider-db-contract.ts b/graphql/server/src/auth/sso/provider-db-contract.ts index 784b4b3cdc..5e57c1b414 100644 --- a/graphql/server/src/auth/sso/provider-db-contract.ts +++ b/graphql/server/src/auth/sso/provider-db-contract.ts @@ -8,10 +8,12 @@ import sql from 'pg-sql2'; import { callFunction, + continuationFromDatabaseResult, optionalString, requiredBoolean, requiredString } from './db-contract'; +import type { HandoffMaterial } from './handoff'; export const PROVIDER_DB_FUNCTIONS = { start: 'start_provider_oauth_request', @@ -31,9 +33,10 @@ export const PROVIDER_DB_FUNCTIONS = { * binding and return the request fields parsed below. Consume atomically * marks the state used before Provider callback handling. * - `complete_provider_unified_login(uuid, text, text, text, jsonb, text, - * boolean, text)` accepts request ID plus normalized identity, existing - * credential options, and browser binding; it returns the unchanged - * identity-auth credential result and optional shared continuation. + * boolean, text, bytea)` accepts request ID plus normalized identity, + * existing credential options, browser binding, and the server-generated + * handoff digest; it returns the unchanged identity-auth credential result + * and transaction-bound callback continuation. */ export interface ProviderOAuthRequest { @@ -51,7 +54,7 @@ export interface ProviderCredentialResult { accessTokenExpiresAt: string; isVerified: boolean; totpEnabled: boolean; - continuationUrl: string | null; + continuationUrl: string; } /** @@ -161,6 +164,7 @@ export const completeProviderUnifiedLogin = async ( requestId: string; identity: NormalizedExternalIdentity; browserBinding: string; + handoff: HandoffMaterial; } ): Promise => { const operation = PROVIDER_DB_FUNCTIONS.complete; @@ -176,9 +180,10 @@ export const completeProviderUnifiedLogin = async ( sql.value(JSON.stringify(input.identity.profile)), sql.value('bearer'), sql.value(false), - sql.value(input.browserBinding) + sql.value(input.browserBinding), + sql.value(input.handoff.hash) ], - ['uuid', 'text', 'text', 'text', 'jsonb', 'text', 'boolean', 'text'] + ['uuid', 'text', 'text', 'text', 'jsonb', 'text', 'boolean', 'text', 'bytea'] ); const mfaRequired = requiredBoolean( @@ -202,6 +207,10 @@ export const completeProviderUnifiedLogin = async ( ), isVerified: requiredBoolean(row, 'is_verified', operation), totpEnabled: requiredBoolean(row, 'totp_enabled', operation), - continuationUrl: optionalString(row, 'continuation_url', operation) + continuationUrl: continuationFromDatabaseResult( + row, + operation, + input.handoff + ) }; }; diff --git a/graphql/server/src/auth/sso/service.ts b/graphql/server/src/auth/sso/service.ts index e49285f984..c04abb3d7d 100644 --- a/graphql/server/src/auth/sso/service.ts +++ b/graphql/server/src/auth/sso/service.ts @@ -16,6 +16,8 @@ import { signUpUnifiedLogin, startUnifiedLogin } from './db-contract'; +import { createHandoffMaterial } from './handoff'; +import { redeemUnifiedLoginHandoff } from './handoff-db-contract'; import { loadProviderDisplayOptions, resolveConfiguredProvider @@ -24,6 +26,8 @@ import { startProviderOAuthRequest } from './provider-db-contract'; import type { ContinueUnifiedLoginInput, ProviderDisplayOption, + RedeemUnifiedLoginHandoffInput, + RedeemUnifiedLoginHandoffPayload, StartProviderAuthenticationInput, StartProviderAuthenticationPayload, StartUnifiedLoginInput, @@ -120,6 +124,10 @@ export interface UnifiedAuthService { context: UnifiedAuthGraphQLContext, input: StartProviderAuthenticationInput ): Promise; + redeem( + context: UnifiedAuthGraphQLContext, + input: RedeemUnifiedLoginHandoffInput + ): Promise; } export const createUnifiedAuthService = (oauthEnabled: boolean): UnifiedAuthService => ({ @@ -149,7 +157,13 @@ export const createUnifiedAuthService = (oauthEnabled: boolean): UnifiedAuthServ const browserBinding = requireBrowserBinding(graphQLContext); const surface = await resolveSsoSurface(context); if (!context.userId) throw errors.UNAUTHENTICATED(); - return confirmUnifiedLogin(context, surface, input, browserBinding); + return confirmUnifiedLogin( + context, + surface, + input, + browserBinding, + createHandoffMaterial() + ); }, async signIn(graphQLContext, input) { @@ -157,7 +171,13 @@ export const createUnifiedAuthService = (oauthEnabled: boolean): UnifiedAuthServ const context = requireContext(graphQLContext); const browserBinding = requireBrowserBinding(graphQLContext); const surface = await resolveSsoSurface(context); - return signInUnifiedLogin(context, surface, input, browserBinding); + return signInUnifiedLogin( + context, + surface, + input, + browserBinding, + createHandoffMaterial() + ); }, async signUp(graphQLContext, input) { @@ -165,7 +185,13 @@ export const createUnifiedAuthService = (oauthEnabled: boolean): UnifiedAuthServ const context = requireContext(graphQLContext); const browserBinding = requireBrowserBinding(graphQLContext); const surface = await resolveSsoSurface(context); - return signUpUnifiedLogin(context, surface, input, browserBinding); + return signUpUnifiedLogin( + context, + surface, + input, + browserBinding, + createHandoffMaterial() + ); }, async startProvider(graphQLContext, input) { @@ -207,5 +233,25 @@ export const createUnifiedAuthService = (oauthEnabled: boolean): UnifiedAuthServ return { authorizationUrl: `/auth/oauth/authorize?state=${encodeURIComponent(state)}` }; + }, + + async redeem(graphQLContext, input) { + const context = requireContext(graphQLContext); + const token = context.token; + if (!token?.user_id) throw errors.UNAUTHENTICATED(); + if ( + token.kind !== 'api_key' || + typeof token.principal_id !== 'string' || + !context.api.apiId || + token.access_level === 'read_only' + ) { + throw errors.FORBIDDEN(); + } + const surface = await resolveSsoSurface(context); + return redeemUnifiedLoginHandoff( + context, + surface, + input.handoffCode + ); } }); diff --git a/graphql/server/src/auth/sso/types.ts b/graphql/server/src/auth/sso/types.ts index 422774e796..1c50162760 100644 --- a/graphql/server/src/auth/sso/types.ts +++ b/graphql/server/src/auth/sso/types.ts @@ -63,7 +63,21 @@ export interface StartUnifiedLoginPayload { export interface UnifiedLoginContinuationPayload { transactionId: string; authenticated: true; - continuationUrl: string | null; + continuationUrl: string; +} + +export interface RedeemUnifiedLoginHandoffInput { + handoffCode: string; +} + +export interface RedeemUnifiedLoginHandoffPayload { + credentialId: string; + userId: string; + accessToken: string; + accessTokenExpiresAt: string; + isVerified: boolean; + totpEnabled: boolean; + returnTo: string; } export interface UnifiedLoginCredentialPayload diff --git a/graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts b/graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts index 5f44391ba5..d7834475e4 100644 --- a/graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts +++ b/graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts @@ -11,7 +11,7 @@ describe('redactSensitiveRequestUrl', () => { '/callback?error=%5BREDACTED%5D' ); expect(redactSensitiveRequestUrl('/callback?handoff=secret&site_state=public')).toBe( - '/callback?handoff=%5BREDACTED%5D&site_state=public' + '/callback?handoff=%5BREDACTED%5D&site_state=%5BREDACTED%5D' ); }); diff --git a/graphql/server/src/middleware/observability/request-logger.ts b/graphql/server/src/middleware/observability/request-logger.ts index c6b92cb231..cc10fea3fb 100644 --- a/graphql/server/src/middleware/observability/request-logger.ts +++ b/graphql/server/src/middleware/observability/request-logger.ts @@ -11,6 +11,7 @@ const SENSITIVE_QUERY_PARAMETERS = new Set([ 'error_description', 'handoff', 'id_token', + 'site_state', 'state', 'token' ]); diff --git a/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts b/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts index 79cf1d3e32..5f7e34334e 100644 --- a/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts +++ b/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts @@ -321,7 +321,7 @@ describe('AuthCookiePlugin unified-auth cookie boundary', () => { { callback: jest.fn() } ); - await callback!( + const result = await callback!( next, { requestDigest: { @@ -353,11 +353,59 @@ describe('AuthCookiePlugin unified-auth cookie boundary', () => { } as never ); - const cookie = (setHeader.mock.calls[0][1] as string[])[0]; + const setCookieCall = setHeader.mock.calls.find(([name]) => name === 'Set-Cookie'); + const cookie = (setCookieCall?.[1] as string[])[0]; expect(cookie).toContain('constructive_session=cnc_live_bt_secret'); expect(cookie).toContain('Secure'); expect(cookie).toContain('HttpOnly'); expect(cookie).not.toContain('Domain='); + expect((result as { headers: Record }).headers['cache-control']) + .toBe('no-store'); + expect(setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store'); + }); + + it('marks handoff redemption no-store without writing a Constructive cookie', async () => { + const setHeader = jest.fn(); + const getHeader = jest.fn(); + const processRequest = AuthCookiePlugin.grafserv?.middleware?.processRequest; + const callback = typeof processRequest === 'function' + ? processRequest + : processRequest?.callback; + + const result = await callback!( + Object.assign(async () => ({ + type: 'buffer' as const, + statusCode: 200, + headers: { 'content-type': 'application/json' }, + buffer: Buffer.from(JSON.stringify({ + data: { redeemUnifiedLoginHandoff: { accessToken: 'site-token' } } + })) + }), { callback: jest.fn() }), + { + requestDigest: { + method: 'POST', + getBody: async () => ({ + type: 'buffer', + buffer: Buffer.from(JSON.stringify({ + query: `mutation Redeem($input: RedeemUnifiedLoginHandoffInput!) { + redeemUnifiedLoginHandoff(input: $input) { accessToken } + }`, + operationName: 'Redeem' + })) + }), + requestContext: { + expressv4: { + req: {}, + res: { setHeader, getHeader } + } + } + } + } as never + ); + + expect((result as { headers: Record }).headers['cache-control']) + .toBe('no-store'); + expect(setHeader.mock.calls.some(([name]) => name === 'Set-Cookie')).toBe(false); }); }); diff --git a/graphql/server/src/plugins/auth-cookie-plugin.ts b/graphql/server/src/plugins/auth-cookie-plugin.ts index 15b45f5639..4baa55b621 100644 --- a/graphql/server/src/plugins/auth-cookie-plugin.ts +++ b/graphql/server/src/plugins/auth-cookie-plugin.ts @@ -98,6 +98,19 @@ const UNIFIED_AUTH_SIGN_IN_MUTATIONS = new Set([ 'signUpUnifiedLogin' ]); +const NO_STORE_AUTH_MUTATIONS = new Set([ + 'startUnifiedLogin', + 'confirmUnifiedLogin', + 'signInUnifiedLogin', + 'signUpUnifiedLogin', + 'startProviderAuthentication', + 'redeemUnifiedLoginHandoff' +]); + +// `redeemUnifiedLoginHandoff` is intentionally absent: its caller is the +// target Site server, and only that Site's response may write its first-party +// Cookie. Constructive returns the distinct Site-local credential as data. + /** * Auth mutations that should clear the session cookie. */ @@ -298,6 +311,32 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { return result; } + const res = (event.requestDigest.requestContext as { + expressv4?: { + res?: { + setHeader: (name: string, value: string | string[]) => void; + getHeader: (name: string) => string | string[] | undefined; + }; + }; + })?.expressv4?.res; + const noStore = mutationFields.some(field => + NO_STORE_AUTH_MUTATIONS.has(field.fieldName) + ); + const authResult: BufferResult = noStore + ? { + ...bufferResult, + headers: { + ...bufferResult.headers, + 'cache-control': 'no-store', + pragma: 'no-cache' + } + } + : bufferResult; + if (noStore && res?.setHeader) { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Pragma', 'no-cache'); + } + // Check for auth mutations const signInMutation = mutationFields.find(field => SIGN_IN_MUTATIONS.has(field.fieldName) @@ -307,7 +346,7 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { ); if (!signInMutation && !signOutMutation) { - return result; + return authResult; } log.debug( @@ -323,7 +362,7 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { // Skip if there are GraphQL errors if (graphqlResponse.errors?.length || !graphqlResponse.data) { - return result; + return authResult; } const data = graphqlResponse.data; @@ -370,8 +409,6 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { // Set cookies directly on Express response and return modified headers if (cookiesToSet.length > 0) { - const res = (event.requestDigest.requestContext as { expressv4?: { res?: { setHeader: (name: string, value: string[]) => void; getHeader: (name: string) => string | string[] | undefined } } })?.expressv4?.res; - if (res?.setHeader) { // Get existing Set-Cookie headers from Express response const existingCookies = res.getHeader('Set-Cookie'); @@ -391,18 +428,18 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { } // Also update the BufferResult headers for grafserv to pass through - const updatedHeaders = { ...bufferResult.headers }; + const updatedHeaders = { ...authResult.headers }; // Remove set-cookie from grafserv headers since we set it on Express delete updatedHeaders['set-cookie']; return { - ...bufferResult, + ...authResult, headers: updatedHeaders, }; } - return result; + return authResult; }, }, }, diff --git a/packages/express-context/__tests__/pg-settings.test.ts b/packages/express-context/__tests__/pg-settings.test.ts index 6b87b83b15..eb080cf684 100644 --- a/packages/express-context/__tests__/pg-settings.test.ts +++ b/packages/express-context/__tests__/pg-settings.test.ts @@ -29,6 +29,38 @@ describe('buildPgSettings — jwt.claims.api_id provenance', () => { expect(settings['jwt.claims.user_id']).toBe('u1'); }); + it('forwards existing credential and principal claims to direct DB calls', () => { + const token = { + id: 'credential-1', + user_id: 'user-1', + session_id: 'session-1', + principal_id: 'principal-1', + kind: 'api_key', + access_level: 'full_access' + } as ConstructiveAPIToken; + + const settings = buildPgSettings({ api, token, requestId: 'r1' }); + + expect(settings).toMatchObject({ + 'jwt.claims.token_id': 'credential-1', + 'jwt.claims.user_id': 'user-1', + 'jwt.claims.session_id': 'session-1', + 'jwt.claims.principal_id': 'principal-1', + 'jwt.claims.kind': 'api_key', + 'jwt.claims.access_level': 'full_access' + }); + }); + + it('uses the human user as principal when a credential has no service principal', () => { + const settings = buildPgSettings({ + api, + token: { user_id: 'user-1' }, + requestId: 'r1' + }); + + expect(settings['jwt.claims.principal_id']).toBe('user-1'); + }); + it('omits jwt.claims.api_id when the api has no apiId (non-API surface)', () => { const settings = buildPgSettings({ api: { ...api, apiId: undefined }, diff --git a/packages/express-context/src/pg-settings.ts b/packages/express-context/src/pg-settings.ts index cb86336456..b7fe5ff940 100644 --- a/packages/express-context/src/pg-settings.ts +++ b/packages/express-context/src/pg-settings.ts @@ -37,20 +37,23 @@ export function buildPgSettings(input: PgSettingsInput): Record if (token?.user_id) { settings['role'] = api.roleName || 'authenticated'; settings['jwt.claims.user_id'] = token.user_id; + if (token.id) { + settings['jwt.claims.token_id'] = token.id; + } + if (token.session_id) { + settings['jwt.claims.session_id'] = token.session_id; + } + if (token.kind) { + settings['jwt.claims.kind'] = token.kind; + } + if (token.access_level) { + settings['jwt.claims.access_level'] = token.access_level; + } + settings['jwt.claims.principal_id'] = token.principal_id || token.user_id; } else { settings['role'] = api.anonRole || 'anonymous'; } - // Session claims - if (token?.session_id) { - settings['jwt.claims.session_id'] = token.session_id; - } - - // Principal identity (service accounts / bots) - if (token?.principal_id) { - settings['jwt.claims.principal_id'] = token.principal_id; - } - // Database context if (api.databaseId) { settings['jwt.claims.database_id'] = api.databaseId; From cffd1624318ea78f4b2fdd7b6118f6cf672f5889 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 10 Aug 2026 01:07:56 +0800 Subject: [PATCH 09/11] test: add unified auth integration coverage --- .../__fixtures__/seed/oauth-sso/contract.sql | 193 +++++++++++++++ .../__tests__/oauth-sso.integration.test.ts | 219 ++++++++++++++++++ .../__tests__/loaders/sso-surface.test.ts | 3 + .../src/loaders/sso-surface.ts | 2 +- 4 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 graphql/server-test/__fixtures__/seed/oauth-sso/contract.sql create mode 100644 graphql/server-test/__tests__/oauth-sso.integration.test.ts diff --git a/graphql/server-test/__fixtures__/seed/oauth-sso/contract.sql b/graphql/server-test/__fixtures__/seed/oauth-sso/contract.sql new file mode 100644 index 0000000000..d226cd5cec --- /dev/null +++ b/graphql/server-test/__fixtures__/seed/oauth-sso/contract.sql @@ -0,0 +1,193 @@ +-- Integration-only implementation of the frozen Constructive/DB SSO seam. +-- Product state transitions remain owned and tested by Constructive DB; this +-- fixture lets graphql-server-test exercise the real HTTP, routing, Context, +-- GraphQL, Cookie, and PostgreSQL call boundary without mocking those layers. + +CREATE SCHEMA tenant_test_sso_private; + +CREATE TABLE metaschema_modules_public.unified_auth_module ( + database_id uuid NOT NULL, + scope text NOT NULL, + private_schema_id uuid NOT NULL +); + +INSERT INTO metaschema_public.schema + (id, database_id, name, schema_name, description, is_public) +VALUES ( + 'f0000000-0000-0000-0000-000000000001', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'unified_auth_private', + 'tenant_test_sso_private', + 'Test-only unified authentication private surface', + false +); + +INSERT INTO metaschema_modules_public.unified_auth_module + (database_id, scope, private_schema_id) +VALUES ( + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'database', + 'f0000000-0000-0000-0000-000000000001' +); + +CREATE TABLE tenant_test_sso_private.test_login_transactions ( + transaction_id text PRIMARY KEY, + site_id uuid NOT NULL, + callback_url text NOT NULL, + return_to text NOT NULL, + site_state text NOT NULL, + browser_binding text NOT NULL, + start_api_id text NOT NULL +); + +CREATE TABLE tenant_test_sso_private.test_handoffs ( + code_hash bytea PRIMARY KEY, + transaction_id text NOT NULL, + expires_at timestamptz NOT NULL +); + +CREATE FUNCTION tenant_test_sso_private.start_unified_login( + requested_site_id uuid, + requested_callback_url text, + requested_return_to text, + requested_site_state text, + requested_browser_binding text +) +RETURNS TABLE ( + transaction_id text, + site_id uuid, + site_display_name text, + site_icon_url text, + site_theme_color text, + sign_in_mode text, + reusable_authentication boolean, + current_user_id uuid, + current_user_display_name text, + current_user_avatar_url text +) +LANGUAGE plpgsql +SECURITY DEFINER +AS $function$ +DECLARE + new_transaction_id text := repeat('t', 43); + exact_site_id uuid := 'f1000000-0000-0000-0000-000000000001'; + exact_callback text := 'https://site-one.example/auth/complete?locale=en'; + routed_api_id text := current_setting('jwt.claims.api_id', true); +BEGIN + IF requested_site_id <> exact_site_id THEN + RAISE EXCEPTION 'INVALID_SSO_CALLBACK'; + END IF; + IF requested_callback_url IS NOT NULL AND requested_callback_url <> exact_callback THEN + RAISE EXCEPTION 'INVALID_SSO_CALLBACK'; + END IF; + IF routed_api_id <> '6c9997a4-591b-4cb3-9313-4ef45d6f134e' THEN + RAISE EXCEPTION 'INVALID_SSO_CALLBACK'; + END IF; + + INSERT INTO tenant_test_sso_private.test_login_transactions ( + transaction_id, + site_id, + callback_url, + return_to, + site_state, + browser_binding, + start_api_id + ) VALUES ( + new_transaction_id, + exact_site_id, + exact_callback, + requested_return_to, + requested_site_state, + requested_browser_binding, + routed_api_id + ) + ON CONFLICT ON CONSTRAINT test_login_transactions_pkey DO UPDATE SET + return_to = EXCLUDED.return_to, + site_state = EXCLUDED.site_state, + browser_binding = EXCLUDED.browser_binding, + start_api_id = EXCLUDED.start_api_id; + + RETURN QUERY SELECT + new_transaction_id, + exact_site_id, + 'Customer Portal'::text, + NULL::text, + '#112233'::text, + 'confirm'::text, + false, + NULL::uuid, + NULL::text, + NULL::text; +END; +$function$; + +CREATE FUNCTION tenant_test_sso_private.sign_in_unified_login( + requested_transaction_id text, + requested_email text, + requested_password text, + requested_remember_me boolean, + requested_credential_kind text, + requested_browser_binding text, + requested_device_token text, + requested_handoff_hash bytea +) +RETURNS TABLE ( + id uuid, + user_id uuid, + access_token text, + access_token_expires_at timestamptz, + is_verified boolean, + totp_enabled boolean, + mfa_required boolean, + callback_url text, + site_state text, + handoff_expires_at timestamptz +) +LANGUAGE plpgsql +SECURITY DEFINER +AS $function$ +DECLARE + login tenant_test_sso_private.test_login_transactions%ROWTYPE; + expiry timestamptz := clock_timestamp() + interval '1 minute'; +BEGIN + SELECT * INTO STRICT login + FROM tenant_test_sso_private.test_login_transactions transaction_row + WHERE transaction_row.transaction_id = requested_transaction_id; + + IF login.browser_binding <> requested_browser_binding OR + requested_email <> 'user@example.com' OR + requested_password <> 'correct horse battery staple' OR + requested_credential_kind <> 'bearer' OR + current_setting('jwt.claims.api_id', true) <> login.start_api_id THEN + RAISE EXCEPTION 'BAD_SIGNIN'; + END IF; + + INSERT INTO tenant_test_sso_private.test_handoffs ( + code_hash, + transaction_id, + expires_at + ) VALUES ( + requested_handoff_hash, + requested_transaction_id, + expiry + ); + + RETURN QUERY SELECT + 'f2000000-0000-0000-0000-000000000001'::uuid, + 'f3000000-0000-0000-0000-000000000001'::uuid, + 'cnc_live_bt_auth_center_fixture'::text, + clock_timestamp() + interval '1 hour', + true, + false, + false, + login.callback_url, + login.site_state, + expiry; +END; +$function$; + +GRANT USAGE ON SCHEMA tenant_test_sso_private TO anonymous, authenticated; +GRANT EXECUTE ON FUNCTION tenant_test_sso_private.start_unified_login(uuid, text, text, text, text) + TO anonymous, authenticated; +GRANT EXECUTE ON FUNCTION tenant_test_sso_private.sign_in_unified_login(text, text, text, boolean, text, text, text, bytea) + TO anonymous, authenticated; diff --git a/graphql/server-test/__tests__/oauth-sso.integration.test.ts b/graphql/server-test/__tests__/oauth-sso.integration.test.ts new file mode 100644 index 0000000000..0d3b2ff807 --- /dev/null +++ b/graphql/server-test/__tests__/oauth-sso.integration.test.ts @@ -0,0 +1,219 @@ +import { createHash } from 'node:crypto'; +import path from 'node:path'; + +import type { PgTestClient } from 'pgsql-test/test-client'; +import type supertest from 'supertest'; + +import { getConnections, seed } from '../src'; + +jest.setTimeout(60_000); + +const sharedSeedRoot = path.join(__dirname, '..', '..', '..', '__fixtures__', 'seed'); +const shared = (...segments: string[]) => path.join(sharedSeedRoot, ...segments); +const local = (...segments: string[]) => path.join( + __dirname, + '..', + '__fixtures__', + 'seed', + 'oauth-sso', + ...segments +); +const pgpmWorkspace = path.join(sharedSeedRoot, '..', '..'); +const siteId = 'f1000000-0000-0000-0000-000000000001'; +const authHost = 'app.test.constructive.io'; +const browserBinding = 'b'.repeat(43); +const siteState = 's'.repeat(43); + +const metaSchemas = [ + 'catalog_private', + 'routing_public', + 'apps_public', + 'metaschema_public', + 'metaschema_modules_public' +]; + +describe('OAuth/SSO real server integration seam', () => { + let request: supertest.Agent; + let pg: PgTestClient; + let teardown: () => Promise; + + const postGraphQL = (query: string, variables?: Record) => + request + .post('/graphql') + .set('Host', authHost) + .set('Cookie', `csrf_token=${browserBinding}`) + .send({ query, variables }); + + const startLogin = () => postGraphQL( + `mutation Start($input: StartUnifiedLoginInput!) { + startUnifiedLogin(input: $input) { + transactionId + site { id displayName themeColor } + providers { key } + } + }`, + { + input: { + siteId, + returnTo: '/approvals/42', + siteState + } + } + ); + + beforeAll(async () => { + ({ request, pg, teardown } = await getConnections( + { + schemas: ['simple-pets-public', 'simple-pets-pets-public'], + authRole: 'anonymous', + server: { + useRouting: true, + api: { + isPublic: true, + metaSchemas + } + } + }, + [ + 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'), + local('contract.sql') + ]) + ] + )); + }); + + afterAll(async () => teardown()); + + it('starts only through the canonical routed Tenant host', async () => { + const response = await startLogin(); + + expect(response.status).toBe(200); + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.startUnifiedLogin).toMatchObject({ + transactionId: 't'.repeat(43), + site: { + id: siteId, + displayName: 'Customer Portal', + themeColor: '#112233' + }, + providers: [] + }); + + const [stored] = await pg.any<{ + browser_binding: string; + return_to: string; + start_api_id: string; + }>( + `SELECT browser_binding, return_to, start_api_id + FROM tenant_test_sso_private.test_login_transactions` + ); + expect(stored).toEqual({ + browser_binding: browserBinding, + return_to: '/approvals/42', + start_api_id: '6c9997a4-591b-4cb3-9313-4ef45d6f134e' + }); + + const unknownHost = await request + .post('/graphql') + .set('Host', 'unknown.example.test') + .set('Cookie', `csrf_token=${browserBinding}`) + .send({ + query: `mutation Start($input: StartUnifiedLoginInput!) { + startUnifiedLogin(input: $input) { transactionId } + }`, + variables: { input: { siteId, siteState } } + }); + expect(unknownHost.status).toBe(404); + }); + + it('converges local sign-in on a hashed handoff and host-only auth cookie', async () => { + const startResponse = await startLogin(); + expect(startResponse.status).toBe(200); + expect(startResponse.body.errors).toBeUndefined(); + + const response = await postGraphQL( + `mutation SignIn($input: UnifiedPasswordInput!) { + signInUnifiedLogin(input: $input) { + accessToken + continuationUrl + } + }`, + { + input: { + transactionId: 't'.repeat(43), + email: 'user@example.com', + password: 'correct horse battery staple' + } + } + ); + + expect(response.status).toBe(200); + expect(response.body.errors).toBeUndefined(); + expect(response.headers['cache-control']).toBe('no-store'); + const setCookie = response.headers['set-cookie']; + const cookies = Array.isArray(setCookie) ? setCookie : [setCookie]; + const cookie = cookies + .find(value => value.startsWith('constructive_session=')); + expect(cookie).toContain('Secure'); + expect(cookie).toContain('HttpOnly'); + expect(cookie).not.toContain('Domain='); + + const continuation = new URL( + response.body.data.signInUnifiedLogin.continuationUrl + ); + const handoff = continuation.searchParams.get('handoff'); + expect(continuation.origin).toBe('https://site-one.example'); + expect(continuation.pathname).toBe('/auth/complete'); + expect(continuation.searchParams.get('locale')).toBe('en'); + expect(continuation.searchParams.get('site_state')).toBe(siteState); + expect(handoff).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(continuation.toString()).not.toContain('cnc_live_bt_auth_center_fixture'); + + const [stored] = await pg.any<{ code_hash_hex: string }>( + `SELECT encode(code_hash, 'hex') AS code_hash_hex + FROM tenant_test_sso_private.test_handoffs` + ); + expect(stored.code_hash_hex).toBe( + createHash('sha256').update(handoff as string).digest('hex') + ); + }); + + it('does not allow possession-only redemption from a browser request', async () => { + const response = await postGraphQL( + `mutation Redeem($input: RedeemUnifiedLoginHandoffInput!) { + redeemUnifiedLoginHandoff(input: $input) { accessToken returnTo } + }`, + { input: { handoffCode: 'h'.repeat(43) } } + ); + + expect(response.status).toBe(200); + expect(response.body.data).toBeNull(); + expect(response.body.errors[0].extensions.code).toBe('UNAUTHENTICATED'); + expect(response.headers['set-cookie'] ?? []).not.toEqual( + expect.arrayContaining([expect.stringContaining('constructive_session=')]) + ); + }); + + it('keeps OAuth disabled behavior stable without Provider resolution', async () => { + const response = await postGraphQL( + `mutation Provider($input: StartProviderAuthenticationInput!) { + startProviderAuthentication(input: $input) { authorizationUrl } + }`, + { + input: { + transactionId: 't'.repeat(43), + providerKey: 'google' + } + } + ); + + expect(response.status).toBe(200); + expect(response.body.data).toBeNull(); + expect(response.body.errors[0].extensions.code).toBe('OAUTH_SIGN_IN_DISABLED'); + expect(response.headers['cache-control']).toBe('no-store'); + }); +}); diff --git a/packages/express-context/__tests__/loaders/sso-surface.test.ts b/packages/express-context/__tests__/loaders/sso-surface.test.ts index 92af9c3e39..b7c9802e8f 100644 --- a/packages/express-context/__tests__/loaders/sso-surface.test.ts +++ b/packages/express-context/__tests__/loaders/sso-surface.test.ts @@ -49,6 +49,9 @@ describe('ssoSurfaceLoader', () => { expect(calls[0].text).toMatch( /private_schema\.id = unified_auth\.private_schema_id/ ); + expect(calls[0].text).toMatch( + /private_schema\.schema_name AS private_schema/ + ); }); it('returns undefined when this Tenant has no provisioned module', async () => { diff --git a/packages/express-context/src/loaders/sso-surface.ts b/packages/express-context/src/loaders/sso-surface.ts index 212319214d..ade9c99211 100644 --- a/packages/express-context/src/loaders/sso-surface.ts +++ b/packages/express-context/src/loaders/sso-surface.ts @@ -15,7 +15,7 @@ import type { LoaderContext, ModuleLoader } from './types'; import { requireDatabaseId } from './types'; const SSO_SURFACE_SQL = ` - SELECT private_schema.name AS private_schema + SELECT private_schema.schema_name AS private_schema FROM metaschema_modules_public.unified_auth_module unified_auth JOIN metaschema_public.schema private_schema ON private_schema.id = unified_auth.private_schema_id From 646d83b71c6a64f28c847ec552dc14d48c060aa2 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 11 Aug 2026 01:35:33 +0800 Subject: [PATCH 10/11] feat: converge OAuth SSO runtime integration --- .../__fixtures__/seed/oauth-sso/contract.sql | 193 -------- .../seed/oauth-sso/real-runtime.ts | 430 +++++++++++++++++ .../__tests__/oauth-sso.integration.test.ts | 438 +++++++++++++----- graphql/server-test/package.json | 1 + .../src/constructive-db-runtime.ts | 27 ++ graphql/server-test/src/index.ts | 2 + graphql/server-test/src/server.ts | 5 +- graphql/server-test/src/types.ts | 2 + .../src/auth/oauth/__tests__/router.test.ts | 6 + .../src/auth/oauth/__tests__/service.test.ts | 5 +- graphql/server/src/auth/oauth/router.ts | 1 + graphql/server/src/auth/oauth/service.ts | 2 + .../src/auth/sso/__tests__/service.test.ts | 24 +- .../auth/sso/__tests__/site-session.test.ts | 108 +++++ graphql/server/src/auth/sso/db-contract.ts | 54 ++- graphql/server/src/auth/sso/handoff.ts | 11 +- graphql/server/src/auth/sso/opaque.ts | 13 + .../src/auth/sso/provider-db-contract.ts | 36 +- graphql/server/src/auth/sso/service.ts | 1 + graphql/server/src/auth/sso/site-session.ts | 56 +++ .../src/middleware/__tests__/routing.test.ts | 2 + .../server/src/middleware/error-handler.ts | 19 +- graphql/server/src/middleware/graphile.ts | 7 +- graphql/server/src/middleware/routing.ts | 3 + .../__tests__/auth-cookie-plugin.test.ts | 8 +- .../server/src/plugins/auth-cookie-plugin.ts | 8 +- graphql/server/src/server.ts | 2 + .../__tests__/loaders/auth-loaders.test.ts | 32 +- .../__tests__/pg-settings.test.ts | 16 +- packages/express-context/src/context.ts | 3 +- .../src/loaders/identity-providers.ts | 47 +- packages/express-context/src/pg-settings.ts | 7 + packages/express-context/src/types.ts | 4 + pnpm-lock.yaml | 3 + 34 files changed, 1190 insertions(+), 386 deletions(-) delete mode 100644 graphql/server-test/__fixtures__/seed/oauth-sso/contract.sql create mode 100644 graphql/server-test/__fixtures__/seed/oauth-sso/real-runtime.ts create mode 100644 graphql/server-test/src/constructive-db-runtime.ts create mode 100644 graphql/server/src/auth/sso/__tests__/site-session.test.ts create mode 100644 graphql/server/src/auth/sso/opaque.ts create mode 100644 graphql/server/src/auth/sso/site-session.ts diff --git a/graphql/server-test/__fixtures__/seed/oauth-sso/contract.sql b/graphql/server-test/__fixtures__/seed/oauth-sso/contract.sql deleted file mode 100644 index d226cd5cec..0000000000 --- a/graphql/server-test/__fixtures__/seed/oauth-sso/contract.sql +++ /dev/null @@ -1,193 +0,0 @@ --- Integration-only implementation of the frozen Constructive/DB SSO seam. --- Product state transitions remain owned and tested by Constructive DB; this --- fixture lets graphql-server-test exercise the real HTTP, routing, Context, --- GraphQL, Cookie, and PostgreSQL call boundary without mocking those layers. - -CREATE SCHEMA tenant_test_sso_private; - -CREATE TABLE metaschema_modules_public.unified_auth_module ( - database_id uuid NOT NULL, - scope text NOT NULL, - private_schema_id uuid NOT NULL -); - -INSERT INTO metaschema_public.schema - (id, database_id, name, schema_name, description, is_public) -VALUES ( - 'f0000000-0000-0000-0000-000000000001', - '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', - 'unified_auth_private', - 'tenant_test_sso_private', - 'Test-only unified authentication private surface', - false -); - -INSERT INTO metaschema_modules_public.unified_auth_module - (database_id, scope, private_schema_id) -VALUES ( - '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', - 'database', - 'f0000000-0000-0000-0000-000000000001' -); - -CREATE TABLE tenant_test_sso_private.test_login_transactions ( - transaction_id text PRIMARY KEY, - site_id uuid NOT NULL, - callback_url text NOT NULL, - return_to text NOT NULL, - site_state text NOT NULL, - browser_binding text NOT NULL, - start_api_id text NOT NULL -); - -CREATE TABLE tenant_test_sso_private.test_handoffs ( - code_hash bytea PRIMARY KEY, - transaction_id text NOT NULL, - expires_at timestamptz NOT NULL -); - -CREATE FUNCTION tenant_test_sso_private.start_unified_login( - requested_site_id uuid, - requested_callback_url text, - requested_return_to text, - requested_site_state text, - requested_browser_binding text -) -RETURNS TABLE ( - transaction_id text, - site_id uuid, - site_display_name text, - site_icon_url text, - site_theme_color text, - sign_in_mode text, - reusable_authentication boolean, - current_user_id uuid, - current_user_display_name text, - current_user_avatar_url text -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $function$ -DECLARE - new_transaction_id text := repeat('t', 43); - exact_site_id uuid := 'f1000000-0000-0000-0000-000000000001'; - exact_callback text := 'https://site-one.example/auth/complete?locale=en'; - routed_api_id text := current_setting('jwt.claims.api_id', true); -BEGIN - IF requested_site_id <> exact_site_id THEN - RAISE EXCEPTION 'INVALID_SSO_CALLBACK'; - END IF; - IF requested_callback_url IS NOT NULL AND requested_callback_url <> exact_callback THEN - RAISE EXCEPTION 'INVALID_SSO_CALLBACK'; - END IF; - IF routed_api_id <> '6c9997a4-591b-4cb3-9313-4ef45d6f134e' THEN - RAISE EXCEPTION 'INVALID_SSO_CALLBACK'; - END IF; - - INSERT INTO tenant_test_sso_private.test_login_transactions ( - transaction_id, - site_id, - callback_url, - return_to, - site_state, - browser_binding, - start_api_id - ) VALUES ( - new_transaction_id, - exact_site_id, - exact_callback, - requested_return_to, - requested_site_state, - requested_browser_binding, - routed_api_id - ) - ON CONFLICT ON CONSTRAINT test_login_transactions_pkey DO UPDATE SET - return_to = EXCLUDED.return_to, - site_state = EXCLUDED.site_state, - browser_binding = EXCLUDED.browser_binding, - start_api_id = EXCLUDED.start_api_id; - - RETURN QUERY SELECT - new_transaction_id, - exact_site_id, - 'Customer Portal'::text, - NULL::text, - '#112233'::text, - 'confirm'::text, - false, - NULL::uuid, - NULL::text, - NULL::text; -END; -$function$; - -CREATE FUNCTION tenant_test_sso_private.sign_in_unified_login( - requested_transaction_id text, - requested_email text, - requested_password text, - requested_remember_me boolean, - requested_credential_kind text, - requested_browser_binding text, - requested_device_token text, - requested_handoff_hash bytea -) -RETURNS TABLE ( - id uuid, - user_id uuid, - access_token text, - access_token_expires_at timestamptz, - is_verified boolean, - totp_enabled boolean, - mfa_required boolean, - callback_url text, - site_state text, - handoff_expires_at timestamptz -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $function$ -DECLARE - login tenant_test_sso_private.test_login_transactions%ROWTYPE; - expiry timestamptz := clock_timestamp() + interval '1 minute'; -BEGIN - SELECT * INTO STRICT login - FROM tenant_test_sso_private.test_login_transactions transaction_row - WHERE transaction_row.transaction_id = requested_transaction_id; - - IF login.browser_binding <> requested_browser_binding OR - requested_email <> 'user@example.com' OR - requested_password <> 'correct horse battery staple' OR - requested_credential_kind <> 'bearer' OR - current_setting('jwt.claims.api_id', true) <> login.start_api_id THEN - RAISE EXCEPTION 'BAD_SIGNIN'; - END IF; - - INSERT INTO tenant_test_sso_private.test_handoffs ( - code_hash, - transaction_id, - expires_at - ) VALUES ( - requested_handoff_hash, - requested_transaction_id, - expiry - ); - - RETURN QUERY SELECT - 'f2000000-0000-0000-0000-000000000001'::uuid, - 'f3000000-0000-0000-0000-000000000001'::uuid, - 'cnc_live_bt_auth_center_fixture'::text, - clock_timestamp() + interval '1 hour', - true, - false, - false, - login.callback_url, - login.site_state, - expiry; -END; -$function$; - -GRANT USAGE ON SCHEMA tenant_test_sso_private TO anonymous, authenticated; -GRANT EXECUTE ON FUNCTION tenant_test_sso_private.start_unified_login(uuid, text, text, text, text) - TO anonymous, authenticated; -GRANT EXECUTE ON FUNCTION tenant_test_sso_private.sign_in_unified_login(text, text, text, boolean, text, text, text, bytea) - TO anonymous, authenticated; diff --git a/graphql/server-test/__fixtures__/seed/oauth-sso/real-runtime.ts b/graphql/server-test/__fixtures__/seed/oauth-sso/real-runtime.ts new file mode 100644 index 0000000000..d1a617e15f --- /dev/null +++ b/graphql/server-test/__fixtures__/seed/oauth-sso/real-runtime.ts @@ -0,0 +1,430 @@ +import { createHash } from 'node:crypto'; + +import type { SeedAdapter, SeedContext } from 'pgsql-test/seed/types'; + +export const REAL_RUNTIME_FIXTURE = { + ownerId: 'f0000000-0000-4000-8000-000000000001', + siteId: 'f1000000-0000-4000-8000-000000000001', + runtimeBucketId: 'f1100000-0000-4000-8000-000000000001', + serviceUserId: 'f2000000-0000-4000-8000-000000000001', + serviceSessionId: 'f3000000-0000-4000-8000-000000000001', + serviceCredentialId: 'f4000000-0000-4000-8000-000000000001', + servicePrincipalId: 'f5000000-0000-4000-8000-000000000001', + serviceApiKey: 'cnc_live_bt_sso_site_runtime_fixture', + authHost: 'auth-auth-sso-e2e.test.constructive.io', + siteHost: 'api-auth-sso-e2e.test.constructive.io' +} as const; + +const modules = [ + 'users_module', + 'membership_types_module', + ['permissions_module', { scope: 'app' }], + ['limits_module', { scope: 'app' }], + ['levels_module', { scope: 'app' }], + ['memberships_module', { scope: 'app' }], + ['permissions_module', { scope: 'org' }], + ['limits_module', { scope: 'org' }], + ['memberships_module', { scope: 'org' }], + 'sessions_module', + 'user_state_module', + 'user_credentials_module', + ['internal_secrets_module', { scope: 'app' }], + ['internal_secrets_module', { scope: 'database' }], + 'emails_module', + 'rls_module', + 'connected_accounts_module', + ['identity_providers_module', { scope: 'database' }], + 'user_auth_module', + [ + 'catalog_module', + { scope: 'database', public_schema_name: 'catalog_private', policies: [] } + ], + [ + 'site_surface_module', + { + scope: 'database', + prefix: '', + public_schema_name: 'routing_public', + policies: [] + } + ], + ['oauth_requests_module', { scope: 'database', prefix: '' }], + ['unified_auth_module', { scope: 'database', prefix: '' }] +] as const; + +const quoteIdentifier = (value: string): string => + `"${value.replaceAll('"', '""')}"`; + +const relation = (schema: string, table: string): string => + `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`; + +const schemaName = async (ctx: SeedContext, schemaId: string): Promise => { + const row = await ctx.pg.one<{ schema_name: string }>( + 'SELECT schema_name FROM metaschema_public.schema WHERE id = $1', + [schemaId] + ); + return row.schema_name; +}; + +const tableName = async (ctx: SeedContext, tableId: string): Promise => { + const row = await ctx.pg.one<{ name: string }>( + 'SELECT name FROM metaschema_public.table WHERE id = $1', + [tableId] + ); + return row.name; +}; + +const hasColumn = async ( + ctx: SeedContext, + schema: string, + table: string, + column: string +): Promise => { + const row = await ctx.pg.one<{ present: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 AND column_name = $3 + ) AS present`, + [schema, table, column] + ); + return row.present; +}; + +/** + * Provision only test data around the real generated Constructive DB runtime. + * No SSO table or function is reproduced here. + */ +export const seedRealUnifiedAuthRuntime = (): SeedAdapter => ({ + async seed(ctx) { + await ctx.pg.any( + `INSERT INTO constructive_users_public.users (id, username) + VALUES ($1, 'sso_e2e_owner') + ON CONFLICT (id) DO NOTHING`, + [REAL_RUNTIME_FIXTURE.ownerId] + ); + + await ctx.pg.any("SET constructive.allow_super_constructive = 'true'"); + const provisioned = await ctx.pg.one<{ database_id: string }>( + `SELECT metaschema_generators.provision_database( + v_database_name := 'auth-sso-e2e', + v_owner_id := $1, + v_subdomain := 'auth-sso-e2e', + v_domain := 'test.constructive.io', + v_modules := $2::jsonb, + v_options := '{}'::jsonb + ) AS database_id`, + [REAL_RUNTIME_FIXTURE.ownerId, JSON.stringify(modules)] + ); + await ctx.pg.any('RESET constructive.allow_super_constructive'); + const databaseId = provisioned.database_id; + + const siteModule = await ctx.pg.one<{ + schema_id: string; + sites_table_id: string; + }>( + `SELECT schema_id, sites_table_id + FROM metaschema_modules_public.site_surface_module + WHERE database_id = $1 AND scope = 'database'`, + [databaseId] + ); + const catalogModule = await ctx.pg.one<{ + schema_id: string; + buckets_table_id: string; + }>( + `SELECT schema_id, buckets_table_id + FROM metaschema_modules_public.catalog_module + WHERE database_id = $1 AND scope = 'database'`, + [databaseId] + ); + const unifiedModule = await ctx.pg.one<{ + private_schema_id: string; + site_auth_callbacks_table_name: string; + site_runtime_clients_table_name: string; + }>( + `SELECT private_schema_id, site_auth_callbacks_table_name, + site_runtime_clients_table_name + FROM metaschema_modules_public.unified_auth_module + WHERE database_id = $1 AND scope = 'database'`, + [databaseId] + ); + const sessionsModule = await ctx.pg.one<{ + schema_id: string; + sessions_table_id: string; + session_credentials_table_id: string; + auth_settings_table_id: string; + }>( + `SELECT schema_id, sessions_table_id, session_credentials_table_id, + auth_settings_table_id + FROM metaschema_modules_public.sessions_module + WHERE database_id = $1`, + [databaseId] + ); + const usersModule = await ctx.pg.one<{ + schema_id: string; + table_id: string; + }>( + `SELECT schema_id, table_id + FROM metaschema_modules_public.users_module + WHERE database_id = $1`, + [databaseId] + ); + const providersModule = await ctx.pg.one<{ + private_schema_id: string; + table_name: string; + }>( + `SELECT private_schema_id, table_name + FROM metaschema_modules_public.identity_providers_module + WHERE database_id = $1`, + [databaseId] + ); + const secretsModule = await ctx.pg.one<{ + private_schema_id: string; + internal_secrets_table_name: string; + prefix: string; + }>( + `SELECT private_schema_id, internal_secrets_table_name, prefix + FROM metaschema_modules_public.internal_secrets_module + WHERE database_id = $1 AND scope = 'database'`, + [databaseId] + ); + + const [ + siteSchema, + catalogSchema, + privateSchema, + sessionsSchema, + usersSchema, + providersSchema, + secretsPrivateSchema + ] = + await Promise.all([ + schemaName(ctx, siteModule.schema_id), + schemaName(ctx, catalogModule.schema_id), + schemaName(ctx, unifiedModule.private_schema_id), + schemaName(ctx, sessionsModule.schema_id), + schemaName(ctx, usersModule.schema_id), + schemaName(ctx, providersModule.private_schema_id), + schemaName(ctx, secretsModule.private_schema_id) + ]); + const { schema_name: secretsPublicSchema } = await ctx.pg.one<{ + schema_name: string; + }>( + `SELECT schema_name + FROM metaschema_public.schema + WHERE database_id = $1 AND schema_name LIKE '%store-public' + ORDER BY schema_name + LIMIT 1`, + [databaseId] + ); + const [sitesTable, bucketsTable, sessionsTable, credentialsTable, authSettingsTable, usersTable] = + await Promise.all([ + tableName(ctx, siteModule.sites_table_id), + tableName(ctx, catalogModule.buckets_table_id), + tableName(ctx, sessionsModule.sessions_table_id), + tableName(ctx, sessionsModule.session_credentials_table_id), + tableName(ctx, sessionsModule.auth_settings_table_id), + tableName(ctx, usersModule.table_id) + ]); + + const bucket = await ctx.pg.one<{ id: string }>( + `INSERT INTO ${relation(catalogSchema, bucketsTable)} + (owner_scope, owner_key, is_visible, database_id, key, type) + VALUES ('platform', NULL, true, $1, 'sso-e2e-site', 'public') + RETURNING id`, + [databaseId] + ); + await ctx.pg.any( + `INSERT INTO ${relation(siteSchema, sitesTable)} + (id, name, title, bucket_id, is_published, unified_auth_enabled, + unified_auth_sign_in_mode, unified_auth_sso_group_key, database_id) + VALUES ($1, 'customer-portal', 'Customer Portal', $2, true, true, + 'confirm', 'customer-apps', $3)`, + [REAL_RUNTIME_FIXTURE.siteId, bucket.id, databaseId] + ); + await ctx.pg.any( + `INSERT INTO catalog_private.buckets + (id, owner_scope, owner_key, is_visible, database_id, key, type) + VALUES ($1, 'database', $2, true, $2, 'sso-e2e-runtime', 'public')`, + [REAL_RUNTIME_FIXTURE.runtimeBucketId, databaseId] + ); + await ctx.pg.any( + `INSERT INTO routing_public.sites + (id, database_id, name, title, bucket_id, is_published) + VALUES ($1, $2, 'customer-portal-runtime', 'Customer Portal', $3, true)`, + [ + REAL_RUNTIME_FIXTURE.siteId, + databaseId, + REAL_RUNTIME_FIXTURE.runtimeBucketId + ] + ); + await ctx.pg.any( + `INSERT INTO ${relation(siteSchema, unifiedModule.site_auth_callbacks_table_name)} + (site_id, callback_url, active, database_id) + VALUES ($1, $2, true, $3)`, + [ + REAL_RUNTIME_FIXTURE.siteId, + `https://${REAL_RUNTIME_FIXTURE.siteHost}/auth/complete`, + databaseId + ] + ); + + const siteApi = await ctx.pg.one<{ id: string }>( + `SELECT id FROM routing_public.apis + WHERE database_id = $1 AND name = 'api'`, + [databaseId] + ); + await ctx.pg.any( + `INSERT INTO ${relation(siteSchema, unifiedModule.site_runtime_clients_table_name)} + (site_id, api_id, principal_id, active, database_id) + VALUES ($1, $2, $3, true, $4)`, + [ + REAL_RUNTIME_FIXTURE.siteId, + siteApi.id, + REAL_RUNTIME_FIXTURE.servicePrincipalId, + databaseId + ] + ); + await ctx.pg.any( + `UPDATE routing_public.routes + SET runtime_site_id = $1 + WHERE database_id = $2 AND target_api_id = $3`, + [REAL_RUNTIME_FIXTURE.siteId, databaseId, siteApi.id] + ); + + await ctx.pg.any( + `UPDATE ${relation(sessionsSchema, authSettingsTable)} + SET require_csrf_for_auth = false, + allow_identity_sign_in = true, + allow_identity_sign_up = true` + ); + + const secretSetFunction = `${secretsModule.prefix}_internal_secrets_set`; + await ctx.pg.any("SELECT set_config('jwt.claims.database_id', $1, false)", [ + databaseId + ]); + await ctx.pg.any( + `SELECT ${relation( + secretsPublicSchema, + secretSetFunction + )}($1, 'github/client-secret', 'github-client-secret', uuid_nil(), 'pgp')`, + [databaseId] + ); + const providerSecret = await ctx.pg.one<{ id: string }>( + `SELECT id + FROM ${relation( + secretsPrivateSchema, + secretsModule.internal_secrets_table_name + )} + WHERE name = 'github/client-secret' + AND namespace_id = uuid_nil() + AND retired_at IS NULL`, + ); + await ctx.pg.any( + `INSERT INTO ${relation(providersSchema, providersModule.table_name)} + (slug, kind, display_name, enabled, client_id, client_secret_id, + authorization_url, token_url, userinfo_url, scopes, pkce_enabled) + VALUES ('github', 'github', 'GitHub', true, 'github-client', $1, + 'https://github.com/login/oauth/authorize', + 'https://github.com/login/oauth/access_token', + 'https://api.github.com/user', + ARRAY['read:user', 'user:email'], true)`, + [providerSecret.id] + ); + + const userColumns = ['id', 'username']; + const userValues: unknown[] = [REAL_RUNTIME_FIXTURE.serviceUserId, 'sso_site_runtime']; + if (await hasColumn(ctx, usersSchema, usersTable, 'database_id')) { + userColumns.push('database_id'); + userValues.push(databaseId); + } + await ctx.pg.any( + `INSERT INTO ${relation(usersSchema, usersTable)} + (${userColumns.map(quoteIdentifier).join(', ')}) + VALUES (${userValues.map((_, index) => `$${index + 1}`).join(', ')})`, + userValues + ); + + const sessionColumns = [ + 'id', + 'user_id', + 'is_anonymous', + 'expires_at', + 'csrf_secret', + 'fingerprint_mode', + 'auth_method' + ]; + const sessionValues: unknown[] = [ + REAL_RUNTIME_FIXTURE.serviceSessionId, + REAL_RUNTIME_FIXTURE.serviceUserId, + false, + new Date(Date.now() + 60 * 60 * 1000), + Buffer.alloc(32, 7), + 'none', + 'api_key' + ]; + if (await hasColumn(ctx, sessionsSchema, sessionsTable, 'database_id')) { + sessionColumns.push('database_id'); + sessionValues.push(databaseId); + } + await ctx.pg.any( + `INSERT INTO ${relation(sessionsSchema, sessionsTable)} + (${sessionColumns.map(quoteIdentifier).join(', ')}) + VALUES (${sessionValues.map((_, index) => `$${index + 1}`).join(', ')})`, + sessionValues + ); + + const credentialColumns = [ + 'id', + 'session_id', + 'kind', + 'secret_hash', + 'expires_at', + 'principal_id', + 'access_level' + ]; + const credentialValues: unknown[] = [ + REAL_RUNTIME_FIXTURE.serviceCredentialId, + REAL_RUNTIME_FIXTURE.serviceSessionId, + 'api_key', + createHash('sha256').update(REAL_RUNTIME_FIXTURE.serviceApiKey).digest(), + new Date(Date.now() + 60 * 60 * 1000), + REAL_RUNTIME_FIXTURE.servicePrincipalId, + 'full_access' + ]; + if (await hasColumn(ctx, sessionsSchema, credentialsTable, 'database_id')) { + credentialColumns.push('database_id'); + credentialValues.push(databaseId); + } + await ctx.pg.any( + `INSERT INTO ${relation(sessionsSchema, credentialsTable)} + (${credentialColumns.map(quoteIdentifier).join(', ')}) + VALUES (${credentialValues.map((_, index) => `$${index + 1}`).join(', ')})`, + credentialValues + ); + + await ctx.pg.any(` + CREATE TABLE public.oauth_sso_real_runtime_fixture ( + database_id uuid PRIMARY KEY, + private_schema text NOT NULL, + sessions_schema text NOT NULL, + sessions_table text NOT NULL, + credentials_table text NOT NULL, + site_api_id uuid NOT NULL + ) + `); + await ctx.pg.any( + `INSERT INTO public.oauth_sso_real_runtime_fixture + (database_id, private_schema, sessions_schema, sessions_table, + credentials_table, site_api_id) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + databaseId, + privateSchema, + sessionsSchema, + sessionsTable, + credentialsTable, + siteApi.id + ] + ); + } +}); diff --git a/graphql/server-test/__tests__/oauth-sso.integration.test.ts b/graphql/server-test/__tests__/oauth-sso.integration.test.ts index 0d3b2ff807..1cc72cf342 100644 --- a/graphql/server-test/__tests__/oauth-sso.integration.test.ts +++ b/graphql/server-test/__tests__/oauth-sso.integration.test.ts @@ -1,26 +1,22 @@ import { createHash } from 'node:crypto'; -import path from 'node:path'; import type { PgTestClient } from 'pgsql-test/test-client'; import type supertest from 'supertest'; -import { getConnections, seed } from '../src'; - -jest.setTimeout(60_000); - -const sharedSeedRoot = path.join(__dirname, '..', '..', '..', '__fixtures__', 'seed'); -const shared = (...segments: string[]) => path.join(sharedSeedRoot, ...segments); -const local = (...segments: string[]) => path.join( - __dirname, - '..', - '__fixtures__', - 'seed', - 'oauth-sso', - ...segments -); -const pgpmWorkspace = path.join(sharedSeedRoot, '..', '..'); -const siteId = 'f1000000-0000-0000-0000-000000000001'; -const authHost = 'app.test.constructive.io'; +import { + REAL_RUNTIME_FIXTURE, + seedRealUnifiedAuthRuntime +} from '../__fixtures__/seed/oauth-sso/real-runtime'; +import { + getConnections, + getConstructiveDbApplicationPath, + seed +} from '../src'; + +jest.setTimeout(600_000); + +const constructiveDbApplicationPath = getConstructiveDbApplicationPath(); +const describeRealRuntime = constructiveDbApplicationPath ? describe : describe.skip; const browserBinding = 'b'.repeat(43); const siteState = 's'.repeat(43); @@ -32,42 +28,75 @@ const metaSchemas = [ 'metaschema_modules_public' ]; -describe('OAuth/SSO real server integration seam', () => { +interface RuntimeMetadata { + database_id: string; + private_schema: string; + sessions_schema: string; + sessions_table: string; + credentials_table: string; + site_api_id: string; +} + +const quoteIdentifier = (value: string): string => + `"${value.replaceAll('"', '""')}"`; + +describeRealRuntime('OAuth/SSO generated Constructive DB integration', () => { let request: supertest.Agent; let pg: PgTestClient; let teardown: () => Promise; + let runtime: RuntimeMetadata; - const postGraphQL = (query: string, variables?: Record) => - request + const postGraphQL = ( + host: string, + query: string, + variables?: Record, + token?: string + ) => { + const pending = request .post('/graphql') - .set('Host', authHost) - .set('Cookie', `csrf_token=${browserBinding}`) - .send({ query, variables }); + .set('Host', host) + .set('X-Forwarded-Proto', 'https') + .set('Cookie', `csrf_token=${browserBinding}`); + if (token) pending.set('Authorization', `Bearer ${token}`); + return pending.send({ query, variables }); + }; - const startLogin = () => postGraphQL( + const startLogin = (token?: string) => postGraphQL( + REAL_RUNTIME_FIXTURE.authHost, `mutation Start($input: StartUnifiedLoginInput!) { startUnifiedLogin(input: $input) { transactionId + reusableAuthentication + currentAccount { id displayName } site { id displayName themeColor } providers { key } } }`, { input: { - siteId, + siteId: REAL_RUNTIME_FIXTURE.siteId, returnTo: '/approvals/42', siteState } - } + }, + token ); beforeAll(async () => { + if (!constructiveDbApplicationPath) { + throw new Error('The real Constructive DB application path is required.'); + } ({ request, pg, teardown } = await getConnections( { - schemas: ['simple-pets-public', 'simple-pets-pets-public'], + schemas: ['constructive_public'], authRole: 'anonymous', server: { useRouting: true, + trustProxy: true, + oauth: { + enabled: true, + providerRequestTimeoutMs: 2_000 + }, api: { isPublic: true, metaSchemas @@ -75,115 +104,217 @@ describe('OAuth/SSO real server integration seam', () => { } }, [ - 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'), - local('contract.sql') - ]) + seed.pgpm(constructiveDbApplicationPath), + seedRealUnifiedAuthRuntime() ] )); + runtime = await pg.one( + 'SELECT * FROM public.oauth_sso_real_runtime_fixture' + ); }); afterAll(async () => teardown()); - it('starts only through the canonical routed Tenant host', async () => { - const response = await startLogin(); - - expect(response.status).toBe(200); - expect(response.body.errors).toBeUndefined(); - expect(response.body.data.startUnifiedLogin).toMatchObject({ - transactionId: 't'.repeat(43), - site: { - id: siteId, - displayName: 'Customer Portal', - themeColor: '#112233' - }, - providers: [] - }); - - const [stored] = await pg.any<{ - browser_binding: string; - return_to: string; - start_api_id: string; + it('routes the auth center without Site identity and the Site with trusted runtime_site_id', async () => { + const rows = await pg.any<{ + hostname: string; + runtime_site_id: string | null; }>( - `SELECT browser_binding, return_to, start_api_id - FROM tenant_test_sso_private.test_login_transactions` + `SELECT $1::text AS hostname, runtime_site_id + FROM routing_public.resolve_route($1, '/', NULL) + UNION ALL + SELECT $2::text AS hostname, runtime_site_id + FROM routing_public.resolve_route($2, '/', NULL)`, + [REAL_RUNTIME_FIXTURE.authHost, REAL_RUNTIME_FIXTURE.siteHost] ); - expect(stored).toEqual({ - browser_binding: browserBinding, - return_to: '/approvals/42', - start_api_id: '6c9997a4-591b-4cb3-9313-4ef45d6f134e' - }); + expect(rows).toEqual([ + { hostname: REAL_RUNTIME_FIXTURE.authHost, runtime_site_id: null }, + { + hostname: REAL_RUNTIME_FIXTURE.siteHost, + runtime_site_id: REAL_RUNTIME_FIXTURE.siteId + } + ]); - const unknownHost = await request - .post('/graphql') - .set('Host', 'unknown.example.test') - .set('Cookie', `csrf_token=${browserBinding}`) - .send({ - query: `mutation Start($input: StartUnifiedLoginInput!) { - startUnifiedLogin(input: $input) { transactionId } - }`, - variables: { input: { siteId, siteState } } - }); + const unknownHost = await postGraphQL( + 'unknown.example.test', + `mutation Start($input: StartUnifiedLoginInput!) { + startUnifiedLogin(input: $input) { transactionId } + }`, + { input: { siteId: REAL_RUNTIME_FIXTURE.siteId, siteState } } + ); expect(unknownHost.status).toBe(404); }); - it('converges local sign-in on a hashed handoff and host-only auth cookie', async () => { + it('runs signup, reusable auth, handoff redemption, replay protection, and revocation end to end', async () => { const startResponse = await startLogin(); expect(startResponse.status).toBe(200); expect(startResponse.body.errors).toBeUndefined(); + const transactionId = startResponse.body.data.startUnifiedLogin.transactionId as string; + expect(transactionId).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(startResponse.body.data.startUnifiedLogin).toMatchObject({ + reusableAuthentication: false, + currentAccount: null, + site: { + id: REAL_RUNTIME_FIXTURE.siteId, + displayName: 'Customer Portal' + }, + providers: [{ key: 'github' }] + }); - const response = await postGraphQL( - `mutation SignIn($input: UnifiedPasswordInput!) { - signInUnifiedLogin(input: $input) { + const transactionRows = await pg.any<{ + token_hash: Buffer; + return_to: string; + }>( + `SELECT token_hash, return_to + FROM ${quoteIdentifier(runtime.private_schema)}.unified_login_transactions` + ); + expect(transactionRows).toHaveLength(1); + expect(transactionRows[0].token_hash.toString('hex')).toBe( + createHash('sha256').update(transactionId).digest('hex') + ); + expect(transactionRows[0].return_to).toBe('/approvals/42'); + + const signup = await postGraphQL( + REAL_RUNTIME_FIXTURE.authHost, + `mutation SignUp($input: UnifiedPasswordInput!) { + signUpUnifiedLogin(input: $input) { + credentialId + userId accessToken continuationUrl } }`, { input: { - transactionId: 't'.repeat(43), - email: 'user@example.com', - password: 'correct horse battery staple' + transactionId, + email: 'unified-user@example.com', + password: 'Str0ngP@ssword!' } } ); + expect(signup.status).toBe(200); + expect(signup.body.errors).toBeUndefined(); + const central = signup.body.data.signUpUnifiedLogin as { + credentialId: string; + userId: string; + accessToken: string; + continuationUrl: string; + }; + expect(central.accessToken).toMatch(/^cnc_live_bt_/); - expect(response.status).toBe(200); - expect(response.body.errors).toBeUndefined(); - expect(response.headers['cache-control']).toBe('no-store'); - const setCookie = response.headers['set-cookie']; - const cookies = Array.isArray(setCookie) ? setCookie : [setCookie]; - const cookie = cookies - .find(value => value.startsWith('constructive_session=')); - expect(cookie).toContain('Secure'); - expect(cookie).toContain('HttpOnly'); - expect(cookie).not.toContain('Domain='); - - const continuation = new URL( - response.body.data.signInUnifiedLogin.continuationUrl - ); + const centralCookies = (signup.headers['set-cookie'] ?? []) as string[]; + expect(centralCookies).toEqual(expect.arrayContaining([ + expect.stringContaining('constructive_session=') + ])); + const centralCookie = centralCookies.find(value => + value.startsWith('constructive_session=') + ) as string; + expect(centralCookie).toContain('Secure'); + expect(centralCookie).toContain('HttpOnly'); + expect(centralCookie).not.toContain('Domain='); + + const continuation = new URL(central.continuationUrl); const handoff = continuation.searchParams.get('handoff'); - expect(continuation.origin).toBe('https://site-one.example'); + expect(continuation.origin).toBe(`https://${REAL_RUNTIME_FIXTURE.siteHost}`); expect(continuation.pathname).toBe('/auth/complete'); - expect(continuation.searchParams.get('locale')).toBe('en'); expect(continuation.searchParams.get('site_state')).toBe(siteState); expect(handoff).toMatch(/^[A-Za-z0-9_-]{43}$/); - expect(continuation.toString()).not.toContain('cnc_live_bt_auth_center_fixture'); + expect(continuation.toString()).not.toContain(central.accessToken); - const [stored] = await pg.any<{ code_hash_hex: string }>( - `SELECT encode(code_hash, 'hex') AS code_hash_hex - FROM tenant_test_sso_private.test_handoffs` + const storedHandoff = await pg.one<{ code_hash: Buffer }>( + `SELECT code_hash + FROM ${quoteIdentifier(runtime.private_schema)}.sso_handoffs` ); - expect(stored.code_hash_hex).toBe( + expect(storedHandoff.code_hash.toString('hex')).toBe( createHash('sha256').update(handoff as string).digest('hex') ); + + const reusable = await startLogin(central.accessToken); + expect(reusable.body.errors).toBeUndefined(); + expect(reusable.body.data.startUnifiedLogin).toMatchObject({ + reusableAuthentication: true, + currentAccount: { id: central.userId } + }); + + const redeem = await postGraphQL( + REAL_RUNTIME_FIXTURE.siteHost, + `mutation Redeem($input: RedeemUnifiedLoginHandoffInput!) { + redeemUnifiedLoginHandoff(input: $input) { + credentialId + userId + accessToken + returnTo + } + }`, + { input: { handoffCode: handoff } }, + REAL_RUNTIME_FIXTURE.serviceApiKey + ); + expect(redeem.body.errors).toBeUndefined(); + const siteCredential = redeem.body.data.redeemUnifiedLoginHandoff as { + credentialId: string; + userId: string; + accessToken: string; + returnTo: string; + }; + expect(siteCredential).toMatchObject({ + userId: central.userId, + returnTo: '/approvals/42' + }); + expect(siteCredential.accessToken).toMatch(/^cnc_live_bt_/); + expect(siteCredential.accessToken).not.toBe(central.accessToken); + // Constructive returns a distinct Site credential to the authenticated Site + // server; only that Site's own callback response may write its first-party + // cookie on the Site domain. + expect(redeem.headers['set-cookie']).toBeUndefined(); + + const replay = await postGraphQL( + REAL_RUNTIME_FIXTURE.siteHost, + `mutation Redeem($input: RedeemUnifiedLoginHandoffInput!) { + redeemUnifiedLoginHandoff(input: $input) { accessToken } + }`, + { input: { handoffCode: handoff } }, + REAL_RUNTIME_FIXTURE.serviceApiKey + ); + expect(replay.body.data).toBeNull(); + expect(replay.body.errors[0].extensions.code).toBe('SSO_HANDOFF_ALREADY_USED'); + + const protectedBeforeRevocation = await postGraphQL( + REAL_RUNTIME_FIXTURE.siteHost, + 'query SiteSession { __typename }', + undefined, + siteCredential.accessToken + ); + expect(protectedBeforeRevocation.body).toEqual({ + data: { __typename: 'Query' } + }); + + const centralSession = await pg.one<{ session_id: string }>( + `SELECT session_id + FROM ${quoteIdentifier(runtime.sessions_schema)}.${quoteIdentifier(runtime.credentials_table)} + WHERE id = $1`, + [central.credentialId] + ); + await pg.any( + `UPDATE ${quoteIdentifier(runtime.sessions_schema)}.${quoteIdentifier(runtime.sessions_table)} + SET revoked_at = clock_timestamp() + WHERE id = $1`, + [centralSession.session_id] + ); + + const protectedAfterRevocation = await postGraphQL( + REAL_RUNTIME_FIXTURE.siteHost, + 'query RevokedSiteSession { __typename }', + undefined, + siteCredential.accessToken + ); + expect(protectedAfterRevocation.status).toBe(200); + expect(protectedAfterRevocation.body.data).toBeUndefined(); + expect(protectedAfterRevocation.body.errors[0].extensions.code).toBe('INVALID_TOKEN'); }); - it('does not allow possession-only redemption from a browser request', async () => { + it('does not allow possession-only redemption from an auth-center browser request', async () => { const response = await postGraphQL( + REAL_RUNTIME_FIXTURE.authHost, `mutation Redeem($input: RedeemUnifiedLoginHandoffInput!) { redeemUnifiedLoginHandoff(input: $input) { accessToken returnTo } }`, @@ -193,27 +324,104 @@ describe('OAuth/SSO real server integration seam', () => { expect(response.status).toBe(200); expect(response.body.data).toBeNull(); expect(response.body.errors[0].extensions.code).toBe('UNAUTHENTICATED'); - expect(response.headers['set-cookie'] ?? []).not.toEqual( - expect.arrayContaining([expect.stringContaining('constructive_session=')]) - ); }); - it('keeps OAuth disabled behavior stable without Provider resolution', async () => { - const response = await postGraphQL( + it('runs the GitHub Provider boundary through real DB state and the shared handoff', async () => { + const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation( + async input => { + const url = String(input); + if (url === 'https://github.com/login/oauth/access_token') { + return new Response(JSON.stringify({ access_token: 'github-token' }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + if (url === 'https://api.github.com/user') { + return new Response(JSON.stringify({ + id: 424242, + login: 'unified-provider-user', + name: 'Unified Provider User', + email: 'provider-user@example.com' + }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + throw new Error(`Unexpected Provider request: ${url}`); + } + ); + + const startResponse = await startLogin(); + const transactionId = startResponse.body.data.startUnifiedLogin.transactionId; + const providerStart = await postGraphQL( + REAL_RUNTIME_FIXTURE.authHost, `mutation Provider($input: StartProviderAuthenticationInput!) { startProviderAuthentication(input: $input) { authorizationUrl } }`, - { - input: { - transactionId: 't'.repeat(43), - providerKey: 'google' - } - } + { input: { transactionId, providerKey: 'github' } } ); + expect(providerStart.body.errors).toBeUndefined(); + const authorizationEntry = providerStart.body.data + .startProviderAuthentication.authorizationUrl as string; + expect(authorizationEntry).toMatch(/^\/auth\/oauth\/authorize\?state=/); + expect(authorizationEntry).not.toContain(transactionId); - expect(response.status).toBe(200); - expect(response.body.data).toBeNull(); - expect(response.body.errors[0].extensions.code).toBe('OAUTH_SIGN_IN_DISABLED'); - expect(response.headers['cache-control']).toBe('no-store'); + const authorize = await request + .get(authorizationEntry) + .set('Host', REAL_RUNTIME_FIXTURE.authHost) + .set('X-Forwarded-Proto', 'https') + .set('Cookie', `csrf_token=${browserBinding}`); + expect(authorize.status).toBe(303); + const providerAuthorization = new URL(authorize.headers.location); + expect(providerAuthorization.origin).toBe('https://github.com'); + expect(providerAuthorization.pathname).toBe('/login/oauth/authorize'); + expect(providerAuthorization.searchParams.get('code_challenge_method')).toBe('S256'); + expect(providerAuthorization.searchParams.get('code_challenge')).toMatch( + /^[A-Za-z0-9_-]{43}$/ + ); + const oauthState = providerAuthorization.searchParams.get('state'); + expect(oauthState).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(providerAuthorization.toString()).not.toContain(transactionId); + + const callback = await request + .get(`/auth/oauth/callback?state=${encodeURIComponent(oauthState as string)}&code=provider-code`) + .set('Host', REAL_RUNTIME_FIXTURE.authHost) + .set('X-Forwarded-Proto', 'https') + .set('Cookie', `csrf_token=${browserBinding}`); + expect(callback.status).toBe(303); + const callbackCookies = (callback.headers['set-cookie'] ?? []) as string[]; + expect(callbackCookies).toEqual(expect.arrayContaining([ + expect.stringContaining('constructive_session=') + ])); + const centralProviderToken = decodeURIComponent( + callbackCookies + .find(value => value.startsWith('constructive_session='))! + .split(';')[0] + .split('=')[1] + ); + expect(centralProviderToken).toMatch(/^cnc_live_bt_/); + + const continuation = new URL(callback.headers.location); + const handoffCode = continuation.searchParams.get('handoff'); + expect(continuation.origin).toBe(`https://${REAL_RUNTIME_FIXTURE.siteHost}`); + expect(handoffCode).toMatch(/^[A-Za-z0-9_-]{43}$/); + const redeem = await postGraphQL( + REAL_RUNTIME_FIXTURE.siteHost, + `mutation Redeem($input: RedeemUnifiedLoginHandoffInput!) { + redeemUnifiedLoginHandoff(input: $input) { userId accessToken returnTo } + }`, + { input: { handoffCode } }, + REAL_RUNTIME_FIXTURE.serviceApiKey + ); + expect(redeem.body.errors).toBeUndefined(); + expect(redeem.body.data.redeemUnifiedLoginHandoff).toMatchObject({ + returnTo: '/approvals/42', + accessToken: expect.stringMatching(/^cnc_live_bt_/) + }); + expect(redeem.body.data.redeemUnifiedLoginHandoff.accessToken) + .not.toBe(centralProviderToken); + expect(fetchMock).toHaveBeenCalledTimes(2); + + fetchMock.mockRestore(); }); }); diff --git a/graphql/server-test/package.json b/graphql/server-test/package.json index 84c3c4f3e3..c69bdb266e 100644 --- a/graphql/server-test/package.json +++ b/graphql/server-test/package.json @@ -29,6 +29,7 @@ "test:watch": "jest --watch" }, "devDependencies": { + "12factor-env": "workspace:^", "@0no-co/graphql.web": "^1.3.3", "@agentic-kit/ollama": "workspace:*", "@constructive-io/graphql-codegen": "workspace:^", diff --git a/graphql/server-test/src/constructive-db-runtime.ts b/graphql/server-test/src/constructive-db-runtime.ts new file mode 100644 index 0000000000..1fa4f71116 --- /dev/null +++ b/graphql/server-test/src/constructive-db-runtime.ts @@ -0,0 +1,27 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +import { cleanEnv, str, withDefault } from '12factor-env'; + +const runtimeEnv = (): { applicationPath: string } => { + const parsed = cleanEnv(process.env, { + CONSTRUCTIVE_DB_APPLICATION_PATH: withDefault(str, '') + }); + return { applicationPath: parsed.CONSTRUCTIVE_DB_APPLICATION_PATH.trim() }; +}; + +/** + * Resolve an explicitly pinned generated Constructive DB application checkout. + * Empty means the cross-repository suite is not part of the current test run. + */ +export const getConstructiveDbApplicationPath = (): string | null => { + const configured = runtimeEnv().applicationPath; + if (!configured) return null; + const resolved = path.resolve(configured); + if (!existsSync(path.join(resolved, 'pgpm.plan'))) { + throw new Error( + `CONSTRUCTIVE_DB_APPLICATION_PATH does not contain a generated pgpm application: ${resolved}` + ); + } + return resolved; +}; diff --git a/graphql/server-test/src/index.ts b/graphql/server-test/src/index.ts index 5b4b7e7f82..1b53ec211c 100644 --- a/graphql/server-test/src/index.ts +++ b/graphql/server-test/src/index.ts @@ -1,3 +1,5 @@ +export { getConstructiveDbApplicationPath } from './constructive-db-runtime'; + // Export types export * from './types'; diff --git a/graphql/server-test/src/server.ts b/graphql/server-test/src/server.ts index c8fbfc1e2d..04fc181dc6 100644 --- a/graphql/server-test/src/server.ts +++ b/graphql/server-test/src/server.ts @@ -48,7 +48,10 @@ export const createTestServer = async ( server: { ...opts.server, host, - port + port, + ...(serverOpts.trustProxy !== undefined && { + trustProxy: serverOpts.trustProxy + }) } }; diff --git a/graphql/server-test/src/types.ts b/graphql/server-test/src/types.ts index 7736a97216..220992f10d 100644 --- a/graphql/server-test/src/types.ts +++ b/graphql/server-test/src/types.ts @@ -16,6 +16,8 @@ export interface ServerOptions { port?: number; /** Host to bind the server to (defaults to localhost) */ host?: string; + /** Trust the forwarded protocol when a test exercises an HTTPS callback. */ + trustProxy?: boolean; /** * Which server to run this suite against: * - `true` (default): the production `@constructive-io/graphql-server`, which diff --git a/graphql/server/src/auth/oauth/__tests__/router.test.ts b/graphql/server/src/auth/oauth/__tests__/router.test.ts index 8b6130fb82..9ed3bf8af6 100644 --- a/graphql/server/src/auth/oauth/__tests__/router.test.ts +++ b/graphql/server/src/auth/oauth/__tests__/router.test.ts @@ -26,6 +26,7 @@ const makeApp = () => { app.use((req, _res, next) => { req.constructive = context; req.cookies = { csrf_token: 'b'.repeat(64) }; + req.deviceToken = 'device-token'; req.api = { dbname: 'tenant', anonRole: 'anonymous', @@ -87,6 +88,11 @@ describe('OAuth HTTP routes', () => { 'https://portal.example.com/auth/complete?handoff=handoff-code&site_state=site-state' ); expect(response.text).not.toContain('cnc_auth_center_token'); + expect(mockedComplete).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ deviceToken: 'device-token' }) + ); }); it('returns only a stable safe cancellation classification', async () => { diff --git a/graphql/server/src/auth/oauth/__tests__/service.test.ts b/graphql/server/src/auth/oauth/__tests__/service.test.ts index 180da2f95d..203709c814 100644 --- a/graphql/server/src/auth/oauth/__tests__/service.test.ts +++ b/graphql/server/src/auth/oauth/__tests__/service.test.ts @@ -133,6 +133,7 @@ describe('Provider OAuth orchestration', () => { code: 'provider-authorization-code', providerReturnedError: false, browserBinding, + deviceToken: null, requestTimeoutMs: 1000, fetch: providerFetch as typeof fetch }); @@ -155,7 +156,8 @@ describe('Provider OAuth orchestration', () => { }), 'bearer', false, - browserBinding, + null, + expect.stringMatching(/^\\x[0-9a-f]{64}$/), expect.stringMatching(/^\\x[0-9a-f]{64}$/) ]); }); @@ -173,6 +175,7 @@ describe('Provider OAuth orchestration', () => { state: opaqueState, providerReturnedError: true, browserBinding, + deviceToken: null, requestTimeoutMs: 1000 })).rejects.toMatchObject({ code: 'OAUTH_AUTHORIZATION_CANCELLED' }); expect(query).toHaveBeenCalledTimes(1); diff --git a/graphql/server/src/auth/oauth/router.ts b/graphql/server/src/auth/oauth/router.ts index ae07f38b6f..bab2a4da3c 100644 --- a/graphql/server/src/auth/oauth/router.ts +++ b/graphql/server/src/auth/oauth/router.ts @@ -111,6 +111,7 @@ export const createOAuthRouter = (options: OAuthRouterOptions): Router => { code, providerReturnedError, browserBinding, + deviceToken: req.deviceToken ?? null, requestTimeoutMs: options.requestTimeoutMs }); diff --git a/graphql/server/src/auth/oauth/service.ts b/graphql/server/src/auth/oauth/service.ts index 77b276ec13..fe327e6778 100644 --- a/graphql/server/src/auth/oauth/service.ts +++ b/graphql/server/src/auth/oauth/service.ts @@ -81,6 +81,7 @@ export const completeProviderAuthentication = async ( code?: string; providerReturnedError: boolean; browserBinding: string; + deviceToken: string | null; requestTimeoutMs: number; fetch?: typeof fetch; } @@ -125,6 +126,7 @@ export const completeProviderAuthentication = async ( requestId: request.requestId, identity, browserBinding: input.browserBinding, + deviceToken: input.deviceToken, handoff: createHandoffMaterial() }); }; diff --git a/graphql/server/src/auth/sso/__tests__/service.test.ts b/graphql/server/src/auth/sso/__tests__/service.test.ts index d335092dbc..d171a84774 100644 --- a/graphql/server/src/auth/sso/__tests__/service.test.ts +++ b/graphql/server/src/auth/sso/__tests__/service.test.ts @@ -51,6 +51,9 @@ const makeContext = ( api: { apiId: options.runtime ? '00000000-0000-0000-0000-000000000020' + : undefined, + siteId: options.runtime + ? '00000000-0000-0000-0000-000000000024' : undefined }, token: options.runtime @@ -63,6 +66,9 @@ const makeContext = ( } : null, requestOrigin: 'https://auth.example.com', + siteId: options.runtime + ? '00000000-0000-0000-0000-000000000024' + : null, userId: options.userId ?? null, useModule: jest.fn(async (name: string) => { if (name === 'ssoSurface') return surface; @@ -105,7 +111,6 @@ describe('unified authentication GraphQL service', () => { it('starts through the current Tenant SSO function and merges Provider options', async () => { const { context, query } = makeContext({ - transaction_id: opaque, site_id: '00000000-0000-0000-0000-000000000001', site_display_name: 'Customer Portal', site_icon_url: null, @@ -128,16 +133,18 @@ describe('unified authentication GraphQL service', () => { expect(result.providers).toEqual([ { key: 'google-workspace', displayName: 'Google Workspace' } ]); + expect(result.transactionId).toMatch(/^[A-Za-z0-9_-]{43}$/); expect(result.site.displayName).toBe('Customer Portal'); expect(query.mock.calls[0][0]).toContain( '"tenant_acme_sso_private"."start_unified_login"' ); expect(query.mock.calls[0][1]).toEqual([ + expect.stringMatching(/^\\x[0-9a-f]{64}$/), '00000000-0000-0000-0000-000000000001', null, '/approvals/42', opaque, - opaque + expect.stringMatching(/^\\x[0-9a-f]{64}$/) ]); }); @@ -175,12 +182,13 @@ describe('unified authentication GraphQL service', () => { '"tenant_acme_sso_private"."sign_in_unified_login"' ); expect(query.mock.calls[0][1]).toEqual([ - opaque, + expect.stringMatching(/^\\x[0-9a-f]{64}$/), 'user@example.com', 'correct horse battery staple', true, 'bearer', - opaque, + expect.stringMatching(/^\\x[0-9a-f]{64}$/), + null, null, expect.stringMatching(/^\\x[0-9a-f]{64}$/) ]); @@ -207,8 +215,8 @@ describe('unified authentication GraphQL service', () => { '"tenant_acme_sso_private"."confirm_unified_login"' ); expect(query.mock.calls[0][1]).toEqual([ - opaque, - opaque, + expect.stringMatching(/^\\x[0-9a-f]{64}$/), + expect.stringMatching(/^\\x[0-9a-f]{64}$/), expect.stringMatching(/^\\x[0-9a-f]{64}$/) ]); }); @@ -306,13 +314,13 @@ describe('unified authentication GraphQL service', () => { '"tenant_acme_sso_private"."start_provider_oauth_request"' ); expect(query.mock.calls[0][1]).toEqual([ - opaque, + expect.stringMatching(/^\\x[0-9a-f]{64}$/), googleProvider.slug, expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), 'https://auth.example.com/auth/oauth/callback', - opaque + expect.stringMatching(/^\\x[0-9a-f]{64}$/) ]); }); diff --git a/graphql/server/src/auth/sso/__tests__/site-session.test.ts b/graphql/server/src/auth/sso/__tests__/site-session.test.ts new file mode 100644 index 0000000000..883a330fea --- /dev/null +++ b/graphql/server/src/auth/sso/__tests__/site-session.test.ts @@ -0,0 +1,108 @@ +import type { + ConstructiveContext, + SsoSurface +} from '@constructive-io/express-context'; +import type { NextFunction, Request, Response } from 'express'; +import type { PoolClient, QueryResult } from 'pg'; + +import { createSiteSessionValidationMiddleware } from '../site-session'; + +const surface: SsoSurface = { privateSchema: 'tenant_acme_sso_private' }; + +const makeBoundary = ( + options: { + siteId?: string | null; + tokenKind?: string; + result?: unknown; + error?: Error; + ssoEnabled?: boolean; + } = {} +) => { + const query = jest.fn(async () => { + if (options.error) throw options.error; + return { + rows: [{ result: options.result ?? { valid: true } }] + } as unknown as QueryResult; + }); + const client = { query } as unknown as PoolClient; + const context = { + siteId: options.siteId === undefined ? 'site-1' : options.siteId, + token: { + id: 'credential-1', + user_id: 'user-1', + session_id: 'session-1', + kind: options.tokenKind ?? 'bearer' + }, + useModule: jest.fn(async (name: string) => + name === 'ssoSurface' && options.ssoEnabled !== false + ? surface + : undefined + ), + withPgClient: jest.fn(async (callback: (pg: PoolClient) => Promise) => + callback(client) + ) + } as unknown as ConstructiveContext; + const req = { + constructive: context, + path: '/graphql', + originalUrl: '/graphql' + } as Request; + const responseBody: { value?: unknown } = {}; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn((value: unknown) => { + responseBody.value = value; + return res; + }) + } as unknown as Response; + const next = jest.fn() as NextFunction; + return { query, req, res, next, responseBody }; +}; + +describe('Site session validation middleware', () => { + it('validates a Site-local session through the current Tenant SSO surface', async () => { + const { query, req, res, next } = makeBoundary(); + + await createSiteSessionValidationMiddleware()(req, res, next); + + expect(query).toHaveBeenCalledWith( + expect.stringContaining('"tenant_acme_sso_private"."validate_site_session"'), + [] + ); + expect(next).toHaveBeenCalledWith(); + }); + + it('does not treat a Site runtime API key as a Site-local browser session', async () => { + const { query, req, res, next } = makeBoundary({ tokenKind: 'api_key' }); + + await createSiteSessionValidationMiddleware()(req, res, next); + + expect(query).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + }); + + it('does not infer a Site when routing did not provide one', async () => { + const { query, req, res, next } = makeBoundary({ siteId: null }); + + await createSiteSessionValidationMiddleware()(req, res, next); + + expect(query).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + }); + + it('returns the stable DB authentication error after unified-session revocation', async () => { + const databaseError = Object.assign(new Error('INVALID_TOKEN'), { + code: 'P0001', + detail: JSON.stringify({ code: 'INVALID_TOKEN', context: {}, class: 'public' }) + }); + const { req, res, next, responseBody } = makeBoundary({ error: databaseError }); + + await createSiteSessionValidationMiddleware()(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(responseBody.value).toMatchObject({ + errors: [{ extensions: { code: 'INVALID_TOKEN' } }] + }); + }); +}); diff --git a/graphql/server/src/auth/sso/db-contract.ts b/graphql/server/src/auth/sso/db-contract.ts index ba8e66b5ab..3d69f89967 100644 --- a/graphql/server/src/auth/sso/db-contract.ts +++ b/graphql/server/src/auth/sso/db-contract.ts @@ -6,6 +6,7 @@ import { buildHandoffContinuationUrl, type HandoffMaterial } from './handoff'; +import { createOpaqueMaterial, hashOpaqueValue } from './opaque'; import type { ContinueUnifiedLoginInput, StartUnifiedLoginInput, @@ -26,19 +27,21 @@ import type { * * Exact v1 signatures fixed by this integration: * - * - `start_unified_login(uuid, text, text, text, text)` returns - * `transaction_id`, safe Site display fields, `sign_in_mode`, + * - `start_unified_login(bytea, uuid, text, text, text, bytea)` accepts a + * server-generated transaction digest and returns safe Site display fields, + * `sign_in_mode`, * `reusable_authentication`, and optional safe current-user display fields. - * - `confirm_unified_login(text, text, bytea)` returns the associated `user_id` + * - `confirm_unified_login(bytea, bytea, bytea)` returns the associated `user_id` * and the transaction-bound Site callback continuation fields. - * - `sign_in_unified_login(text, text, text, boolean, text, text, text, bytea)` - * and `sign_up_unified_login(...)` return the unchanged local credential - * columns and the same continuation fields. + * - `sign_in_unified_login(bytea, text, text, boolean, text, bytea, text, + * text, bytea)` and `sign_up_unified_login(...)` return the unchanged local + * credential columns and the same continuation fields. * - * The final `text` arguments are the server-read authentication-center browser - * binding and device-token values. The transaction identifier is an opaque - * token whose digest is stored by DB; it is deliberately not modelled as a row - * UUID. + * Browser-held transaction and binding values are always digested before they + * cross the DB boundary. The SSO browser binding is not an anonymous-session + * CSRF secret, so the unchanged local credential primitive receives no CSRF + * value unless a future flow establishes such a session explicitly. The + * transaction identifier is deliberately not modelled as a row UUID. */ export const SSO_DB_FUNCTIONS = { start: 'start_unified_login', @@ -175,18 +178,20 @@ export const startUnifiedLogin = async ( browserBinding: string ): Promise => { const operation = SSO_DB_FUNCTIONS.start; + const transaction = createOpaqueMaterial(); const row = await callFunction( context, surface, operation, [ + sql.value(transaction.hash), sql.value(input.siteId), sql.value(input.callbackUrl ?? null), sql.value(input.returnTo ?? '/'), sql.value(input.siteState), - sql.value(browserBinding) + sql.value(hashOpaqueValue(browserBinding)) ], - ['uuid', 'text', 'text', 'text', 'text'] + ['bytea', 'uuid', 'text', 'text', 'text', 'bytea'] ); const signInMode = requiredString(row, 'sign_in_mode', operation); if (signInMode !== 'confirm' && signInMode !== 'silent') { @@ -203,7 +208,7 @@ export const startUnifiedLogin = async ( : null; return { - transactionId: requiredString(row, 'transaction_id', operation), + transactionId: transaction.value, site: { id: requiredString(row, 'site_id', operation), displayName: requiredString(row, 'site_display_name', operation), @@ -233,11 +238,11 @@ export const confirmUnifiedLogin = async ( surface, operation, [ - sql.value(input.transactionId), - sql.value(browserBinding), + sql.value(hashOpaqueValue(input.transactionId)), + sql.value(hashOpaqueValue(browserBinding)), sql.value(handoff.hash) ], - ['text', 'text', 'bytea'] + ['bytea', 'bytea', 'bytea'] ); requiredString(row, 'user_id', operation); return { @@ -260,16 +265,27 @@ const authenticateWithPassword = async ( surface, functionName, [ - sql.value(input.transactionId), + sql.value(hashOpaqueValue(input.transactionId)), sql.value(input.email), sql.value(input.password), sql.value(input.rememberMe ?? false), sql.value('bearer'), - sql.value(browserBinding), + sql.value(hashOpaqueValue(browserBinding)), + sql.value(null), sql.value(input.deviceToken ?? null), sql.value(handoff.hash) ], - ['text', 'text', 'text', 'boolean', 'text', 'text', 'text', 'bytea'] + [ + 'bytea', + 'text', + 'text', + 'boolean', + 'text', + 'bytea', + 'text', + 'text', + 'bytea' + ] ); // Strict-auth/MFA/step-up integration is explicitly outside v1. The DB diff --git a/graphql/server/src/auth/sso/handoff.ts b/graphql/server/src/auth/sso/handoff.ts index 21224d359a..a8eec1bbee 100644 --- a/graphql/server/src/auth/sso/handoff.ts +++ b/graphql/server/src/auth/sso/handoff.ts @@ -1,8 +1,7 @@ -import { createHash, randomBytes } from 'node:crypto'; - import { errors } from '@constructive-io/errors'; -const HANDOFF_BYTES = 32; +import { createOpaqueMaterial, hashOpaqueValue } from './opaque'; + const HANDOFF_CODE = /^[A-Za-z0-9_-]{43}$/; const SITE_STATE = /^[A-Za-z0-9_-]{32,128}$/; @@ -13,13 +12,13 @@ export interface HandoffMaterial { } export const createHandoffMaterial = (): HandoffMaterial => { - const code = randomBytes(HANDOFF_BYTES).toString('base64url'); - return { code, hash: hashHandoffCode(code) }; + const material = createOpaqueMaterial(); + return { code: material.value, hash: material.hash }; }; export const hashHandoffCode = (code: string): string => { if (!HANDOFF_CODE.test(code)) throw errors.INVALID_SSO_HANDOFF(); - return `\\x${createHash('sha256').update(code, 'utf8').digest('hex')}`; + return hashOpaqueValue(code); }; /** diff --git a/graphql/server/src/auth/sso/opaque.ts b/graphql/server/src/auth/sso/opaque.ts new file mode 100644 index 0000000000..c7edead372 --- /dev/null +++ b/graphql/server/src/auth/sso/opaque.ts @@ -0,0 +1,13 @@ +import { createHash, randomBytes } from 'node:crypto'; + +const OPAQUE_BYTES = 32; + +/** Create a high-entropy browser value while persisting only its digest. */ +export const createOpaqueMaterial = (): { value: string; hash: string } => { + const value = randomBytes(OPAQUE_BYTES).toString('base64url'); + return { value, hash: hashOpaqueValue(value) }; +}; + +/** PostgreSQL bytea hex input for an opaque browser-held value. */ +export const hashOpaqueValue = (value: string): string => + `\\x${createHash('sha256').update(value, 'utf8').digest('hex')}`; diff --git a/graphql/server/src/auth/sso/provider-db-contract.ts b/graphql/server/src/auth/sso/provider-db-contract.ts index 5e57c1b414..0e22474148 100644 --- a/graphql/server/src/auth/sso/provider-db-contract.ts +++ b/graphql/server/src/auth/sso/provider-db-contract.ts @@ -14,6 +14,7 @@ import { requiredString } from './db-contract'; import type { HandoffMaterial } from './handoff'; +import { hashOpaqueValue } from './opaque'; export const PROVIDER_DB_FUNCTIONS = { start: 'start_provider_oauth_request', @@ -25,15 +26,15 @@ export const PROVIDER_DB_FUNCTIONS = { /** * Fixed Constructive/DB signatures for the Provider subflow: * - * - `start_provider_oauth_request(text, text, text, text, text, text, text)` + * - `start_provider_oauth_request(bytea, text, text, text, text, text, bytea)` * accepts unified transaction token, Provider key, state, verifier, nonce, * redirect URI, and browser binding; returns `oauth_request_id`. - * - `read_provider_oauth_request(text, text)` and - * `consume_provider_oauth_request(text, text)` accept state plus browser + * - `read_provider_oauth_request(text, bytea)` and + * `consume_provider_oauth_request(text, bytea)` accept state plus browser * binding and return the request fields parsed below. Consume atomically * marks the state used before Provider callback handling. * - `complete_provider_unified_login(uuid, text, text, text, jsonb, text, - * boolean, text, bytea)` accepts request ID plus normalized identity, + * boolean, text, bytea, bytea)` accepts request ID plus normalized identity, * existing credential options, browser binding, and the server-generated * handoff digest; it returns the unchanged identity-auth credential result * and transaction-bound callback continuation. @@ -85,15 +86,15 @@ export const startProviderOAuthRequest = async ( surface, operation, [ - sql.value(input.transactionId), + sql.value(hashOpaqueValue(input.transactionId)), sql.value(input.providerKey), sql.value(input.state), sql.value(input.codeVerifier), sql.value(input.nonce), sql.value(input.redirectUri), - sql.value(input.browserBinding) + sql.value(hashOpaqueValue(input.browserBinding)) ], - ['text', 'text', 'text', 'text', 'text', 'text', 'text'] + ['bytea', 'text', 'text', 'text', 'text', 'text', 'bytea'] ); requiredString(row, 'oauth_request_id', operation); }; @@ -111,8 +112,8 @@ const restoreProviderOAuthRequest = async ( context, surface, functionName, - [sql.value(state), sql.value(browserBinding)], - ['text', 'text'] + [sql.value(state), sql.value(hashOpaqueValue(browserBinding))], + ['text', 'bytea'] ); return { requestId: requiredString(row, 'oauth_request_id', functionName), @@ -164,6 +165,7 @@ export const completeProviderUnifiedLogin = async ( requestId: string; identity: NormalizedExternalIdentity; browserBinding: string; + deviceToken: string | null; handoff: HandoffMaterial; } ): Promise => { @@ -180,10 +182,22 @@ export const completeProviderUnifiedLogin = async ( sql.value(JSON.stringify(input.identity.profile)), sql.value('bearer'), sql.value(false), - sql.value(input.browserBinding), + sql.value(input.deviceToken), + sql.value(hashOpaqueValue(input.browserBinding)), sql.value(input.handoff.hash) ], - ['uuid', 'text', 'text', 'text', 'jsonb', 'text', 'boolean', 'text', 'bytea'] + [ + 'uuid', + 'text', + 'text', + 'text', + 'jsonb', + 'text', + 'boolean', + 'text', + 'bytea', + 'bytea' + ] ); const mfaRequired = requiredBoolean( diff --git a/graphql/server/src/auth/sso/service.ts b/graphql/server/src/auth/sso/service.ts index c04abb3d7d..3c322150fe 100644 --- a/graphql/server/src/auth/sso/service.ts +++ b/graphql/server/src/auth/sso/service.ts @@ -243,6 +243,7 @@ export const createUnifiedAuthService = (oauthEnabled: boolean): UnifiedAuthServ token.kind !== 'api_key' || typeof token.principal_id !== 'string' || !context.api.apiId || + !context.siteId || token.access_level === 'read_only' ) { throw errors.FORBIDDEN(); diff --git a/graphql/server/src/auth/sso/site-session.ts b/graphql/server/src/auth/sso/site-session.ts new file mode 100644 index 0000000000..790f82bd61 --- /dev/null +++ b/graphql/server/src/auth/sso/site-session.ts @@ -0,0 +1,56 @@ +import { errors, toError } from '@constructive-io/errors'; +import type { NextFunction, Request, RequestHandler, Response } from 'express'; + +import { respondWithGraphQLError } from '../../errors/graphql-response'; +import { callFunction, requiredBoolean } from './db-contract'; + +export const VALIDATE_SITE_SESSION_FUNCTION = 'validate_site_session'; + +/** + * Validate Site-local sessions against their bound unified session. + * + * The authoritative `(site_id, api_id, principal_id)` tuple comes from + * routing and authenticated credential pgSettings. API/service principals are + * intentionally not Site sessions and remain available for handoff redemption. + */ +export const createSiteSessionValidationMiddleware = (): RequestHandler => + async (req: Request, res: Response, next: NextFunction): Promise => { + const context = req.constructive; + const token = context?.token; + if ( + !context || + !context.siteId || + !token?.user_id || + token.kind === 'api_key' + ) { + next(); + return; + } + + try { + const surface = await context.useModule('ssoSurface'); + if (!surface) { + next(); + return; + } + + const row = await callFunction( + context, + surface, + VALIDATE_SITE_SESSION_FUNCTION, + [], + [] + ); + if (!requiredBoolean(row, 'valid', VALIDATE_SITE_SESSION_FUNCTION)) { + throw errors.INVALID_TOKEN(); + } + next(); + } catch (cause) { + const error = toError(cause); + if (req.path === '/graphql' || req.originalUrl.startsWith('/graphql')) { + respondWithGraphQLError(res, error); + return; + } + next(error); + } + }; diff --git a/graphql/server/src/middleware/__tests__/routing.test.ts b/graphql/server/src/middleware/__tests__/routing.test.ts index bcd013a888..5d8fb008b1 100644 --- a/graphql/server/src/middleware/__tests__/routing.test.ts +++ b/graphql/server/src/middleware/__tests__/routing.test.ts @@ -44,6 +44,7 @@ const matchedRoute = (overrides: Partial = {}): ResolvedRoute => verification_status: 'verified', tls_status: 'ready', tls_secret_name: 'tls-api-example-com', + runtime_site_id: 'site-1', ...overrides }); @@ -104,6 +105,7 @@ describe('routeToApiStructure', () => { expect(structure).toEqual( expect.objectContaining({ apiId: 'api-1', + siteId: 'site-1', databaseId: 'db-1', dbname: 'tenant_db', roleName: 'api_role', diff --git a/graphql/server/src/middleware/error-handler.ts b/graphql/server/src/middleware/error-handler.ts index bbf63de194..1111e1847c 100644 --- a/graphql/server/src/middleware/error-handler.ts +++ b/graphql/server/src/middleware/error-handler.ts @@ -1,5 +1,6 @@ import './types'; +import { ConstructiveError } from '@constructive-io/errors'; import { getNodeEnv } from '@pgpmjs/env'; import { Logger } from '@pgpmjs/logger'; import type { ErrorRequestHandler, NextFunction, Request, Response } from 'express'; @@ -39,6 +40,14 @@ const isCsrfError = (err: Error): boolean => { }; const categorizeError = (err: Error): ErrorResponse => { + if (err instanceof ConstructiveError) { + return { + statusCode: err.http, + code: err.code, + message: err.isPublic ? err.message : 'An unexpected error occurred', + logLevel: err.http >= 500 ? 'error' : 'warn' + }; + } if (isApiError(err)) { return { statusCode: err.statusCode, @@ -79,7 +88,15 @@ const logError = (err: Error, req: Request, level: 'warn' | 'error'): void => { clientIp: req.clientIp, }; - if (isApiError(err)) { + if (err instanceof ConstructiveError) { + log[level]({ + event: 'constructive_error', + code: err.code, + statusCode: err.http, + message: err.message, + ...context + }); + } else if (isApiError(err)) { log[level]({ event: 'api_error', code: err.code, statusCode: err.statusCode, message: err.message, ...context }); } else { log[level]({ event: 'unexpected_error', name: err.name, message: err.message, stack: isDevelopment() ? err.stack : undefined, ...context }); diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index fda0445522..e772d80ac8 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -228,6 +228,11 @@ const buildPreset = ( if (req.api?.apiId) { context['jwt.claims.api_id'] = req.api.apiId; } + // Independent trusted Site identity from scoped routing. A Site is + // not inferred from api_id because multiple Sites may share one API. + if (req.api?.siteId) { + context['jwt.claims.site_id'] = req.api.siteId; + } if (req.clientIp) { context['jwt.claims.ip_address'] = req.clientIp; } @@ -365,7 +370,7 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { try { const instance = await inFlight; return instance.handler(req, res, next); - } catch (error) { + } catch { log.warn(`${label} Coalesced request failed for PostGraphile[${key}], retrying`); // Fall through to Phase C to retry creation } diff --git a/graphql/server/src/middleware/routing.ts b/graphql/server/src/middleware/routing.ts index 752b32bdff..10bf6da955 100644 --- a/graphql/server/src/middleware/routing.ts +++ b/graphql/server/src/middleware/routing.ts @@ -37,6 +37,8 @@ export interface ResolvedRoute { verification_status: string | null; tls_status: string | null; tls_secret_name: string | null; + /** Optional Site security context bound to this route independently of API. */ + runtime_site_id: string | null; } const RESOLVER_FUNCTION = 'resolve_route'; @@ -128,6 +130,7 @@ export const routeToApiStructure = ( return { apiId: config.api_id ?? route.target_source_id ?? undefined, + siteId: route.runtime_site_id ?? undefined, // Scoped APIs leave dbname NULL when their schemas live in the serving // database; fall back to the server's own database in that case. dbname: config.dbname || opts.pg?.database || '', diff --git a/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts b/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts index 5f7e34334e..7ac0c4650b 100644 --- a/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts +++ b/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts @@ -344,9 +344,9 @@ describe('AuthCookiePlugin unified-auth cookie boundary', () => { cookieHttponly: false, cookieSamesite: 'lax' } - } - }, - res: { setHeader, getHeader } + }, + res: { setHeader, getHeader } + } } } } @@ -453,7 +453,7 @@ describe('AuthCookiePlugin P0 scenarios', () => { const simulatePluginCookieDecision = ( query: string, response: GraphQLResponse, - config: CookieConfig = defaultConfig + _config: CookieConfig = defaultConfig ): { sessionCookie?: string; deviceCookie?: string; cleared: string[] } => { const result: { sessionCookie?: string; deviceCookie?: string; cleared: string[] } = { cleared: [], diff --git a/graphql/server/src/plugins/auth-cookie-plugin.ts b/graphql/server/src/plugins/auth-cookie-plugin.ts index 4baa55b621..3a3e41a3b3 100644 --- a/graphql/server/src/plugins/auth-cookie-plugin.ts +++ b/graphql/server/src/plugins/auth-cookie-plugin.ts @@ -311,7 +311,7 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { return result; } - const res = (event.requestDigest.requestContext as { + const grafservResponse = (event.requestDigest.requestContext as { expressv4?: { res?: { setHeader: (name: string, value: string | string[]) => void; @@ -319,6 +319,10 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { }; }; })?.expressv4?.res; + // Grafserv's Express adapter always exposes the request, but some + // versions do not copy the response onto requestContext. Express + // itself links the authoritative response as req.res. + const res = grafservResponse ?? req.res; const noStore = mutationFields.some(field => NO_STORE_AUTH_MUTATIONS.has(field.fieldName) ); @@ -417,7 +421,7 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { if (existingCookies) { if (Array.isArray(existingCookies)) { allCookies.push(...existingCookies); - } else { + } else if (typeof existingCookies === 'string') { allCookies.push(existingCookies); } } diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index c30e6d06e6..c9a61b4bd1 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -23,6 +23,7 @@ import requestIp from 'request-ip'; import { createAgenticRouter } from './agentic'; import { createOAuthRouter } from './auth/oauth'; +import { createSiteSessionValidationMiddleware } from './auth/sso/site-session'; import { closeDebugDatabasePools } from './diagnostics/debug-db-snapshot'; import type { DebugSamplerHandle } from './diagnostics/debug-sampler'; import { startDebugSampler } from './diagnostics/debug-sampler'; @@ -180,6 +181,7 @@ class Server { loaders: contextLoaders, routingSchema: getRoutingSchema(effectiveOpts) })); + app.use(createSiteSessionValidationMiddleware()); app.use(createCaptchaMiddleware()); // CSRF protection for cookie-authenticated requests diff --git a/packages/express-context/__tests__/loaders/auth-loaders.test.ts b/packages/express-context/__tests__/loaders/auth-loaders.test.ts index f09e8e31fe..d23d0772e7 100644 --- a/packages/express-context/__tests__/loaders/auth-loaders.test.ts +++ b/packages/express-context/__tests__/loaders/auth-loaders.test.ts @@ -141,8 +141,22 @@ describe('identityProvidersLoader', () => { }; 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: [{ + schema_name: 'tenant_a_auth_private', + table_name: 'identity_providers', + scope: 'database', + prefix: '' + }] + }, + { + rows: [{ + schema_name: 'tenant_a_secrets', + table_name: 'internal_secrets', + scope: 'database', + prefix: '' + }] + }, { rows } ]; @@ -152,9 +166,10 @@ describe('identityProvidersLoader', () => { 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[1].values).toEqual(['db-a', 'database']); + expect(calls[2].text).toContain('"tenant_a_secrets"."_internal_secrets_get"'); expect(calls[2].text).toContain('"tenant_a_auth_private"."identity_providers"'); + expect(calls[2].values).toEqual(['db-a']); expect(module?.providers.google).toMatchObject({ clientId: 'client-abc', clientSecret: 'shh', @@ -170,7 +185,14 @@ describe('identityProvidersLoader', () => { 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: [{ + schema_name: 'tenant_a_auth_private', + table_name: 'identity_providers', + scope: 'database', + prefix: '' + }] + }, { rows: [] } ]); await expect(identityProvidersLoader.resolve(ctx(pool))).rejects.toThrow( diff --git a/packages/express-context/__tests__/pg-settings.test.ts b/packages/express-context/__tests__/pg-settings.test.ts index eb080cf684..16605b10d0 100644 --- a/packages/express-context/__tests__/pg-settings.test.ts +++ b/packages/express-context/__tests__/pg-settings.test.ts @@ -3,6 +3,7 @@ import type { ApiStructure, ConstructiveAPIToken } from '../src/types'; const api: ApiStructure = { apiId: '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + siteId: '87763e7e-8aeb-4e5c-98ce-95e16b6f62ac', dbname: 'testdb', anonRole: 'anonymous', roleName: 'authenticated', @@ -17,6 +18,7 @@ describe('buildPgSettings — jwt.claims.api_id provenance', () => { const settings = buildPgSettings({ api, token: null, requestId: 'r1' }); expect(settings['jwt.claims.api_id']).toBe(api.apiId); + expect(settings['jwt.claims.site_id']).toBe(api.siteId); expect(settings['role']).toBe('anonymous'); }); @@ -74,10 +76,22 @@ describe('buildPgSettings — jwt.claims.api_id provenance', () => { it('is derived only from the resolved api, never from the token', () => { const token = { user_id: 'u1', - api_id: 'attacker-controlled' + api_id: 'attacker-controlled', + site_id: 'attacker-controlled' } as unknown as ConstructiveAPIToken; const settings = buildPgSettings({ api, token, requestId: 'r1' }); expect(settings['jwt.claims.api_id']).toBe(api.apiId); + expect(settings['jwt.claims.site_id']).toBe(api.siteId); + }); + + it('omits jwt.claims.site_id when the route has no Site context', () => { + const settings = buildPgSettings({ + api: { ...api, siteId: undefined }, + token: null, + requestId: 'r1' + }); + + expect(settings['jwt.claims.site_id']).toBeUndefined(); }); }); diff --git a/packages/express-context/src/context.ts b/packages/express-context/src/context.ts index d35bf17970..0ce526cdc6 100644 --- a/packages/express-context/src/context.ts +++ b/packages/express-context/src/context.ts @@ -7,7 +7,7 @@ * - pgSettings (role, claims, request_id, database_id) * - Tenant database pool (via pg-cache) * - withPgClient (transaction-scoped RLS helper) - * - Convenience/request fact fields (userId, databaseId, requestId, origin) + * - Convenience/request fact fields (userId, databaseId, siteId, requestId, origin) * - useModule (lazy, on-demand per-database module resolution) * * The result is a single `req.constructive` object that any downstream @@ -131,6 +131,7 @@ export function buildContext( token, pgSettings, databaseId: api.databaseId ?? null, + siteId: api.siteId ?? null, userId: token?.user_id ?? null, requestId, requestOrigin: resolveRequestOrigin(req), diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts index 837a8292e3..ac44404280 100644 --- a/packages/express-context/src/loaders/identity-providers.ts +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -28,7 +28,8 @@ import { requireDatabaseId } from './types'; // ─── SQL ──────────────────────────────────────────────────────────────────── const IDENTITY_PROVIDERS_DISCOVERY_SQL = ` - SELECT s.schema_name AS schema_name, m.table_name AS table_name + SELECT s.schema_name AS schema_name, m.table_name AS table_name, + m.scope, m.prefix 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 @@ -36,29 +37,37 @@ const IDENTITY_PROVIDERS_DISCOVERY_SQL = ` `; const INTERNAL_SECRETS_DISCOVERY_SQL = ` - SELECT s.schema_name AS schema_name, m.internal_secrets_table_name AS table_name + SELECT s.schema_name AS schema_name, m.internal_secrets_table_name AS table_name, + m.scope, m.prefix 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 + WHERE m.database_id = $1 AND m.scope = $2 LIMIT 1 `; interface DiscoveredLocation { schema_name: string; table_name: string; + scope: string; + prefix: 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. + * The getter is the generated internal-secrets getter 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. Database-scoped stores take the current database ID as their + * first argument; app/platform stores do not. 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) => ` +const buildProvidersQuery = ( + providers: DiscoveredLocation, + secrets: DiscoveredLocation +) => ` SELECT p.id, p.slug, @@ -68,7 +77,8 @@ const buildProvidersQuery = (providers: DiscoveredLocation, secrets: DiscoveredL p.client_id, CASE WHEN p.client_secret_id IS NULL THEN NULL - ELSE "${secrets.schema_name}"."${secrets.table_name}_get"( + ELSE "${secrets.schema_name}"."${secrets.prefix}_internal_secrets_get"( + ${secrets.scope === 'database' ? '$1,' : ''} p.slug || '/client-secret', uuid_nil() ) @@ -155,16 +165,16 @@ const toProviderConfig = (row: ProviderRow): IdentityProviderConfig => { const discoverOne = async ( ctx: LoaderContext, sql: string, - moduleName: string + values: unknown[] ): Promise => { - const result = await ctx.tenantPool.query(sql, [ctx.databaseId]); + const result = await ctx.tenantPool.query(sql, values); 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 }; + return row; }; // ─── Loader ───────────────────────────────────────────────────────────────── @@ -184,14 +194,14 @@ export const identityProvidersLoader: ModuleLoader = const providers = await discoverOne( ctx, IDENTITY_PROVIDERS_DISCOVERY_SQL, - 'identity_providers_module' + [databaseId] ); if (!providers) return undefined; const secrets = await discoverOne( ctx, INTERNAL_SECRETS_DISCOVERY_SQL, - 'internal_secrets_module' + [databaseId, providers.scope] ); // A provider table without its secret store cannot yield a usable client // secret, and silently returning secret-less providers would present a @@ -203,7 +213,10 @@ export const identityProvidersLoader: ModuleLoader = ); } - const result = await tenantPool.query(buildProvidersQuery(providers, secrets)); + const result = await tenantPool.query( + buildProvidersQuery(providers, secrets), + secrets.scope === 'database' ? [databaseId] : [] + ); const bySlug: Record = {}; for (const row of result.rows) { diff --git a/packages/express-context/src/pg-settings.ts b/packages/express-context/src/pg-settings.ts index b7fe5ff940..9e767fbe64 100644 --- a/packages/express-context/src/pg-settings.ts +++ b/packages/express-context/src/pg-settings.ts @@ -67,6 +67,13 @@ export function buildPgSettings(input: PgSettingsInput): Record settings['jwt.claims.api_id'] = api.apiId; } + // Site provenance is an independent trusted routing fact. Multiple Sites + // may share an API, so it must never be reconstructed from api_id, Origin, + // Referer, or token claims. + if (api.siteId) { + settings['jwt.claims.site_id'] = api.siteId; + } + // Distributed tracing settings['request.id'] = requestId; diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 2819cb2492..8837563563 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -141,6 +141,8 @@ export interface IdentityProvidersModule { export interface ApiStructure { apiId?: string; + /** Trusted Site runtime identity emitted by scoped routing, when present. */ + siteId?: string; dbname: string; anonRole: string; roleName: string; @@ -280,6 +282,8 @@ export interface ConstructiveContext { pgSettings: Record; /** Database UUID from the API resolver */ databaseId: string | null; + /** Trusted Site UUID from the resolved route; never inferred from Origin. */ + siteId: string | null; /** Authenticated user ID from the JWT token */ userId: string | null; /** Per-request correlation ID for distributed tracing */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e316102d2f..2bf0115a49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2143,6 +2143,9 @@ importers: specifier: ^7.0.0 version: 7.2.2 devDependencies: + 12factor-env: + specifier: workspace:^ + version: link:../../packages/12factor-env/dist '@0no-co/graphql.web': specifier: ^1.3.3 version: 1.3.3(graphql@16.13.0) From f51d88064788fc4aea5a3bfa51a1676b259399d7 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Tue, 11 Aug 2026 08:14:53 +0800 Subject: [PATCH 11/11] test: align SSO fixture with capabilities module --- .../server-test/__fixtures__/seed/oauth-sso/real-runtime.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphql/server-test/__fixtures__/seed/oauth-sso/real-runtime.ts b/graphql/server-test/__fixtures__/seed/oauth-sso/real-runtime.ts index d1a617e15f..f6b7a5234f 100644 --- a/graphql/server-test/__fixtures__/seed/oauth-sso/real-runtime.ts +++ b/graphql/server-test/__fixtures__/seed/oauth-sso/real-runtime.ts @@ -18,11 +18,11 @@ export const REAL_RUNTIME_FIXTURE = { const modules = [ 'users_module', 'membership_types_module', - ['permissions_module', { scope: 'app' }], + ['capabilities_module', { scope: 'app' }], ['limits_module', { scope: 'app' }], ['levels_module', { scope: 'app' }], ['memberships_module', { scope: 'app' }], - ['permissions_module', { scope: 'org' }], + ['capabilities_module', { scope: 'org' }], ['limits_module', { scope: 'org' }], ['memberships_module', { scope: 'org' }], 'sessions_module',