From f0e08d56cb98717914e2cd10e160de3a7743e2da Mon Sep 17 00:00:00 2001 From: zetazzz Date: Mon, 10 Aug 2026 00:19:04 +0800 Subject: [PATCH] feat: add OAuth provider HTTP flow --- .../src/auth/oauth/__tests__/router.test.ts | 112 ++++++++++ .../src/auth/oauth/__tests__/service.test.ts | 178 +++++++++++++++ graphql/server/src/auth/oauth/index.ts | 1 + graphql/server/src/auth/oauth/page.ts | 34 +++ graphql/server/src/auth/oauth/router.ts | 139 ++++++++++++ graphql/server/src/auth/oauth/service.ts | 130 +++++++++++ .../sso/__tests__/plugin.integration.test.ts | 3 +- .../src/auth/sso/__tests__/service.test.ts | 45 +++- graphql/server/src/auth/sso/db-contract.ts | 16 +- graphql/server/src/auth/sso/plugin.ts | 18 +- .../server/src/auth/sso/provider-config.ts | 106 +++++++++ .../src/auth/sso/provider-db-contract.ts | 210 ++++++++++++++++++ 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, 1178 insertions(+), 81 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 000000000..a7968feca --- /dev/null +++ b/graphql/server/src/auth/oauth/__tests__/router.test.ts @@ -0,0 +1,112 @@ +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.deviceToken = 'device-token'; + 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'); + expect(mockedComplete).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ deviceToken: 'device-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 000000000..1f9d4c7d9 --- /dev/null +++ b/graphql/server/src/auth/oauth/__tests__/service.test.ts @@ -0,0 +1,178 @@ +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, + deviceToken: null, + 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, + null, + expect.stringMatching(/^\\x[0-9a-f]{64}$/) + ]); + }); + + 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, + deviceToken: null, + 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 000000000..cf9900cf8 --- /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 000000000..c31554a29 --- /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 000000000..8dc9d2b19 --- /dev/null +++ b/graphql/server/src/auth/oauth/router.ts @@ -0,0 +1,139 @@ +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, + deviceToken: req.deviceToken ?? null, + 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 000000000..77db3fd15 --- /dev/null +++ b/graphql/server/src/auth/oauth/service.ts @@ -0,0 +1,130 @@ +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; + deviceToken: string | null; + 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, + deviceToken: input.deviceToken + }); +}; 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 3cd493c5a..9ef22edec 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 0f12085d6..08e2e0d6a 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; @@ -96,7 +97,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( @@ -166,6 +167,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([ + 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', + expect.stringMatching(/^\\x[0-9a-f]{64}$/) + ]); + }); + + 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 a147cb9ba..311d0a9c9 100644 --- a/graphql/server/src/auth/sso/db-contract.ts +++ b/graphql/server/src/auth/sso/db-contract.ts @@ -45,7 +45,7 @@ export const SSO_DB_FUNCTIONS = { signUp: 'sign_up_unified_login' } as const; -type DatabaseRecord = Record; +export type DatabaseRecord = Record; interface StartDatabaseResult { transactionId: string; @@ -62,14 +62,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 @@ -81,7 +81,7 @@ const requiredString = ( return value; }; -const optionalString = ( +export const optionalString = ( row: DatabaseRecord, field: string, operation: string @@ -92,7 +92,7 @@ const optionalString = ( return value; }; -const requiredBoolean = ( +export const requiredBoolean = ( row: DatabaseRecord, field: string, operation: string @@ -102,7 +102,7 @@ const requiredBoolean = ( return value; }; -type SqlCast = 'boolean' | 'bytea' | 'text' | 'uuid'; +export type SqlCast = 'boolean' | 'bytea' | 'jsonb' | 'text' | 'uuid'; const castValue = ( value: ReturnType, @@ -113,6 +113,8 @@ const castValue = ( return sql.fragment`${value}::boolean`; case 'bytea': return sql.fragment`${value}::bytea`; + case 'jsonb': + return sql.fragment`${value}::jsonb`; case 'text': return sql.fragment`${value}::text`; case 'uuid': @@ -120,7 +122,7 @@ const castValue = ( } }; -const callFunction = async ( +export const callFunction = async ( context: ConstructiveContext, surface: SsoSurface, functionName: string, diff --git a/graphql/server/src/auth/sso/plugin.ts b/graphql/server/src/auth/sso/plugin.ts index 1f86fca6a..74cf13433 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 000000000..15229448e --- /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 000000000..b1056bb79 --- /dev/null +++ b/graphql/server/src/auth/sso/provider-db-contract.ts @@ -0,0 +1,210 @@ +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'; +import { hashOpaqueValue } from './opaque'; + +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(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, 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, + * existing credential options, device token, 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(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(hashOpaqueValue(input.browserBinding)) + ], + ['bytea', 'text', 'text', 'text', 'text', 'text', 'bytea'] + ); + 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(hashOpaqueValue(browserBinding))], + ['text', 'bytea'] + ); + 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; + deviceToken: string | null; + } +): 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.deviceToken), + sql.value(hashOpaqueValue(input.browserBinding)) + ], + ['uuid', 'text', 'text', 'text', 'jsonb', 'text', 'boolean', 'text', 'bytea'] + ); + + 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 77b5e5894..e49285f98 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 a5166c569..422774e79 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 000000000..5f44391ba --- /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 5280a682a..c6b92cb23 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 20eb62d20..c30e6d06e 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 36eea0d62..39a827049 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 6886dbd34..222539a2f 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 000000000..b73c4252c --- /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 14eb8ad90..0ce526cdc 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, siteId, requestId) + * - 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 @@ -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. * @@ -116,6 +134,7 @@ export function buildContext( siteId: api.siteId ?? 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 5fe55920a..5a9ec6cb4 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 3da443486..883756356 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -288,6 +288,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 */