diff --git a/graphql/server/src/auth/oauth/__tests__/router.test.ts b/graphql/server/src/auth/oauth/__tests__/router.test.ts index a7968feca4..9ed3bf8af6 100644 --- a/graphql/server/src/auth/oauth/__tests__/router.test.ts +++ b/graphql/server/src/auth/oauth/__tests__/router.test.ts @@ -71,18 +71,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'); expect(mockedComplete).toHaveBeenCalledWith( expect.anything(), diff --git a/graphql/server/src/auth/oauth/__tests__/service.test.ts b/graphql/server/src/auth/oauth/__tests__/service.test.ts index 1f9d4c7d98..203709c814 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() @@ -137,6 +139,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([ @@ -152,6 +157,7 @@ describe('Provider OAuth orchestration', () => { 'bearer', false, null, + expect.stringMatching(/^\\x[0-9a-f]{64}$/), expect.stringMatching(/^\\x[0-9a-f]{64}$/) ]); }); diff --git a/graphql/server/src/auth/oauth/page.ts b/graphql/server/src/auth/oauth/page.ts index c31554a296..0257e9b265 100644 --- a/graphql/server/src/auth/oauth/page.ts +++ b/graphql/server/src/auth/oauth/page.ts @@ -26,9 +26,3 @@ const page = (title: string, body: string): string => ` export const renderOAuthFailurePage = (error: ConstructiveError): string => page('External sign in failed', `${error.message} (${error.code})`); - -export const renderOAuthSuccessPage = (): string => - page( - 'External sign in completed', - 'Authentication succeeded. You may close this page.' - ); diff --git a/graphql/server/src/auth/oauth/router.ts b/graphql/server/src/auth/oauth/router.ts index 8dc9d2b190..bab2a4da3c 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 @@ -125,11 +122,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 77db3fd158..fe327e6778 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, @@ -125,6 +126,7 @@ export const completeProviderAuthentication = async ( requestId: request.requestId, identity, browserBinding: input.browserBinding, - deviceToken: input.deviceToken + deviceToken: input.deviceToken, + handoff: createHandoffMaterial() }); }; diff --git a/graphql/server/src/auth/sso/__tests__/handoff.test.ts b/graphql/server/src/auth/sso/__tests__/handoff.test.ts new file mode 100644 index 0000000000..77341ae8db --- /dev/null +++ b/graphql/server/src/auth/sso/__tests__/handoff.test.ts @@ -0,0 +1,49 @@ +import { + buildHandoffContinuationUrl, + createHandoffMaterial, + hashHandoffCode +} from '../handoff'; + +describe('SSO handoff primitives', () => { + it('creates 256-bit plaintext and keeps only its SHA-256 bytea digest', () => { + const first = createHandoffMaterial(); + const second = createHandoffMaterial(); + + expect(first.code).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(first.hash).toMatch(/^\\x[0-9a-f]{64}$/); + expect(first.hash).toBe(hashHandoffCode(first.code)); + expect(first.code).not.toBe(second.code); + }); + + it('adds only handoff and Site state to an exact HTTPS callback', () => { + const result = buildHandoffContinuationUrl( + 'https://portal.example.com/auth/complete?locale=en', + 's'.repeat(43), + 'h'.repeat(43) + ); + const callback = new URL(result); + + expect(callback.origin).toBe('https://portal.example.com'); + expect(callback.pathname).toBe('/auth/complete'); + expect(callback.searchParams.get('locale')).toBe('en'); + expect(callback.searchParams.get('handoff')).toBe('h'.repeat(43)); + expect(callback.searchParams.get('site_state')).toBe('s'.repeat(43)); + }); + + it('fails closed for non-HTTPS or reserved callback parameters', () => { + expect(() => buildHandoffContinuationUrl( + 'http://portal.example.com/auth/complete', + 's'.repeat(43), + 'h'.repeat(43) + )).toThrow(); + expect(() => buildHandoffContinuationUrl( + 'https://portal.example.com/auth/complete?handoff=attacker', + 's'.repeat(43), + 'h'.repeat(43) + )).toThrow(); + }); + + it('rejects malformed redemption codes before hashing', () => { + expect(() => hashHandoffCode('short')).toThrow(); + }); +}); diff --git a/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts b/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts index 9ef22edec9..f0ebd69641 100644 --- a/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts +++ b/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts @@ -54,7 +54,8 @@ describe('UnifiedAuthPlugin schema integration', () => { 'confirmUnifiedLogin', 'signInUnifiedLogin', 'signUpUnifiedLogin', - 'startProviderAuthentication' + 'startProviderAuthentication', + 'redeemUnifiedLoginHandoff' ]) ); }); diff --git a/graphql/server/src/auth/sso/__tests__/service.test.ts b/graphql/server/src/auth/sso/__tests__/service.test.ts index 08e2e0d6a4..d171a84774 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,7 +48,27 @@ 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, + siteId: options.runtime + ? '00000000-0000-0000-0000-000000000024' + : 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', + siteId: options.runtime + ? '00000000-0000-0000-0000-000000000024' + : null, userId: options.userId ?? null, useModule: jest.fn(async (name: string) => { if (name === 'ssoSurface') return surface; @@ -135,7 +156,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); @@ -150,7 +174,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"' @@ -163,10 +189,109 @@ describe('unified authentication GraphQL service', () => { 'bearer', expect.stringMatching(/^\\x[0-9a-f]{64}$/), null, - 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([ + expect.stringMatching(/^\\x[0-9a-f]{64}$/), + expect.stringMatching(/^\\x[0-9a-f]{64}$/), + 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/__tests__/site-session.test.ts b/graphql/server/src/auth/sso/__tests__/site-session.test.ts new file mode 100644 index 0000000000..883a330fea --- /dev/null +++ b/graphql/server/src/auth/sso/__tests__/site-session.test.ts @@ -0,0 +1,108 @@ +import type { + ConstructiveContext, + SsoSurface +} from '@constructive-io/express-context'; +import type { NextFunction, Request, Response } from 'express'; +import type { PoolClient, QueryResult } from 'pg'; + +import { createSiteSessionValidationMiddleware } from '../site-session'; + +const surface: SsoSurface = { privateSchema: 'tenant_acme_sso_private' }; + +const makeBoundary = ( + options: { + siteId?: string | null; + tokenKind?: string; + result?: unknown; + error?: Error; + ssoEnabled?: boolean; + } = {} +) => { + const query = jest.fn(async () => { + if (options.error) throw options.error; + return { + rows: [{ result: options.result ?? { valid: true } }] + } as unknown as QueryResult; + }); + const client = { query } as unknown as PoolClient; + const context = { + siteId: options.siteId === undefined ? 'site-1' : options.siteId, + token: { + id: 'credential-1', + user_id: 'user-1', + session_id: 'session-1', + kind: options.tokenKind ?? 'bearer' + }, + useModule: jest.fn(async (name: string) => + name === 'ssoSurface' && options.ssoEnabled !== false + ? surface + : undefined + ), + withPgClient: jest.fn(async (callback: (pg: PoolClient) => Promise) => + callback(client) + ) + } as unknown as ConstructiveContext; + const req = { + constructive: context, + path: '/graphql', + originalUrl: '/graphql' + } as Request; + const responseBody: { value?: unknown } = {}; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn((value: unknown) => { + responseBody.value = value; + return res; + }) + } as unknown as Response; + const next = jest.fn() as NextFunction; + return { query, req, res, next, responseBody }; +}; + +describe('Site session validation middleware', () => { + it('validates a Site-local session through the current Tenant SSO surface', async () => { + const { query, req, res, next } = makeBoundary(); + + await createSiteSessionValidationMiddleware()(req, res, next); + + expect(query).toHaveBeenCalledWith( + expect.stringContaining('"tenant_acme_sso_private"."validate_site_session"'), + [] + ); + expect(next).toHaveBeenCalledWith(); + }); + + it('does not treat a Site runtime API key as a Site-local browser session', async () => { + const { query, req, res, next } = makeBoundary({ tokenKind: 'api_key' }); + + await createSiteSessionValidationMiddleware()(req, res, next); + + expect(query).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + }); + + it('does not infer a Site when routing did not provide one', async () => { + const { query, req, res, next } = makeBoundary({ siteId: null }); + + await createSiteSessionValidationMiddleware()(req, res, next); + + expect(query).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + }); + + it('returns the stable DB authentication error after unified-session revocation', async () => { + const databaseError = Object.assign(new Error('INVALID_TOKEN'), { + code: 'P0001', + detail: JSON.stringify({ code: 'INVALID_TOKEN', context: {}, class: 'public' }) + }); + const { req, res, next, responseBody } = makeBoundary({ error: databaseError }); + + await createSiteSessionValidationMiddleware()(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(responseBody.value).toMatchObject({ + errors: [{ extensions: { code: 'INVALID_TOKEN' } }] + }); + }); +}); diff --git a/graphql/server/src/auth/sso/db-contract.ts b/graphql/server/src/auth/sso/db-contract.ts index 311d0a9c91..3d69f89967 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 { createOpaqueMaterial, hashOpaqueValue } from './opaque'; import type { ContinueUnifiedLoginInput, @@ -27,10 +31,11 @@ import type { * server-generated transaction digest and returns safe Site display fields, * `sign_in_mode`, * `reusable_authentication`, and optional safe current-user display fields. - * - `confirm_unified_login(bytea, bytea)` returns the associated `user_id`. + * - `confirm_unified_login(bytea, bytea, bytea)` returns the associated `user_id` + * and the transaction-bound Site callback continuation fields. * - `sign_in_unified_login(bytea, text, text, boolean, text, bytea, text, - * text)` and - * `sign_up_unified_login(...)` return the unchanged local credential columns. + * text, bytea)` and `sign_up_unified_login(...)` return the unchanged local + * credential columns and the same continuation fields. * * Browser-held transaction and binding values are always digested before they * cross the DB boundary. The SSO browser binding is not an anonymous-session @@ -122,6 +127,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, @@ -208,7 +229,8 @@ 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( @@ -217,16 +239,16 @@ export const confirmUnifiedLogin = async ( operation, [ sql.value(hashOpaqueValue(input.transactionId)), - sql.value(hashOpaqueValue(browserBinding)) + sql.value(hashOpaqueValue(browserBinding)), + sql.value(handoff.hash) ], - ['bytea', 'bytea'] + ['bytea', 'bytea', '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) }; }; @@ -235,7 +257,8 @@ const authenticateWithPassword = async ( context: ConstructiveContext, surface: SsoSurface, input: UnifiedPasswordInput, - browserBinding: string + browserBinding: string, + handoff: HandoffMaterial ): Promise => { const row = await callFunction( context, @@ -249,9 +272,20 @@ const authenticateWithPassword = async ( sql.value('bearer'), sql.value(hashOpaqueValue(browserBinding)), sql.value(null), - sql.value(input.deviceToken ?? null) + sql.value(input.deviceToken ?? null), + sql.value(handoff.hash) ], - ['bytea', 'text', 'text', 'boolean', 'text', 'bytea', 'text', 'text'] + [ + 'bytea', + 'text', + 'text', + 'boolean', + 'text', + 'bytea', + 'text', + 'text', + 'bytea' + ] ); // Strict-auth/MFA/step-up integration is explicitly outside v1. The DB @@ -275,8 +309,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) }; }; @@ -284,26 +317,30 @@ export const signInUnifiedLogin = ( context: ConstructiveContext, surface: SsoSurface, input: UnifiedPasswordInput, - browserBinding: string + browserBinding: string, + handoff: HandoffMaterial ): Promise => authenticateWithPassword( SSO_DB_FUNCTIONS.signIn, context, surface, input, - browserBinding + browserBinding, + handoff ); export const signUpUnifiedLogin = ( context: ConstructiveContext, surface: SsoSurface, input: UnifiedPasswordInput, - browserBinding: string + browserBinding: string, + handoff: HandoffMaterial ): Promise => authenticateWithPassword( SSO_DB_FUNCTIONS.signUp, context, surface, input, - browserBinding + browserBinding, + handoff ); diff --git a/graphql/server/src/auth/sso/handoff-db-contract.ts b/graphql/server/src/auth/sso/handoff-db-contract.ts new file mode 100644 index 0000000000..996be82bb9 --- /dev/null +++ b/graphql/server/src/auth/sso/handoff-db-contract.ts @@ -0,0 +1,66 @@ +import { errors } from '@constructive-io/errors'; +import type { + ConstructiveContext, + SsoSurface +} from '@constructive-io/express-context'; +import sql from 'pg-sql2'; + +import { + callFunction, + requiredBoolean, + requiredString +} from './db-contract'; +import { hashHandoffCode } from './handoff'; +import type { RedeemUnifiedLoginHandoffPayload } from './types'; + +export const SSO_HANDOFF_DB_FUNCTION = 'redeem_sso_handoff'; + +/** + * Redeem through the current routed API and authenticated service principal. + * The DB function reads the authoritative api_id, token kind/id, principal, + * Tenant, and role from the existing request pgSettings. Possession of the + * handoff digest is deliberately insufficient by itself. + */ +export const redeemUnifiedLoginHandoff = async ( + context: ConstructiveContext, + surface: SsoSurface, + handoffCode: string +): Promise => { + const operation = SSO_HANDOFF_DB_FUNCTION; + const row = await callFunction( + context, + surface, + operation, + [sql.value(hashHandoffCode(handoffCode))], + ['bytea'] + ); + + const mfaRequired = requiredBoolean(row, 'mfa_required', operation); + if (mfaRequired) throw errors.AUTH_METHOD_NOT_ALLOWED({}); + + const returnTo = requiredString(row, 'return_to', operation); + if ( + returnTo.length > 2048 || + !returnTo.startsWith('/') || + returnTo.startsWith('//') || + /[\r\n]/.test(returnTo) + ) { + throw errors.INTERNAL_FAILURE({ + details: 'The database returned an invalid Site return target.' + }); + } + + return { + credentialId: requiredString(row, 'id', operation), + userId: requiredString(row, 'user_id', operation), + accessToken: requiredString(row, 'access_token', operation), + accessTokenExpiresAt: requiredString( + row, + 'access_token_expires_at', + operation + ), + isVerified: requiredBoolean(row, 'is_verified', operation), + totpEnabled: requiredBoolean(row, 'totp_enabled', operation), + returnTo + }; +}; diff --git a/graphql/server/src/auth/sso/handoff.ts b/graphql/server/src/auth/sso/handoff.ts new file mode 100644 index 0000000000..a8eec1bbee --- /dev/null +++ b/graphql/server/src/auth/sso/handoff.ts @@ -0,0 +1,66 @@ +import { errors } from '@constructive-io/errors'; + +import { createOpaqueMaterial, hashOpaqueValue } from './opaque'; + +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 material = createOpaqueMaterial(); + return { code: material.value, hash: material.hash }; +}; + +export const hashHandoffCode = (code: string): string => { + if (!HANDOFF_CODE.test(code)) throw errors.INVALID_SSO_HANDOFF(); + return hashOpaqueValue(code); +}; + +/** + * Add only the approved one-time callback artifacts to the exact callback + * restored from the Tenant-owned login transaction. + */ +export const buildHandoffContinuationUrl = ( + callbackUrl: string, + siteState: string, + handoffCode: string +): string => { + let callback: URL; + try { + callback = new URL(callbackUrl); + } catch (cause) { + throw errors.INTERNAL_FAILURE( + { details: 'The database returned an invalid unified login callback.' }, + undefined, + { cause } + ); + } + + if ( + callback.protocol !== 'https:' || + callback.username || + callback.password || + callback.hash || + callback.searchParams.has('handoff') || + callback.searchParams.has('site_state') + ) { + throw errors.INTERNAL_FAILURE({ + details: 'The database returned an unsafe unified login callback.' + }); + } + if (!HANDOFF_CODE.test(handoffCode)) { + throw errors.INTERNAL_FAILURE({ details: 'The generated SSO handoff is invalid.' }); + } + if (!SITE_STATE.test(siteState)) { + throw errors.INTERNAL_FAILURE({ details: 'The database returned an invalid Site state.' }); + } + + callback.searchParams.set('handoff', handoffCode); + callback.searchParams.set('site_state', siteState); + return callback.toString(); +}; diff --git a/graphql/server/src/auth/sso/plugin.ts b/graphql/server/src/auth/sso/plugin.ts index 74cf134332..4158e68fb3 100644 --- a/graphql/server/src/auth/sso/plugin.ts +++ b/graphql/server/src/auth/sso/plugin.ts @@ -4,6 +4,7 @@ import { extendSchema, gql } from 'graphile-utils'; import { createUnifiedAuthService } from './service'; import type { ContinueUnifiedLoginInput, + RedeemUnifiedLoginHandoffInput, StartProviderAuthenticationInput, StartUnifiedLoginInput, UnifiedAuthGraphQLContext, @@ -60,7 +61,7 @@ export const createUnifiedAuthPlugin = ( type UnifiedLoginContinuationPayload { transactionId: String! authenticated: Boolean! - continuationUrl: String + continuationUrl: String! } type UnifiedLoginCredentialPayload { @@ -72,7 +73,17 @@ export const createUnifiedAuthPlugin = ( accessTokenExpiresAt: Datetime! isVerified: Boolean! totpEnabled: Boolean! - continuationUrl: String + continuationUrl: String! + } + + type RedeemUnifiedLoginHandoffPayload { + credentialId: UUID! + userId: UUID! + accessToken: String! + accessTokenExpiresAt: Datetime! + isVerified: Boolean! + totpEnabled: Boolean! + returnTo: String! } input StartUnifiedLoginInput { @@ -99,6 +110,10 @@ export const createUnifiedAuthPlugin = ( providerKey: String! } + input RedeemUnifiedLoginHandoffInput { + handoffCode: String! + } + extend type Query { unifiedAuthProviders: [UnifiedAuthProvider!]! } @@ -109,6 +124,7 @@ export const createUnifiedAuthPlugin = ( signInUnifiedLogin(input: UnifiedPasswordInput!): UnifiedLoginCredentialPayload! signUpUnifiedLogin(input: UnifiedPasswordInput!): UnifiedLoginCredentialPayload! startProviderAuthentication(input: StartProviderAuthenticationInput!): StartProviderAuthenticationPayload! + redeemUnifiedLoginHandoff(input: RedeemUnifiedLoginHandoffInput!): RedeemUnifiedLoginHandoffPayload! } `, resolvers: { @@ -144,7 +160,12 @@ export const createUnifiedAuthPlugin = ( _source: unknown, args: InputArguments, context: UnifiedAuthGraphQLContext - ) => service.startProvider(context, args.input) + ) => service.startProvider(context, args.input), + redeemUnifiedLoginHandoff: ( + _source: unknown, + args: InputArguments, + context: UnifiedAuthGraphQLContext + ) => service.redeem(context, args.input) } } }, 'UnifiedAuthPlugin'); diff --git a/graphql/server/src/auth/sso/provider-db-contract.ts b/graphql/server/src/auth/sso/provider-db-contract.ts index b1056bb79e..0e22474148 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'; import { hashOpaqueValue } from './opaque'; export const PROVIDER_DB_FUNCTIONS = { @@ -32,9 +34,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, 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. + * boolean, text, bytea, bytea)` accepts request ID plus normalized identity, + * existing credential options, browser binding, and the server-generated + * handoff digest; it returns the unchanged identity-auth credential result + * and transaction-bound callback continuation. */ export interface ProviderOAuthRequest { @@ -52,7 +55,7 @@ export interface ProviderCredentialResult { accessTokenExpiresAt: string; isVerified: boolean; totpEnabled: boolean; - continuationUrl: string | null; + continuationUrl: string; } /** @@ -163,6 +166,7 @@ export const completeProviderUnifiedLogin = async ( identity: NormalizedExternalIdentity; browserBinding: string; deviceToken: string | null; + handoff: HandoffMaterial; } ): Promise => { const operation = PROVIDER_DB_FUNCTIONS.complete; @@ -179,9 +183,21 @@ export const completeProviderUnifiedLogin = async ( sql.value('bearer'), sql.value(false), sql.value(input.deviceToken), - sql.value(hashOpaqueValue(input.browserBinding)) + sql.value(hashOpaqueValue(input.browserBinding)), + sql.value(input.handoff.hash) ], - ['uuid', 'text', 'text', 'text', 'jsonb', 'text', 'boolean', 'text', 'bytea'] + [ + 'uuid', + 'text', + 'text', + 'text', + 'jsonb', + 'text', + 'boolean', + 'text', + 'bytea', + 'bytea' + ] ); const mfaRequired = requiredBoolean( @@ -205,6 +221,10 @@ export const completeProviderUnifiedLogin = async ( ), isVerified: requiredBoolean(row, 'is_verified', operation), totpEnabled: requiredBoolean(row, 'totp_enabled', operation), - continuationUrl: optionalString(row, 'continuation_url', operation) + continuationUrl: continuationFromDatabaseResult( + row, + operation, + input.handoff + ) }; }; diff --git a/graphql/server/src/auth/sso/service.ts b/graphql/server/src/auth/sso/service.ts index e49285f984..3c322150fe 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,26 @@ 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 || + !context.siteId || + 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/site-session.ts b/graphql/server/src/auth/sso/site-session.ts new file mode 100644 index 0000000000..790f82bd61 --- /dev/null +++ b/graphql/server/src/auth/sso/site-session.ts @@ -0,0 +1,56 @@ +import { errors, toError } from '@constructive-io/errors'; +import type { NextFunction, Request, RequestHandler, Response } from 'express'; + +import { respondWithGraphQLError } from '../../errors/graphql-response'; +import { callFunction, requiredBoolean } from './db-contract'; + +export const VALIDATE_SITE_SESSION_FUNCTION = 'validate_site_session'; + +/** + * Validate Site-local sessions against their bound unified session. + * + * The authoritative `(site_id, api_id, principal_id)` tuple comes from + * routing and authenticated credential pgSettings. API/service principals are + * intentionally not Site sessions and remain available for handoff redemption. + */ +export const createSiteSessionValidationMiddleware = (): RequestHandler => + async (req: Request, res: Response, next: NextFunction): Promise => { + const context = req.constructive; + const token = context?.token; + if ( + !context || + !context.siteId || + !token?.user_id || + token.kind === 'api_key' + ) { + next(); + return; + } + + try { + const surface = await context.useModule('ssoSurface'); + if (!surface) { + next(); + return; + } + + const row = await callFunction( + context, + surface, + VALIDATE_SITE_SESSION_FUNCTION, + [], + [] + ); + if (!requiredBoolean(row, 'valid', VALIDATE_SITE_SESSION_FUNCTION)) { + throw errors.INVALID_TOKEN(); + } + next(); + } catch (cause) { + const error = toError(cause); + if (req.path === '/graphql' || req.originalUrl.startsWith('/graphql')) { + respondWithGraphQLError(res, error); + return; + } + next(error); + } + }; diff --git a/graphql/server/src/auth/sso/types.ts b/graphql/server/src/auth/sso/types.ts index 422774e796..1c50162760 100644 --- a/graphql/server/src/auth/sso/types.ts +++ b/graphql/server/src/auth/sso/types.ts @@ -63,7 +63,21 @@ export interface StartUnifiedLoginPayload { export interface UnifiedLoginContinuationPayload { transactionId: string; authenticated: true; - continuationUrl: string | null; + continuationUrl: string; +} + +export interface RedeemUnifiedLoginHandoffInput { + handoffCode: string; +} + +export interface RedeemUnifiedLoginHandoffPayload { + credentialId: string; + userId: string; + accessToken: string; + accessTokenExpiresAt: string; + isVerified: boolean; + totpEnabled: boolean; + returnTo: string; } export interface UnifiedLoginCredentialPayload diff --git a/graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts b/graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts index 5f44391ba5..d7834475e4 100644 --- a/graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts +++ b/graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts @@ -11,7 +11,7 @@ describe('redactSensitiveRequestUrl', () => { '/callback?error=%5BREDACTED%5D' ); expect(redactSensitiveRequestUrl('/callback?handoff=secret&site_state=public')).toBe( - '/callback?handoff=%5BREDACTED%5D&site_state=public' + '/callback?handoff=%5BREDACTED%5D&site_state=%5BREDACTED%5D' ); }); diff --git a/graphql/server/src/middleware/observability/request-logger.ts b/graphql/server/src/middleware/observability/request-logger.ts index c6b92cb231..cc10fea3fb 100644 --- a/graphql/server/src/middleware/observability/request-logger.ts +++ b/graphql/server/src/middleware/observability/request-logger.ts @@ -11,6 +11,7 @@ const SENSITIVE_QUERY_PARAMETERS = new Set([ 'error_description', 'handoff', 'id_token', + 'site_state', 'state', 'token' ]); diff --git a/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts b/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts index 057d2e4b27..ada719d3e4 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 545d346c5a..3a3e41a3b3 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,36 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { return result; } + const grafservResponse = (event.requestDigest.requestContext as { + expressv4?: { + res?: { + setHeader: (name: string, value: string | string[]) => void; + getHeader: (name: string) => string | string[] | undefined; + }; + }; + })?.expressv4?.res; + // Grafserv's Express adapter always exposes the request, but some + // versions do not copy the response onto requestContext. Express + // itself links the authoritative response as req.res. + const res = grafservResponse ?? req.res; + const noStore = mutationFields.some(field => + NO_STORE_AUTH_MUTATIONS.has(field.fieldName) + ); + 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 +350,7 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { ); if (!signInMutation && !signOutMutation) { - return result; + return authResult; } log.debug( @@ -323,7 +366,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,19 +413,6 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { // Set cookies directly on Express response and return modified headers if (cookiesToSet.length > 0) { - const grafservResponse = (event.requestDigest.requestContext as { - expressv4?: { - res?: { - setHeader: (name: string, value: string | string[]) => void; - getHeader: (name: string) => string | string[] | undefined; - }; - }; - })?.expressv4?.res; - // Grafserv's Express adapter always exposes the request, but some - // versions do not copy the response onto requestContext. Express - // itself links the authoritative response as req.res. - const res = grafservResponse ?? req.res; - if (res?.setHeader) { // Get existing Set-Cookie headers from Express response const existingCookies = res.getHeader('Set-Cookie'); @@ -402,18 +432,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/graphql/server/src/server.ts b/graphql/server/src/server.ts index c30e6d06e6..c9a61b4bd1 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -23,6 +23,7 @@ import requestIp from 'request-ip'; import { createAgenticRouter } from './agentic'; import { createOAuthRouter } from './auth/oauth'; +import { createSiteSessionValidationMiddleware } from './auth/sso/site-session'; import { closeDebugDatabasePools } from './diagnostics/debug-db-snapshot'; import type { DebugSamplerHandle } from './diagnostics/debug-sampler'; import { startDebugSampler } from './diagnostics/debug-sampler'; @@ -180,6 +181,7 @@ class Server { loaders: contextLoaders, routingSchema: getRoutingSchema(effectiveOpts) })); + app.use(createSiteSessionValidationMiddleware()); app.use(createCaptchaMiddleware()); // CSRF protection for cookie-authenticated requests diff --git a/packages/express-context/__tests__/pg-settings.test.ts b/packages/express-context/__tests__/pg-settings.test.ts index 590486d26f..16605b10d0 100644 --- a/packages/express-context/__tests__/pg-settings.test.ts +++ b/packages/express-context/__tests__/pg-settings.test.ts @@ -31,6 +31,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 65db1bf78b..9e767fbe64 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;