diff --git a/graphql/server/src/auth/oauth/__tests__/router.test.ts b/graphql/server/src/auth/oauth/__tests__/router.test.ts index 3abe3b5b0..8b6130fb8 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 fe44bd4cb..180da2f95 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 c31554a29..0257e9b26 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 8530ab0dc..ae07f38b6 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 6d2d2539b..77b276ec1 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 000000000..77341ae8d --- /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 9ef22edec..f0ebd6964 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 857916d48..d335092db 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 b96c7d23f..ba8e66b5a 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 000000000..996be82bb --- /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 000000000..21224d359 --- /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 74cf13433..4158e68fb 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 784b4b3cd..5e57c1b41 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 e49285f98..c04abb3d7 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 422774e79..1c5016276 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 5f44391ba..d7834475e 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 c6b92cb23..cc10fea3f 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 79cf1d3e3..5f7e34334 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 15b45f563..4baa55b62 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 6b87b83b1..eb080cf68 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 cb8633645..b7fe5ff94 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;