From b4e9292a97bafe0e46f8d30867c6a5d1e82b3de0 Mon Sep 17 00:00:00 2001 From: zetazzz Date: Sun, 9 Aug 2026 23:45:33 +0800 Subject: [PATCH] feat: add unified auth GraphQL integration --- graphql/server-test/src/get-connections.ts | 3 +- graphql/server-test/src/types.ts | 8 +- graphql/server/package.json | 1 + .../sso/__tests__/plugin.integration.test.ts | 60 ++++ .../src/auth/sso/__tests__/service.test.ts | 197 +++++++++++ graphql/server/src/auth/sso/db-contract.ts | 307 ++++++++++++++++++ graphql/server/src/auth/sso/index.ts | 1 + graphql/server/src/auth/sso/opaque.ts | 13 + graphql/server/src/auth/sso/plugin.ts | 135 ++++++++ graphql/server/src/auth/sso/service.ts | 212 ++++++++++++ graphql/server/src/auth/sso/types.ts | 68 ++++ .../__tests__/grafast-context.test.ts | 32 ++ .../server/src/middleware/grafast-context.ts | 17 + graphql/server/src/middleware/graphile.ts | 25 +- .../__tests__/auth-cookie-plugin.test.ts | 134 ++++++-- .../server/src/plugins/auth-cookie-plugin.ts | 279 ++++++++++------ graphql/server/src/server.ts | 15 +- packages/csrf/src/index.ts | 1 + packages/csrf/src/middleware.ts | 4 +- pnpm-lock.yaml | 3 + 20 files changed, 1366 insertions(+), 149 deletions(-) create mode 100644 graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts create mode 100644 graphql/server/src/auth/sso/__tests__/service.test.ts create mode 100644 graphql/server/src/auth/sso/db-contract.ts create mode 100644 graphql/server/src/auth/sso/index.ts create mode 100644 graphql/server/src/auth/sso/opaque.ts create mode 100644 graphql/server/src/auth/sso/plugin.ts create mode 100644 graphql/server/src/auth/sso/service.ts create mode 100644 graphql/server/src/auth/sso/types.ts create mode 100644 graphql/server/src/middleware/__tests__/grafast-context.test.ts create mode 100644 graphql/server/src/middleware/grafast-context.ts diff --git a/graphql/server-test/src/get-connections.ts b/graphql/server-test/src/get-connections.ts index 875c30b9cd..edda7c4a4a 100644 --- a/graphql/server-test/src/get-connections.ts +++ b/graphql/server-test/src/get-connections.ts @@ -55,7 +55,8 @@ export const getConnections = async ( exposedSchemas: input.schemas, ...(input.authRole && { anonRole: input.authRole, roleName: input.authRole }) }, - graphile: input.graphile + graphile: input.graphile, + oauth: input.server?.oauth }); // Start the HTTP server. Suites default to the production scoped-routing diff --git a/graphql/server-test/src/types.ts b/graphql/server-test/src/types.ts index 2d1119be2e..7736a97216 100644 --- a/graphql/server-test/src/types.ts +++ b/graphql/server-test/src/types.ts @@ -1,4 +1,8 @@ -import type { ApiOptions,GraphileOptions } from '@constructive-io/graphql-types'; +import type { + ApiOptions, + GraphileOptions, + OAuthServerOptions +} from '@constructive-io/graphql-types'; import type { DocumentNode, GraphQLError } from 'graphql'; import type { Server } from 'http'; import type { PgTestClient } from 'pgsql-test/test-client'; @@ -39,6 +43,8 @@ export interface ServerOptions { * ``` */ api?: Partial; + /** GraphQL-server OAuth options forwarded through the normal typed config path. */ + oauth?: OAuthServerOptions; } /** diff --git a/graphql/server/package.json b/graphql/server/package.json index a3676d1950..d6be1be707 100644 --- a/graphql/server/package.json +++ b/graphql/server/package.json @@ -48,6 +48,7 @@ "@constructive-io/graphql-env": "workspace:^", "@constructive-io/graphql-types": "workspace:^", "@constructive-io/llm-env": "workspace:^", + "@constructive-io/oauth": "workspace:^", "@constructive-io/query-builder": "workspace:^", "@constructive-io/s3-utils": "workspace:^", "@constructive-io/url-domains": "workspace:^", diff --git a/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts b/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts new file mode 100644 index 0000000000..3cd493c5a6 --- /dev/null +++ b/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts @@ -0,0 +1,60 @@ +import path from 'node:path'; + +import { getConnections, seed } from 'graphile-test'; + +import { createUnifiedAuthPlugin } from '../plugin'; + +jest.setTimeout(60_000); + +type Connections = Awaited>; + +describe('UnifiedAuthPlugin schema integration', () => { + let db: Connections['db']; + let query: Connections['query']; + let teardown: () => Promise; + + beforeAll(async () => { + const connections = await getConnections( + { + schemas: ['app_public'], + authRole: 'anonymous', + preset: { plugins: [createUnifiedAuthPlugin(false)] } + }, + [ + seed.sqlfile([ + path.join(__dirname, '../../../../../server-test/sql/test.sql') + ]) + ] + ); + ({ db, query, teardown } = connections); + }); + + beforeEach(() => db.beforeEach()); + afterEach(() => db.afterEach()); + afterAll(() => teardown()); + + it('adds the stable unified-auth Query and Mutation fields', async () => { + const response = await query<{ + query: { fields: Array<{ name: string }> }; + mutation: { fields: Array<{ name: string }> }; + }>(` + query UnifiedAuthSchema { + query: __type(name: "Query") { fields { name } } + mutation: __type(name: "Mutation") { fields { name } } + } + `); + + expect(response.errors).toBeUndefined(); + expect(response.data?.query.fields.map(field => field.name)).toContain( + 'unifiedAuthProviders' + ); + expect(response.data?.mutation.fields.map(field => field.name)).toEqual( + expect.arrayContaining([ + 'startUnifiedLogin', + 'confirmUnifiedLogin', + 'signInUnifiedLogin', + 'signUpUnifiedLogin' + ]) + ); + }); +}); diff --git a/graphql/server/src/auth/sso/__tests__/service.test.ts b/graphql/server/src/auth/sso/__tests__/service.test.ts new file mode 100644 index 0000000000..0f12085d6a --- /dev/null +++ b/graphql/server/src/auth/sso/__tests__/service.test.ts @@ -0,0 +1,197 @@ +import type { + ConstructiveContext, + IdentityProviderConfig, + SsoSurface +} from '@constructive-io/express-context'; +import type { PoolClient, QueryResult } from 'pg'; + +import { createUnifiedAuthService } from '../service'; + +const opaque = 'a'.repeat(43); +const surface: SsoSurface = { privateSchema: 'tenant_acme_sso_private' }; + +const googleProvider: IdentityProviderConfig = { + id: 'provider-id', + slug: 'google-workspace', + kind: 'google', + displayName: 'Google Workspace', + enabled: true, + clientId: 'client-id', + clientSecret: 'client-secret', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + userinfoUrl: null, + issuerUrl: 'https://accounts.google.com', + discoveryUrlOverride: null, + discoveryDoc: null, + jwks: { keys: [] }, + jwksFetchedAt: null, + acceptableClientIds: [], + scopes: ['openid', 'email', 'profile'], + extraAuthorizationParams: {}, + emailOptional: false, + allowLinkByEmail: false, + skipNonceCheck: false, + pkceEnabled: true +}; + +const makeContext = ( + databaseResult?: Record, + options: { + userId?: string | null; + providers?: Record; + } = {} +): { context: ConstructiveContext; query: jest.Mock } => { + const query = jest.fn(async () => ({ + rows: databaseResult === undefined ? [] : [{ result: databaseResult }] + } as unknown as QueryResult)); + const client = { query } as unknown as PoolClient; + const context = { + userId: options.userId ?? null, + useModule: jest.fn(async (name: string) => { + if (name === 'ssoSurface') return surface; + if (name === 'identityProviders') { + return options.providers + ? { providers: options.providers, source: { schemaName: 'p', tableName: 'p' } } + : undefined; + } + return undefined; + }), + withPgClient: jest.fn(async (callback: (pg: PoolClient) => Promise) => + callback(client) + ) + } as unknown as ConstructiveContext; + return { context, query }; +}; + +describe('unified authentication GraphQL service', () => { + it('returns no Provider options without resolving secrets when OAuth is disabled', async () => { + const { context } = makeContext(undefined, { providers: { google: googleProvider } }); + const service = createUnifiedAuthService(false); + + await expect(service.providers({ constructive: context })).resolves.toEqual([]); + expect(context.useModule).not.toHaveBeenCalledWith('identityProviders'); + }); + + it('returns only safe dynamic Provider display fields', async () => { + const { context } = makeContext(undefined, { + providers: { + google: googleProvider, + custom: { ...googleProvider, slug: 'custom', kind: 'custom' } + } + }); + const service = createUnifiedAuthService(true); + + await expect(service.providers({ constructive: context })).resolves.toEqual([ + { key: 'google-workspace', displayName: 'Google Workspace' } + ]); + }); + + it('starts through the current Tenant SSO function and merges Provider options', async () => { + const { context, query } = makeContext({ + site_id: '00000000-0000-0000-0000-000000000001', + site_display_name: 'Customer Portal', + site_icon_url: null, + site_theme_color: '#112233', + sign_in_mode: 'confirm', + reusable_authentication: false, + current_user_id: null + }, { providers: { google: googleProvider } }); + const service = createUnifiedAuthService(true); + + const result = await service.start( + { constructive: context, browserBinding: opaque }, + { + siteId: '00000000-0000-0000-0000-000000000001', + returnTo: '/approvals/42', + siteState: opaque + } + ); + + expect(result.providers).toEqual([ + { key: 'google-workspace', displayName: 'Google Workspace' } + ]); + expect(result.transactionId).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(result.site.displayName).toBe('Customer Portal'); + expect(query.mock.calls[0][0]).toContain( + '"tenant_acme_sso_private"."start_unified_login"' + ); + expect(query.mock.calls[0][1]).toEqual([ + expect.stringMatching(/^\\x[0-9a-f]{64}$/), + '00000000-0000-0000-0000-000000000001', + null, + '/approvals/42', + opaque, + expect.stringMatching(/^\\x[0-9a-f]{64}$/) + ]); + }); + + it('uses the fixed local-password wrapper contract once', async () => { + const { context, query } = makeContext({ + id: '00000000-0000-0000-0000-000000000010', + user_id: '00000000-0000-0000-0000-000000000011', + access_token: 'cnc_live_bt_secret', + access_token_expires_at: '2026-08-10T00:00:00.000Z', + is_verified: false, + totp_enabled: false, + mfa_required: false + }); + const service = createUnifiedAuthService(false); + + const result = await service.signIn( + { constructive: context, browserBinding: opaque }, + { + transactionId: opaque, + email: 'user@example.com', + password: 'correct horse battery staple', + rememberMe: true + } + ); + + expect(result.accessToken).toBe('cnc_live_bt_secret'); + expect(result.continuationUrl).toBeNull(); + expect(query).toHaveBeenCalledTimes(1); + expect(query.mock.calls[0][0]).toContain( + '"tenant_acme_sso_private"."sign_in_unified_login"' + ); + expect(query.mock.calls[0][1]).toEqual([ + expect.stringMatching(/^\\x[0-9a-f]{64}$/), + 'user@example.com', + 'correct horse battery staple', + true, + 'bearer', + expect.stringMatching(/^\\x[0-9a-f]{64}$/), + null, + null + ]); + }); + + it('rejects a cross-origin return target before database access', async () => { + const { context, query } = makeContext(); + const service = createUnifiedAuthService(false); + + await expect(service.start( + { constructive: context, browserBinding: opaque }, + { + siteId: '00000000-0000-0000-0000-000000000001', + returnTo: 'https://evil.example/steal', + siteState: opaque + } + )).rejects.toMatchObject({ code: 'INVALID_SSO_RETURN_TARGET' }); + expect(query).not.toHaveBeenCalled(); + }); + + it('requires the server-read first-party browser binding', async () => { + const { context, query } = makeContext(); + const service = createUnifiedAuthService(false); + + await expect(service.start( + { constructive: context }, + { + siteId: '00000000-0000-0000-0000-000000000001', + siteState: opaque + } + )).rejects.toMatchObject({ code: 'INVALID_SSO_SITE_STATE' }); + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/graphql/server/src/auth/sso/db-contract.ts b/graphql/server/src/auth/sso/db-contract.ts new file mode 100644 index 0000000000..a147cb9baa --- /dev/null +++ b/graphql/server/src/auth/sso/db-contract.ts @@ -0,0 +1,307 @@ +import { errors } from '@constructive-io/errors'; +import type { ConstructiveContext, SsoSurface } from '@constructive-io/express-context'; +import sql from 'pg-sql2'; + +import { createOpaqueMaterial, hashOpaqueValue } from './opaque'; +import type { + ContinueUnifiedLoginInput, + StartUnifiedLoginInput, + UnifiedAuthAccount, + UnifiedAuthSite, + UnifiedLoginContinuationPayload, + UnifiedLoginCredentialPayload, + UnifiedPasswordInput +} from './types'; + +/** + * Stable Constructive/Constructive DB boundary for the GraphQL integration. + * + * These functions live in the current Tenant's provisioned SSO private schema. + * They own transaction locking, expiry, browser/Site/Tenant checks, calls to the + * unchanged local `sign_in`/`sign_up` primitives, and identity/session + * association. Constructive intentionally does not read the private tables. + * + * Exact v1 signatures fixed by this integration: + * + * - `start_unified_login(bytea, uuid, text, text, text, bytea)` accepts a + * server-generated transaction digest and returns safe Site display fields, + * `sign_in_mode`, + * `reusable_authentication`, and optional safe current-user display fields. + * - `confirm_unified_login(bytea, bytea)` returns the associated `user_id`. + * - `sign_in_unified_login(bytea, text, text, boolean, text, bytea, text, + * text)` and + * `sign_up_unified_login(...)` return the unchanged local credential columns. + * + * Browser-held transaction and binding values are always digested before they + * cross the DB boundary. The SSO browser binding is not an anonymous-session + * CSRF secret, so the unchanged local credential primitive receives no CSRF + * value unless a future flow establishes such a session explicitly. The + * transaction identifier is deliberately not modelled as a row UUID. + */ +export const SSO_DB_FUNCTIONS = { + start: 'start_unified_login', + confirm: 'confirm_unified_login', + signIn: 'sign_in_unified_login', + signUp: 'sign_up_unified_login' +} as const; + +type DatabaseRecord = Record; + +interface StartDatabaseResult { + transactionId: string; + site: UnifiedAuthSite; + signInMode: 'CONFIRM_BEFORE_SIGN_IN' | 'SILENT'; + reusableAuthentication: boolean; + currentAccount: UnifiedAuthAccount | null; +} + +const invalidDatabaseResult = (operation: string, cause?: unknown): Error => + errors.INTERNAL_FAILURE( + { details: `Invalid ${operation} result from the unified authentication database function.` }, + undefined, + cause === undefined ? undefined : { cause } + ); + +const asRecord = (value: unknown, operation: string): DatabaseRecord => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw invalidDatabaseResult(operation); + } + return value as DatabaseRecord; +}; + +const requiredString = ( + row: DatabaseRecord, + field: string, + operation: string +): string => { + const value = row[field]; + if (typeof value !== 'string' || value.length === 0) { + throw invalidDatabaseResult(operation); + } + return value; +}; + +const optionalString = ( + row: DatabaseRecord, + field: string, + operation: string +): string | null => { + const value = row[field]; + if (value === null || value === undefined) return null; + if (typeof value !== 'string') throw invalidDatabaseResult(operation); + return value; +}; + +const requiredBoolean = ( + row: DatabaseRecord, + field: string, + operation: string +): boolean => { + const value = row[field]; + if (typeof value !== 'boolean') throw invalidDatabaseResult(operation); + return value; +}; + +type SqlCast = 'boolean' | 'bytea' | 'text' | 'uuid'; + +const castValue = ( + value: ReturnType, + cast: SqlCast +): ReturnType => { + switch (cast) { + case 'boolean': + return sql.fragment`${value}::boolean`; + case 'bytea': + return sql.fragment`${value}::bytea`; + case 'text': + return sql.fragment`${value}::text`; + case 'uuid': + return sql.fragment`${value}::uuid`; + } +}; + +const callFunction = async ( + context: ConstructiveContext, + surface: SsoSurface, + functionName: string, + args: ReturnType[], + casts: SqlCast[] +): Promise => { + const argumentSql = args.map((arg, index) => castValue(arg, casts[index])); + const query = sql.query` + SELECT to_jsonb(operation_result) AS result + FROM ${sql.identifier(surface.privateSchema, functionName)}( + ${sql.join(argumentSql, ', ')} + ) AS operation_result + `; + const compiled = sql.compile(query); + + return context.withPgClient(async client => { + const result = await client.query<{ result: unknown }>( + compiled.text, + compiled.values + ); + if (result.rows.length !== 1) { + throw invalidDatabaseResult(functionName); + } + return asRecord(result.rows[0].result, functionName); + }); +}; + +export const startUnifiedLogin = async ( + context: ConstructiveContext, + surface: SsoSurface, + input: StartUnifiedLoginInput, + browserBinding: string +): Promise => { + const operation = SSO_DB_FUNCTIONS.start; + const transaction = createOpaqueMaterial(); + const row = await callFunction( + context, + surface, + operation, + [ + sql.value(transaction.hash), + sql.value(input.siteId), + sql.value(input.callbackUrl ?? null), + sql.value(input.returnTo ?? '/'), + sql.value(input.siteState), + sql.value(hashOpaqueValue(browserBinding)) + ], + ['bytea', 'uuid', 'text', 'text', 'text', 'bytea'] + ); + const signInMode = requiredString(row, 'sign_in_mode', operation); + if (signInMode !== 'confirm' && signInMode !== 'silent') { + throw invalidDatabaseResult(operation); + } + + const currentUserId = optionalString(row, 'current_user_id', operation); + const currentAccount = currentUserId + ? { + id: currentUserId, + displayName: requiredString(row, 'current_user_display_name', operation), + avatarUrl: optionalString(row, 'current_user_avatar_url', operation) + } + : null; + + return { + transactionId: transaction.value, + site: { + id: requiredString(row, 'site_id', operation), + displayName: requiredString(row, 'site_display_name', operation), + iconUrl: optionalString(row, 'site_icon_url', operation), + themeColor: optionalString(row, 'site_theme_color', operation) + }, + signInMode: signInMode === 'silent' ? 'SILENT' : 'CONFIRM_BEFORE_SIGN_IN', + reusableAuthentication: requiredBoolean( + row, + 'reusable_authentication', + operation + ), + currentAccount + }; +}; + +export const confirmUnifiedLogin = async ( + context: ConstructiveContext, + surface: SsoSurface, + input: ContinueUnifiedLoginInput, + browserBinding: string +): Promise => { + const operation = SSO_DB_FUNCTIONS.confirm; + const row = await callFunction( + context, + surface, + operation, + [ + sql.value(hashOpaqueValue(input.transactionId)), + sql.value(hashOpaqueValue(browserBinding)) + ], + ['bytea', 'bytea'] + ); + requiredString(row, 'user_id', operation); + return { + transactionId: input.transactionId, + authenticated: true, + // PR 6 adds the shared one-time handoff continuation. + continuationUrl: null + }; +}; + +const authenticateWithPassword = async ( + functionName: typeof SSO_DB_FUNCTIONS.signIn | typeof SSO_DB_FUNCTIONS.signUp, + context: ConstructiveContext, + surface: SsoSurface, + input: UnifiedPasswordInput, + browserBinding: string +): Promise => { + const row = await callFunction( + context, + surface, + functionName, + [ + sql.value(hashOpaqueValue(input.transactionId)), + sql.value(input.email), + sql.value(input.password), + sql.value(input.rememberMe ?? false), + sql.value('bearer'), + sql.value(hashOpaqueValue(browserBinding)), + sql.value(null), + sql.value(input.deviceToken ?? null) + ], + ['bytea', 'text', 'text', 'boolean', 'text', 'bytea', 'text', 'text'] + ); + + // Strict-auth/MFA/step-up integration is explicitly outside v1. The DB + // wrapper must fail closed; this guard prevents an accidental partial result + // from being treated as a completed unified login. + const mfaRequired = requiredBoolean(row, 'mfa_required', functionName); + if (mfaRequired) { + throw errors.AUTH_METHOD_NOT_ALLOWED({}); + } + + return { + transactionId: input.transactionId, + authenticated: true, + credentialId: requiredString(row, 'id', functionName), + userId: requiredString(row, 'user_id', functionName), + accessToken: requiredString(row, 'access_token', functionName), + accessTokenExpiresAt: requiredString( + row, + 'access_token_expires_at', + functionName + ), + isVerified: requiredBoolean(row, 'is_verified', functionName), + totpEnabled: requiredBoolean(row, 'totp_enabled', functionName), + // PR 6 adds the shared one-time handoff continuation. + continuationUrl: null + }; +}; + +export const signInUnifiedLogin = ( + context: ConstructiveContext, + surface: SsoSurface, + input: UnifiedPasswordInput, + browserBinding: string +): Promise => + authenticateWithPassword( + SSO_DB_FUNCTIONS.signIn, + context, + surface, + input, + browserBinding + ); + +export const signUpUnifiedLogin = ( + context: ConstructiveContext, + surface: SsoSurface, + input: UnifiedPasswordInput, + browserBinding: string +): Promise => + authenticateWithPassword( + SSO_DB_FUNCTIONS.signUp, + context, + surface, + input, + browserBinding + ); diff --git a/graphql/server/src/auth/sso/index.ts b/graphql/server/src/auth/sso/index.ts new file mode 100644 index 0000000000..93c2eac3b1 --- /dev/null +++ b/graphql/server/src/auth/sso/index.ts @@ -0,0 +1 @@ +export { createUnifiedAuthPlugin } from './plugin'; diff --git a/graphql/server/src/auth/sso/opaque.ts b/graphql/server/src/auth/sso/opaque.ts new file mode 100644 index 0000000000..c7edead372 --- /dev/null +++ b/graphql/server/src/auth/sso/opaque.ts @@ -0,0 +1,13 @@ +import { createHash, randomBytes } from 'node:crypto'; + +const OPAQUE_BYTES = 32; + +/** Create a high-entropy browser value while persisting only its digest. */ +export const createOpaqueMaterial = (): { value: string; hash: string } => { + const value = randomBytes(OPAQUE_BYTES).toString('base64url'); + return { value, hash: hashOpaqueValue(value) }; +}; + +/** PostgreSQL bytea hex input for an opaque browser-held value. */ +export const hashOpaqueValue = (value: string): string => + `\\x${createHash('sha256').update(value, 'utf8').digest('hex')}`; diff --git a/graphql/server/src/auth/sso/plugin.ts b/graphql/server/src/auth/sso/plugin.ts new file mode 100644 index 0000000000..1f86fca6aa --- /dev/null +++ b/graphql/server/src/auth/sso/plugin.ts @@ -0,0 +1,135 @@ +import type { GraphileConfig } from 'graphile-config'; +import { extendSchema, gql } from 'graphile-utils'; + +import { createUnifiedAuthService } from './service'; +import type { + ContinueUnifiedLoginInput, + StartUnifiedLoginInput, + UnifiedAuthGraphQLContext, + UnifiedPasswordInput +} from './types'; + +interface InputArguments { + input: T; +} + +export const createUnifiedAuthPlugin = ( + oauthEnabled: boolean +): GraphileConfig.Plugin => { + const service = createUnifiedAuthService(oauthEnabled); + + return extendSchema({ + typeDefs: gql` + enum UnifiedAuthSignInMode { + CONFIRM_BEFORE_SIGN_IN + SILENT + } + + type UnifiedAuthProvider { + key: String! + displayName: String! + } + + type UnifiedAuthSite { + id: UUID! + displayName: String! + iconUrl: String + themeColor: String + } + + type UnifiedAuthAccount { + id: UUID! + displayName: String! + avatarUrl: String + } + + type StartUnifiedLoginPayload { + transactionId: String! + site: UnifiedAuthSite! + signInMode: UnifiedAuthSignInMode! + reusableAuthentication: Boolean! + currentAccount: UnifiedAuthAccount + providers: [UnifiedAuthProvider!]! + } + + type UnifiedLoginContinuationPayload { + transactionId: String! + authenticated: Boolean! + continuationUrl: String + } + + type UnifiedLoginCredentialPayload { + transactionId: String! + authenticated: Boolean! + credentialId: UUID! + userId: UUID! + accessToken: String! + accessTokenExpiresAt: Datetime! + isVerified: Boolean! + totpEnabled: Boolean! + continuationUrl: String + } + + input StartUnifiedLoginInput { + siteId: UUID! + callbackUrl: String + returnTo: String + siteState: String! + } + + input ContinueUnifiedLoginInput { + transactionId: String! + } + + input UnifiedPasswordInput { + transactionId: String! + email: String! + password: String! + rememberMe: Boolean = false + deviceToken: String + } + + extend type Query { + unifiedAuthProviders: [UnifiedAuthProvider!]! + } + + extend type Mutation { + startUnifiedLogin(input: StartUnifiedLoginInput!): StartUnifiedLoginPayload! + confirmUnifiedLogin(input: ContinueUnifiedLoginInput!): UnifiedLoginContinuationPayload! + signInUnifiedLogin(input: UnifiedPasswordInput!): UnifiedLoginCredentialPayload! + signUpUnifiedLogin(input: UnifiedPasswordInput!): UnifiedLoginCredentialPayload! + } + `, + resolvers: { + Query: { + unifiedAuthProviders: ( + _source: unknown, + _args: Record, + context: UnifiedAuthGraphQLContext + ) => service.providers(context) + }, + Mutation: { + startUnifiedLogin: ( + _source: unknown, + args: InputArguments, + context: UnifiedAuthGraphQLContext + ) => service.start(context, args.input), + confirmUnifiedLogin: ( + _source: unknown, + args: InputArguments, + context: UnifiedAuthGraphQLContext + ) => service.confirm(context, args.input), + signInUnifiedLogin: ( + _source: unknown, + args: InputArguments, + context: UnifiedAuthGraphQLContext + ) => service.signIn(context, args.input), + signUpUnifiedLogin: ( + _source: unknown, + args: InputArguments, + context: UnifiedAuthGraphQLContext + ) => service.signUp(context, args.input) + } + } + }, 'UnifiedAuthPlugin'); +}; diff --git a/graphql/server/src/auth/sso/service.ts b/graphql/server/src/auth/sso/service.ts new file mode 100644 index 0000000000..77b5e58948 --- /dev/null +++ b/graphql/server/src/auth/sso/service.ts @@ -0,0 +1,212 @@ +import { errors } from '@constructive-io/errors'; +import type { + ConstructiveContext, + IdentityProviderConfig, + IdentityProvidersModule, + SsoSurface +} from '@constructive-io/express-context'; +import { + getProviderAdapter, + getProviderAdapterKinds, + type IdentityProviderConfiguration +} from '@constructive-io/oauth'; + +import { + confirmUnifiedLogin, + signInUnifiedLogin, + signUpUnifiedLogin, + startUnifiedLogin +} from './db-contract'; +import type { + ContinueUnifiedLoginInput, + ProviderDisplayOption, + StartUnifiedLoginInput, + StartUnifiedLoginPayload, + UnifiedAuthGraphQLContext, + UnifiedLoginContinuationPayload, + UnifiedLoginCredentialPayload, + UnifiedPasswordInput +} from './types'; + +const OPAQUE_VALUE = /^[A-Za-z0-9_-]{32,256}$/; +const SITE_STATE = /^[A-Za-z0-9_-]{32,128}$/; + +const requireContext = ( + graphQLContext: UnifiedAuthGraphQLContext +): ConstructiveContext => { + if (!graphQLContext.constructive) { + throw errors.INTERNAL_FAILURE({ + details: 'The Constructive request context is unavailable.' + }); + } + return graphQLContext.constructive; +}; + +const resolveSsoSurface = async ( + context: ConstructiveContext +): Promise => { + const surface = await context.useModule('ssoSurface'); + if (!surface) throw errors.SSO_SIGN_IN_DISABLED(); + return surface; +}; + +const validateTransactionInput = (input: ContinueUnifiedLoginInput): void => { + if (!OPAQUE_VALUE.test(input.transactionId)) { + throw errors.SSO_LOGIN_TRANSACTION_EXPIRED(); + } +}; + +const requireBrowserBinding = ( + graphQLContext: UnifiedAuthGraphQLContext +): string => { + if (!graphQLContext.browserBinding || !OPAQUE_VALUE.test(graphQLContext.browserBinding)) { + throw errors.INVALID_SSO_SITE_STATE(); + } + return graphQLContext.browserBinding; +}; + +const validateStartInput = (input: StartUnifiedLoginInput): void => { + if (!SITE_STATE.test(input.siteState)) { + throw errors.INVALID_SSO_SITE_STATE(); + } + const returnTo = input.returnTo ?? '/'; + if ( + returnTo.length > 2048 || + !returnTo.startsWith('/') || + returnTo.startsWith('//') || + /[\r\n]/.test(returnTo) + ) { + throw errors.INVALID_SSO_RETURN_TARGET(); + } + if (input.callbackUrl && input.callbackUrl.length > 2048) { + throw errors.INVALID_SSO_CALLBACK(); + } +}; + +const toOAuthConfiguration = ( + provider: IdentityProviderConfig +): IdentityProviderConfiguration => ({ + slug: provider.slug, + kind: provider.kind, + displayName: provider.displayName, + enabled: provider.enabled, + clientId: provider.clientId, + clientSecret: provider.clientSecret, + authorizationUrl: provider.authorizationUrl, + tokenUrl: provider.tokenUrl, + userinfoUrl: provider.userinfoUrl, + issuerUrl: provider.issuerUrl, + discoveryDoc: provider.discoveryDoc, + jwks: provider.jwks, + acceptableClientIds: provider.acceptableClientIds, + scopes: provider.scopes, + extraAuthorizationParams: provider.extraAuthorizationParams, + emailOptional: provider.emailOptional, + skipNonceCheck: provider.skipNonceCheck, + pkceEnabled: provider.pkceEnabled +}); + +const providerDisplayOptions = ( + module: IdentityProvidersModule | undefined +): ProviderDisplayOption[] => { + if (!module) return []; + const supportedKinds = new Set(getProviderAdapterKinds()); + const options: ProviderDisplayOption[] = []; + + for (const provider of Object.values(module.providers)) { + if (!provider.enabled || !supportedKinds.has(provider.kind)) continue; + try { + getProviderAdapter(provider.kind).validateConfiguration( + toOAuthConfiguration(provider) + ); + } catch (cause) { + throw errors.IDENTITY_PROVIDER_NOT_CONFIGURED( + {}, + undefined, + { cause } + ); + } + options.push({ key: provider.slug, displayName: provider.displayName }); + } + + return options.sort((left, right) => + left.displayName.localeCompare(right.displayName) || + left.key.localeCompare(right.key) + ); +}; + +const loadProviderDisplayOptions = async ( + context: ConstructiveContext, + oauthEnabled: boolean +): Promise => { + if (!oauthEnabled) return []; + const providers = await context.useModule('identityProviders'); + return providerDisplayOptions(providers); +}; + +export interface UnifiedAuthService { + providers(context: UnifiedAuthGraphQLContext): Promise; + start( + context: UnifiedAuthGraphQLContext, + input: StartUnifiedLoginInput + ): Promise; + confirm( + context: UnifiedAuthGraphQLContext, + input: ContinueUnifiedLoginInput + ): Promise; + signIn( + context: UnifiedAuthGraphQLContext, + input: UnifiedPasswordInput + ): Promise; + signUp( + context: UnifiedAuthGraphQLContext, + input: UnifiedPasswordInput + ): Promise; +} + +export const createUnifiedAuthService = (oauthEnabled: boolean): UnifiedAuthService => ({ + async providers(graphQLContext) { + const context = requireContext(graphQLContext); + const surface = await context.useModule('ssoSurface'); + if (!surface) return []; + return loadProviderDisplayOptions(context, oauthEnabled); + }, + + async start(graphQLContext, input) { + validateStartInput(input); + const context = requireContext(graphQLContext); + const browserBinding = requireBrowserBinding(graphQLContext); + const surface = await resolveSsoSurface(context); + // Resolve and validate public Provider options before creating transient + // state so a malformed Tenant Provider cannot leave an unusable login + // transaction behind. + const providers = await loadProviderDisplayOptions(context, oauthEnabled); + const result = await startUnifiedLogin(context, surface, input, browserBinding); + return { ...result, providers }; + }, + + async confirm(graphQLContext, input) { + validateTransactionInput(input); + const context = requireContext(graphQLContext); + const browserBinding = requireBrowserBinding(graphQLContext); + const surface = await resolveSsoSurface(context); + if (!context.userId) throw errors.UNAUTHENTICATED(); + return confirmUnifiedLogin(context, surface, input, browserBinding); + }, + + async signIn(graphQLContext, input) { + validateTransactionInput(input); + const context = requireContext(graphQLContext); + const browserBinding = requireBrowserBinding(graphQLContext); + const surface = await resolveSsoSurface(context); + return signInUnifiedLogin(context, surface, input, browserBinding); + }, + + async signUp(graphQLContext, input) { + validateTransactionInput(input); + const context = requireContext(graphQLContext); + const browserBinding = requireBrowserBinding(graphQLContext); + const surface = await resolveSsoSurface(context); + return signUpUnifiedLogin(context, surface, input, browserBinding); + } +}); diff --git a/graphql/server/src/auth/sso/types.ts b/graphql/server/src/auth/sso/types.ts new file mode 100644 index 0000000000..a5166c5692 --- /dev/null +++ b/graphql/server/src/auth/sso/types.ts @@ -0,0 +1,68 @@ +import type { ConstructiveContext } from '@constructive-io/express-context'; + +export interface UnifiedAuthGraphQLContext { + constructive?: ConstructiveContext; + /** Server-read authentication-center first-party browser binding. */ + browserBinding?: string; +} + +export interface ProviderDisplayOption { + key: string; + displayName: string; +} + +export interface StartUnifiedLoginInput { + siteId: string; + callbackUrl?: string | null; + returnTo?: string | null; + siteState: string; +} + +export interface ContinueUnifiedLoginInput { + transactionId: string; +} + +export interface UnifiedPasswordInput extends ContinueUnifiedLoginInput { + email: string; + password: string; + rememberMe?: boolean | null; + deviceToken?: string | null; +} + +export interface UnifiedAuthSite { + id: string; + displayName: string; + iconUrl: string | null; + themeColor: string | null; +} + +export interface UnifiedAuthAccount { + id: string; + displayName: string; + avatarUrl: string | null; +} + +export interface StartUnifiedLoginPayload { + transactionId: string; + site: UnifiedAuthSite; + signInMode: 'CONFIRM_BEFORE_SIGN_IN' | 'SILENT'; + reusableAuthentication: boolean; + currentAccount: UnifiedAuthAccount | null; + providers: ProviderDisplayOption[]; +} + +export interface UnifiedLoginContinuationPayload { + transactionId: string; + authenticated: true; + continuationUrl: string | null; +} + +export interface UnifiedLoginCredentialPayload + extends UnifiedLoginContinuationPayload { + credentialId: string; + userId: string; + accessToken: string; + accessTokenExpiresAt: string; + isVerified: boolean; + totpEnabled: boolean; +} diff --git a/graphql/server/src/middleware/__tests__/grafast-context.test.ts b/graphql/server/src/middleware/__tests__/grafast-context.test.ts new file mode 100644 index 0000000000..127a7cdf25 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/grafast-context.test.ts @@ -0,0 +1,32 @@ +import type { ConstructiveContext } from '@constructive-io/express-context'; +import type { Request } from 'express'; + +import { createGrafastRequestContext } from '../grafast-context'; + +describe('createGrafastRequestContext', () => { + it('forwards the exact request Constructive Context object', () => { + const constructive = { requestId: 'request-1' } as ConstructiveContext; + const request = { + constructive, + cookies: { csrf_token: 'browser-binding' } + } as unknown as Request; + const pgSettings = { role: 'anonymous' }; + + const context = createGrafastRequestContext(request, pgSettings); + + expect(context.constructive).toBe(constructive); + expect(context.pgSettings).toBe(pgSettings); + expect(context.browserBinding).toBe('browser-binding'); + }); + + it('does not invent a context when Express did not build one', () => { + expect(createGrafastRequestContext(undefined, {})).toEqual({ pgSettings: {} }); + }); + + it('does not accept a non-string browser binding', () => { + const request = { + cookies: { csrf_token: ['not', 'a', 'token'] } + } as unknown as Request; + expect(createGrafastRequestContext(request, {})).toEqual({ pgSettings: {} }); + }); +}); diff --git a/graphql/server/src/middleware/grafast-context.ts b/graphql/server/src/middleware/grafast-context.ts new file mode 100644 index 0000000000..d7140699c1 --- /dev/null +++ b/graphql/server/src/middleware/grafast-context.ts @@ -0,0 +1,17 @@ +import { DEFAULT_CSRF_COOKIE_NAME } from '@constructive-io/csrf'; +import type { Request } from 'express'; + +/** + * Forward the exact request-owned Constructive Context into Graphile. + * Resolvers must not reconstruct Tenant, route, session, or loader state. + */ +export const createGrafastRequestContext = ( + req: Request | undefined, + pgSettings: Record +): Record => ({ + pgSettings, + ...(req?.constructive ? { constructive: req.constructive } : {}), + ...(typeof req?.cookies?.[DEFAULT_CSRF_COOKIE_NAME] === 'string' + ? { browserBinding: req.cookies[DEFAULT_CSRF_COOKIE_NAME] } + : {}) +}); diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index e09e5189f0..781cc8e15c 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -16,11 +16,13 @@ import { createConstructivePreset, makePgService } from 'graphile-settings'; import { getPgPool } from 'pg-cache'; import { getPgEnvOptions } from 'pg-env'; +import { createUnifiedAuthPlugin } from '../auth/sso'; import { isGraphqlObservabilityEnabled } from '../diagnostics/observability'; import { HandlerCreationError } from '../errors/api-errors'; import { respondWithGraphQLError } from '../errors/graphql-response'; import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin'; import type { DatabaseSettings } from '../types'; +import { createGrafastRequestContext } from './grafast-context'; import { observeGraphileBuild } from './observability/graphile-build-stats'; const maskErrorLog = new Logger('graphile:maskError'); @@ -167,12 +169,14 @@ const buildPreset = ( roleName: string, databaseSettings?: DatabaseSettings, apiId?: string, - compute?: ComputeConfig + compute?: ComputeConfig, + oauthEnabled = false ): GraphileConfig.Preset => { return { extends: [createConstructivePreset(databaseSettings)], plugins: [ AuthCookiePlugin, + createUnifiedAuthPlugin(oauthEnabled), // Only registered when the compute module is provisioned for this // database — all schema/table names come from the constructive // metaschema (express-context compute module loader); the plugin has @@ -275,7 +279,7 @@ const buildPreset = ( pgSettings['request.id'] = req.requestId; } - return { pgSettings }; + return createGrafastRequestContext(req, pgSettings); } // Private (in-cluster) surface: there is no token — identity @@ -304,7 +308,7 @@ const buildPreset = ( if (req.requestId) { pgSettings['request.id'] = req.requestId; } - return { pgSettings }; + return createGrafastRequestContext(req, pgSettings); } } @@ -316,9 +320,7 @@ const buildPreset = ( anonSettings['request.id'] = req.requestId; } - return { - pgSettings: anonSettings - }; + return createGrafastRequestContext(req, anonSettings); } } }; @@ -408,7 +410,16 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => { // Create promise and store in in-flight map BEFORE try block const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined; - const preset = buildPreset(pool, schema || [], anonRole, roleName, api.databaseSettings, api.apiId, compute); + const preset = buildPreset( + pool, + schema || [], + anonRole, + roleName, + api.databaseSettings, + api.apiId, + compute, + opts.oauth?.enabled ?? false + ); const creationPromise = observeGraphileBuild( { cacheKey: key, diff --git a/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts b/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts index f4f3ba25bd..057d2e4b27 100644 --- a/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts +++ b/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts @@ -1,4 +1,9 @@ import { DEVICE_TOKEN_COOKIE_NAME,SESSION_COOKIE_NAME } from '../../middleware/cookie'; +import { + AuthCookiePlugin, + extractMutationFields, + hasRememberMe +} from '../auth-cookie-plugin'; /** * Since the AuthCookiePlugin is a grafserv middleware plugin, we test @@ -6,32 +11,6 @@ import { DEVICE_TOKEN_COOKIE_NAME,SESSION_COOKIE_NAME } from '../../middleware/c * Full integration tests would require a running PostGraphile instance. */ -// Re-implement the testable functions here for unit testing -// (In a real codebase, these would be exported from a shared module) - -const extractMutationNames = (query: string): string[] => { - const mutations: string[] = []; - - if (!/^\s*mutation\b/i.test(query)) { - return mutations; - } - - const bodyStart = query.indexOf('{'); - if (bodyStart === -1) return mutations; - - const bodyContent = query.slice(bodyStart + 1); - const fieldPattern = /(\w+)\s*(?:\(|{)/g; - let match; - while ((match = fieldPattern.exec(bodyContent)) !== null) { - const name = match[1]; - if (name !== 'mutation' && name !== 'query' && name !== 'fragment') { - mutations.push(name); - } - } - - return mutations; -}; - const extractAccessToken = ( data: Record, mutationName: string @@ -70,11 +49,6 @@ const extractDeviceId = ( return undefined; }; -const hasRememberMe = (variables?: Record): boolean => { - if (!variables) return false; - return variables.rememberMe === true || variables.remember_me === true; -}; - interface CookieConfig { secure: boolean; sameSite: 'strict' | 'lax' | 'none'; @@ -131,25 +105,42 @@ const serializeClearCookie = (name: string, config: CookieConfig): string => { }; describe('AuthCookiePlugin utilities', () => { - describe('extractMutationNames', () => { + describe('extractMutationFields', () => { it('extracts mutation names from query', () => { const query = 'mutation { signIn(email: "test@example.com") { accessToken } }'; - expect(extractMutationNames(query)).toEqual(['signIn']); + expect(extractMutationFields(query)).toEqual([ + { fieldName: 'signIn', responseKey: 'signIn' } + ]); }); it('extracts multiple mutation names', () => { const query = 'mutation { signIn(email: "test") { token } signUp(email: "new") { token } }'; - expect(extractMutationNames(query)).toEqual(['signIn', 'signUp']); + expect(extractMutationFields(query)).toEqual([ + { fieldName: 'signIn', responseKey: 'signIn' }, + { fieldName: 'signUp', responseKey: 'signUp' } + ]); }); it('returns empty array for non-mutation queries', () => { const query = 'query { users { id } }'; - expect(extractMutationNames(query)).toEqual([]); + expect(extractMutationFields(query)).toEqual([]); }); it('handles mutations with no arguments', () => { const query = 'mutation { signOut { success } }'; - expect(extractMutationNames(query)).toEqual(['signOut']); + expect(extractMutationFields(query)).toEqual([ + { fieldName: 'signOut', responseKey: 'signOut' } + ]); + }); + + it('selects a named operation and preserves aliases', () => { + const query = ` + mutation Ignore { signOut { success } } + mutation Unified { auth: signInUnifiedLogin(input: $input) { accessToken } } + `; + expect(extractMutationFields(query, 'Unified')).toEqual([ + { fieldName: 'signInUnifiedLogin', responseKey: 'auth' } + ]); }); }); @@ -211,6 +202,10 @@ describe('AuthCookiePlugin utilities', () => { expect(hasRememberMe({ remember_me: true })).toBe(true); }); + it('detects rememberMe inside an input object', () => { + expect(hasRememberMe({ input: { rememberMe: true } })).toBe(true); + }); + it('returns false when not present', () => { expect(hasRememberMe({})).toBe(false); }); @@ -299,6 +294,73 @@ describe('AuthCookiePlugin utilities', () => { }); }); +describe('AuthCookiePlugin unified-auth cookie boundary', () => { + it('sets an aliased unified-login result as a host-only first-party cookie', async () => { + const setHeader = jest.fn(); + const getHeader = jest.fn(); + const query = ` + mutation Unified($input: UnifiedPasswordInput!) { + auth: signInUnifiedLogin(input: $input) { accessToken } + } + `; + const processRequest = AuthCookiePlugin.grafserv?.middleware?.processRequest; + const callback = typeof processRequest === 'function' + ? processRequest + : processRequest?.callback; + expect(callback).toBeDefined(); + + const next = Object.assign( + async () => ({ + type: 'buffer' as const, + statusCode: 200, + headers: { 'content-type': 'application/json' }, + buffer: Buffer.from(JSON.stringify({ + data: { auth: { accessToken: 'cnc_live_bt_secret' } } + })) + }), + { callback: jest.fn() } + ); + + await callback!( + next, + { + requestDigest: { + method: 'POST', + getBody: async () => ({ + type: 'buffer', + buffer: Buffer.from(JSON.stringify({ + query, + operationName: 'Unified', + variables: { input: { rememberMe: true } } + })) + }), + requestContext: { + expressv4: { + req: { + api: { + authSettings: { + cookieDomain: '.example.com', + cookieSecure: false, + cookieHttponly: false, + cookieSamesite: 'lax' + } + }, + res: { setHeader, getHeader } + } + } + } + } + } as never + ); + + const cookie = (setHeader.mock.calls[0][1] as string[])[0]; + expect(cookie).toContain('constructive_session=cnc_live_bt_secret'); + expect(cookie).toContain('Secure'); + expect(cookie).toContain('HttpOnly'); + expect(cookie).not.toContain('Domain='); + }); +}); + /** * P0 Tests: Auth failure scenarios, multiple mutations, cookie clearing */ diff --git a/graphql/server/src/plugins/auth-cookie-plugin.ts b/graphql/server/src/plugins/auth-cookie-plugin.ts index 6c5a7bce0b..545d346c5a 100644 --- a/graphql/server/src/plugins/auth-cookie-plugin.ts +++ b/graphql/server/src/plugins/auth-cookie-plugin.ts @@ -4,6 +4,12 @@ import { Logger } from '@pgpmjs/logger'; import type { Request } from 'express'; import type { BufferResult } from 'grafserv'; import type { GraphileConfig } from 'graphile-config'; +import { + type FragmentDefinitionNode, + Kind, + parse, + type SelectionSetNode +} from 'graphql'; import { CookieConfig, @@ -73,6 +79,8 @@ const serializeClearCookie = (name: string, config: CookieConfig): string => { const SIGN_IN_MUTATIONS = new Set([ 'signIn', 'signUp', + 'signInUnifiedLogin', + 'signUpUnifiedLogin', 'signInSso', 'signUpSso', 'signInMagicLink', @@ -85,6 +93,11 @@ const SIGN_IN_MUTATIONS = new Set([ 'signInCrossOrigin', ]); +const UNIFIED_AUTH_SIGN_IN_MUTATIONS = new Set([ + 'signInUnifiedLogin', + 'signUpUnifiedLogin' +]); + /** * Auth mutations that should clear the session cookie. */ @@ -105,30 +118,65 @@ interface GraphQLResponse { errors?: Array<{ message: string; extensions?: { code?: string } }>; } -/** - * Extract mutation names from a GraphQL query string. - */ -const extractMutationNames = (query: string): string[] => { - const mutations: string[] = []; - - if (!/^\s*mutation\b/i.test(query)) { - return mutations; - } - - const bodyStart = query.indexOf('{'); - if (bodyStart === -1) return mutations; +export interface MutationField { + fieldName: string; + responseKey: string; +} - const bodyContent = query.slice(bodyStart + 1); - const fieldPattern = /(\w+)\s*(?:\(|{)/g; - let match; - while ((match = fieldPattern.exec(bodyContent)) !== null) { - const name = match[1]; - if (name !== 'mutation' && name !== 'query' && name !== 'fragment') { - mutations.push(name); +const collectMutationFields = ( + selectionSet: SelectionSetNode, + fragments: ReadonlyMap, + visited: Set +): MutationField[] => { + const fields: MutationField[] = []; + for (const selection of selectionSet.selections) { + if (selection.kind === Kind.FIELD) { + fields.push({ + fieldName: selection.name.value, + responseKey: selection.alias?.value ?? selection.name.value + }); + continue; + } + if (selection.kind === Kind.INLINE_FRAGMENT) { + fields.push(...collectMutationFields(selection.selectionSet, fragments, visited)); + continue; + } + if (!visited.has(selection.name.value)) { + const fragment = fragments.get(selection.name.value); + if (fragment) { + visited.add(selection.name.value); + fields.push(...collectMutationFields(fragment.selectionSet, fragments, visited)); + } } } + return fields; +}; - return mutations; +/** Parse the selected operation and preserve aliases used as response keys. */ +export const extractMutationFields = ( + query: string, + operationName?: string +): MutationField[] => { + const document = parse(query); + const operations = document.definitions.filter( + definition => definition.kind === Kind.OPERATION_DEFINITION + ); + const operation = operationName + ? operations.find(definition => definition.name?.value === operationName) + : operations.length === 1 + ? operations[0] + : undefined; + if (!operation || operation.operation !== 'mutation') return []; + + const fragments = new Map( + document.definitions + .filter( + (definition): definition is FragmentDefinitionNode => + definition.kind === Kind.FRAGMENT_DEFINITION + ) + .map(fragment => [fragment.name.value, fragment]) + ); + return collectMutationFields(operation.selectionSet, fragments, new Set()); }; /** @@ -179,9 +227,17 @@ const extractDeviceId = ( /** * Check if request includes remember_me flag. */ -const hasRememberMe = (variables?: Record): boolean => { +export const hasRememberMe = (variables?: Record): boolean => { if (!variables) return false; - return variables.rememberMe === true || variables.remember_me === true; + if (variables.rememberMe === true || variables.remember_me === true) return true; + const input = variables.input; + return Boolean( + input && + typeof input === 'object' && + !Array.isArray(input) && + ((input as Record).rememberMe === true || + (input as Record).remember_me === true) + ); }; /** @@ -225,14 +281,10 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { // grafserv provides getBody() which returns { type: 'buffer', buffer: Buffer } let body: GraphQLRequestBody | undefined; if (typeof event.requestDigest.getBody === 'function') { - try { - const rawBody = await event.requestDigest.getBody() as { type?: string; buffer?: Buffer }; - if (rawBody?.type === 'buffer' && rawBody.buffer) { - const jsonStr = rawBody.buffer.toString('utf8'); - body = JSON.parse(jsonStr) as GraphQLRequestBody; - } - } catch (e) { - log.debug('[auth-cookie] Failed to parse body from requestDigest'); + const rawBody = await event.requestDigest.getBody() as { type?: string; buffer?: Buffer }; + if (rawBody?.type === 'buffer' && rawBody.buffer) { + const jsonStr = rawBody.buffer.toString('utf8'); + body = JSON.parse(jsonStr) as GraphQLRequestBody; } } body = body || (req.body as GraphQLRequestBody); @@ -241,99 +293,124 @@ export const AuthCookiePlugin: GraphileConfig.Plugin = { } // Extract mutation names - const mutationNames = extractMutationNames(body.query); - if (mutationNames.length === 0) { + const mutationFields = extractMutationFields(body.query, body.operationName); + if (mutationFields.length === 0) { return result; } // Check for auth mutations - const signInMutation = mutationNames.find((m) => SIGN_IN_MUTATIONS.has(m)); - const signOutMutation = mutationNames.find((m) => SIGN_OUT_MUTATIONS.has(m)); + const signInMutation = mutationFields.find(field => + SIGN_IN_MUTATIONS.has(field.fieldName) + ); + const signOutMutation = mutationFields.find(field => + SIGN_OUT_MUTATIONS.has(field.fieldName) + ); if (!signInMutation && !signOutMutation) { return result; } - log.debug(`[auth-cookie] Detected auth mutation: ${signInMutation || signOutMutation}`); + log.debug( + `[auth-cookie] Detected auth mutation: ${ + signInMutation?.fieldName ?? signOutMutation?.fieldName + }` + ); - try { - // Parse response body - const payload = bufferResult.buffer.toString('utf8'); - const graphqlResponse = JSON.parse(payload) as GraphQLResponse; + // Parse response body. Failures deliberately propagate; a logging or + // cookie fallback cannot replace the authentication result semantics. + const payload = bufferResult.buffer.toString('utf8'); + const graphqlResponse = JSON.parse(payload) as GraphQLResponse; - // Skip if there are GraphQL errors - if (graphqlResponse.errors?.length || !graphqlResponse.data) { - return result; - } + // Skip if there are GraphQL errors + if (graphqlResponse.errors?.length || !graphqlResponse.data) { + return result; + } - const data = graphqlResponse.data; - const authSettings = req.api?.authSettings; - const cookiesToSet: string[] = []; - - // Handle sign-out mutations - if (signOutMutation && data[signOutMutation]) { - log.info('[auth-cookie] Sign-out mutation succeeded, clearing session cookie'); - const config = getSessionCookieConfig(authSettings); - cookiesToSet.push(serializeClearCookie(SESSION_COOKIE_NAME, config)); - // Also clear device token on sign-out - const deviceConfig = getDeviceTokenCookieConfig(authSettings); - cookiesToSet.push(serializeClearCookie(DEVICE_TOKEN_COOKIE_NAME, deviceConfig)); - } + const data = graphqlResponse.data; + const authSettings = req.api?.authSettings; + const cookiesToSet: string[] = []; + + // Handle sign-out mutations + if (signOutMutation && data[signOutMutation.responseKey]) { + log.info('[auth-cookie] Sign-out mutation succeeded, clearing session cookie'); + const config = getSessionCookieConfig(authSettings); + cookiesToSet.push(serializeClearCookie(SESSION_COOKIE_NAME, config)); + // Also clear device token on sign-out + const deviceConfig = getDeviceTokenCookieConfig(authSettings); + cookiesToSet.push(serializeClearCookie(DEVICE_TOKEN_COOKIE_NAME, deviceConfig)); + } - // Handle sign-in mutations - if (signInMutation) { - const accessToken = extractAccessToken(data, signInMutation); - if (accessToken) { - const rememberMe = hasRememberMe(body.variables); - const config = getSessionCookieConfig(authSettings, rememberMe); - log.info(`[auth-cookie] Sign-in mutation succeeded, setting session cookie (rememberMe=${rememberMe})`); - cookiesToSet.push(serializeCookie(SESSION_COOKIE_NAME, accessToken, config)); - - const deviceId = extractDeviceId(data, signInMutation); - if (deviceId) { - log.info('[auth-cookie] Device ID returned, setting device token cookie'); - const deviceConfig = getDeviceTokenCookieConfig(authSettings); - cookiesToSet.push(serializeCookie(DEVICE_TOKEN_COOKIE_NAME, deviceId, deviceConfig)); + // Handle sign-in mutations + if (signInMutation) { + const accessToken = extractAccessToken(data, signInMutation.responseKey); + if (accessToken) { + const rememberMe = hasRememberMe(body.variables); + const baseConfig = getSessionCookieConfig(authSettings, rememberMe); + // The Tenant auth-center credential is first party and host only. + // A Site receives its own credential during handoff redemption. + const config = UNIFIED_AUTH_SIGN_IN_MUTATIONS.has(signInMutation.fieldName) + ? { + ...baseConfig, + domain: undefined, + httpOnly: true, + secure: true } + : baseConfig; + log.info(`[auth-cookie] Sign-in mutation succeeded, setting session cookie (rememberMe=${rememberMe})`); + cookiesToSet.push(serializeCookie(SESSION_COOKIE_NAME, accessToken, config)); + + const deviceId = extractDeviceId(data, signInMutation.responseKey); + if (deviceId) { + log.info('[auth-cookie] Device ID returned, setting device token cookie'); + const deviceConfig = getDeviceTokenCookieConfig(authSettings); + cookiesToSet.push(serializeCookie(DEVICE_TOKEN_COOKIE_NAME, deviceId, deviceConfig)); } } + } - // Set cookies directly on Express response and return modified headers - if (cookiesToSet.length > 0) { - const res = (event.requestDigest.requestContext as { expressv4?: { res?: { setHeader: (name: string, value: string[]) => void; getHeader: (name: string) => string | string[] | undefined } } })?.expressv4?.res; - - if (res?.setHeader) { - // Get existing Set-Cookie headers from Express response - const existingCookies = res.getHeader('Set-Cookie'); - const allCookies: string[] = []; - - if (existingCookies) { - if (Array.isArray(existingCookies)) { - allCookies.push(...existingCookies); - } else { - allCookies.push(existingCookies); - } + // 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'); + const allCookies: string[] = []; + + if (existingCookies) { + if (Array.isArray(existingCookies)) { + allCookies.push(...existingCookies); + } else if (typeof existingCookies === 'string') { + allCookies.push(existingCookies); } - allCookies.push(...cookiesToSet); - - // Set as array to get multiple Set-Cookie headers - res.setHeader('Set-Cookie', allCookies); } + allCookies.push(...cookiesToSet); + + // Set as array to get multiple Set-Cookie headers + res.setHeader('Set-Cookie', allCookies); + } - // Also update the BufferResult headers for grafserv to pass through - const existingBufferCookie = bufferResult.headers['set-cookie']; - const updatedHeaders = { ...bufferResult.headers }; + // Also update the BufferResult headers for grafserv to pass through + const updatedHeaders = { ...bufferResult.headers }; - // Remove set-cookie from grafserv headers since we set it on Express - delete updatedHeaders['set-cookie']; + // Remove set-cookie from grafserv headers since we set it on Express + delete updatedHeaders['set-cookie']; - return { - ...bufferResult, - headers: updatedHeaders, - }; - } - } catch (err) { - log.error('[auth-cookie] Error processing auth response:', err); + return { + ...bufferResult, + headers: updatedHeaders, + }; } return result; diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 8ddd11c483..20eb62d20d 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -1,5 +1,11 @@ import { createCsrfMiddleware } from '@constructive-io/csrf'; -import { createContextMiddleware, createDefaultRegistry, requestIdMiddleware } from '@constructive-io/express-context'; +import { + createContextMiddleware, + createDefaultRegistry, + identityProvidersLoader, + requestIdMiddleware, + ssoSurfaceLoader +} from '@constructive-io/express-context'; import { getEnvOptions } from '@constructive-io/graphql-env'; import type { ConstructiveOptions } from '@constructive-io/graphql-types'; import { middleware as parseDomains } from '@constructive-io/url-domains'; @@ -93,6 +99,11 @@ class Server { const api = createApiMiddleware(effectiveOpts); const authenticate = createAuthenticateMiddleware(effectiveOpts); const requestLogger = createRequestLogger({ observabilityEnabled }); + const contextLoaders = createDefaultRegistry(); + contextLoaders.register(ssoSurfaceLoader); + if (effectiveOpts.oauth?.enabled) { + contextLoaders.register(identityProvidersLoader); + } // Log startup configuration (non-sensitive values only) const apiOpts = (effectiveOpts as any).api || {}; @@ -165,7 +176,7 @@ class Server { app.use(authenticate); app.use(createContextMiddleware({ pg: effectiveOpts.pg, - loaders: createDefaultRegistry(), + loaders: contextLoaders, routingSchema: getRoutingSchema(effectiveOpts) })); app.use(createCaptchaMiddleware()); diff --git a/packages/csrf/src/index.ts b/packages/csrf/src/index.ts index 5be66dcd2e..735c7d5da5 100644 --- a/packages/csrf/src/index.ts +++ b/packages/csrf/src/index.ts @@ -4,6 +4,7 @@ export { CsrfMiddlewareResult, CsrfRequest, CsrfResponse, + DEFAULT_CSRF_COOKIE_NAME, } from './middleware'; export { generateToken, verifyToken } from './token'; export { CookieOptions, createCsrfError,CsrfConfig, CsrfError } from './types'; diff --git a/packages/csrf/src/middleware.ts b/packages/csrf/src/middleware.ts index c8dbe74fca..772b9d725d 100644 --- a/packages/csrf/src/middleware.ts +++ b/packages/csrf/src/middleware.ts @@ -1,8 +1,10 @@ import { generateToken, verifyToken } from './token'; import { CookieOptions, createCsrfError,CsrfConfig } from './types'; +export const DEFAULT_CSRF_COOKIE_NAME = 'csrf_token'; + const DEFAULT_CONFIG: Required = { - cookieName: 'csrf_token', + cookieName: DEFAULT_CSRF_COOKIE_NAME, headerName: 'x-csrf-token', fieldName: '_csrf', cookieOptions: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3a8802ef0..e316102d2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1960,6 +1960,9 @@ importers: '@constructive-io/llm-env': specifier: workspace:^ version: link:../../packages/llm-env/dist + '@constructive-io/oauth': + specifier: workspace:^ + version: link:../../packages/oauth/dist '@constructive-io/query-builder': specifier: workspace:^ version: link:../../postgres/query-builder/dist