diff --git a/graphql/server-test/__fixtures__/seed/oauth-sso/real-runtime.ts b/graphql/server-test/__fixtures__/seed/oauth-sso/real-runtime.ts new file mode 100644 index 0000000000..f6b7a5234f --- /dev/null +++ b/graphql/server-test/__fixtures__/seed/oauth-sso/real-runtime.ts @@ -0,0 +1,430 @@ +import { createHash } from 'node:crypto'; + +import type { SeedAdapter, SeedContext } from 'pgsql-test/seed/types'; + +export const REAL_RUNTIME_FIXTURE = { + ownerId: 'f0000000-0000-4000-8000-000000000001', + siteId: 'f1000000-0000-4000-8000-000000000001', + runtimeBucketId: 'f1100000-0000-4000-8000-000000000001', + serviceUserId: 'f2000000-0000-4000-8000-000000000001', + serviceSessionId: 'f3000000-0000-4000-8000-000000000001', + serviceCredentialId: 'f4000000-0000-4000-8000-000000000001', + servicePrincipalId: 'f5000000-0000-4000-8000-000000000001', + serviceApiKey: 'cnc_live_bt_sso_site_runtime_fixture', + authHost: 'auth-auth-sso-e2e.test.constructive.io', + siteHost: 'api-auth-sso-e2e.test.constructive.io' +} as const; + +const modules = [ + 'users_module', + 'membership_types_module', + ['capabilities_module', { scope: 'app' }], + ['limits_module', { scope: 'app' }], + ['levels_module', { scope: 'app' }], + ['memberships_module', { scope: 'app' }], + ['capabilities_module', { scope: 'org' }], + ['limits_module', { scope: 'org' }], + ['memberships_module', { scope: 'org' }], + 'sessions_module', + 'user_state_module', + 'user_credentials_module', + ['internal_secrets_module', { scope: 'app' }], + ['internal_secrets_module', { scope: 'database' }], + 'emails_module', + 'rls_module', + 'connected_accounts_module', + ['identity_providers_module', { scope: 'database' }], + 'user_auth_module', + [ + 'catalog_module', + { scope: 'database', public_schema_name: 'catalog_private', policies: [] } + ], + [ + 'site_surface_module', + { + scope: 'database', + prefix: '', + public_schema_name: 'routing_public', + policies: [] + } + ], + ['oauth_requests_module', { scope: 'database', prefix: '' }], + ['unified_auth_module', { scope: 'database', prefix: '' }] +] as const; + +const quoteIdentifier = (value: string): string => + `"${value.replaceAll('"', '""')}"`; + +const relation = (schema: string, table: string): string => + `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`; + +const schemaName = async (ctx: SeedContext, schemaId: string): Promise => { + const row = await ctx.pg.one<{ schema_name: string }>( + 'SELECT schema_name FROM metaschema_public.schema WHERE id = $1', + [schemaId] + ); + return row.schema_name; +}; + +const tableName = async (ctx: SeedContext, tableId: string): Promise => { + const row = await ctx.pg.one<{ name: string }>( + 'SELECT name FROM metaschema_public.table WHERE id = $1', + [tableId] + ); + return row.name; +}; + +const hasColumn = async ( + ctx: SeedContext, + schema: string, + table: string, + column: string +): Promise => { + const row = await ctx.pg.one<{ present: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 AND column_name = $3 + ) AS present`, + [schema, table, column] + ); + return row.present; +}; + +/** + * Provision only test data around the real generated Constructive DB runtime. + * No SSO table or function is reproduced here. + */ +export const seedRealUnifiedAuthRuntime = (): SeedAdapter => ({ + async seed(ctx) { + await ctx.pg.any( + `INSERT INTO constructive_users_public.users (id, username) + VALUES ($1, 'sso_e2e_owner') + ON CONFLICT (id) DO NOTHING`, + [REAL_RUNTIME_FIXTURE.ownerId] + ); + + await ctx.pg.any("SET constructive.allow_super_constructive = 'true'"); + const provisioned = await ctx.pg.one<{ database_id: string }>( + `SELECT metaschema_generators.provision_database( + v_database_name := 'auth-sso-e2e', + v_owner_id := $1, + v_subdomain := 'auth-sso-e2e', + v_domain := 'test.constructive.io', + v_modules := $2::jsonb, + v_options := '{}'::jsonb + ) AS database_id`, + [REAL_RUNTIME_FIXTURE.ownerId, JSON.stringify(modules)] + ); + await ctx.pg.any('RESET constructive.allow_super_constructive'); + const databaseId = provisioned.database_id; + + const siteModule = await ctx.pg.one<{ + schema_id: string; + sites_table_id: string; + }>( + `SELECT schema_id, sites_table_id + FROM metaschema_modules_public.site_surface_module + WHERE database_id = $1 AND scope = 'database'`, + [databaseId] + ); + const catalogModule = await ctx.pg.one<{ + schema_id: string; + buckets_table_id: string; + }>( + `SELECT schema_id, buckets_table_id + FROM metaschema_modules_public.catalog_module + WHERE database_id = $1 AND scope = 'database'`, + [databaseId] + ); + const unifiedModule = await ctx.pg.one<{ + private_schema_id: string; + site_auth_callbacks_table_name: string; + site_runtime_clients_table_name: string; + }>( + `SELECT private_schema_id, site_auth_callbacks_table_name, + site_runtime_clients_table_name + FROM metaschema_modules_public.unified_auth_module + WHERE database_id = $1 AND scope = 'database'`, + [databaseId] + ); + const sessionsModule = await ctx.pg.one<{ + schema_id: string; + sessions_table_id: string; + session_credentials_table_id: string; + auth_settings_table_id: string; + }>( + `SELECT schema_id, sessions_table_id, session_credentials_table_id, + auth_settings_table_id + FROM metaschema_modules_public.sessions_module + WHERE database_id = $1`, + [databaseId] + ); + const usersModule = await ctx.pg.one<{ + schema_id: string; + table_id: string; + }>( + `SELECT schema_id, table_id + FROM metaschema_modules_public.users_module + WHERE database_id = $1`, + [databaseId] + ); + const providersModule = await ctx.pg.one<{ + private_schema_id: string; + table_name: string; + }>( + `SELECT private_schema_id, table_name + FROM metaschema_modules_public.identity_providers_module + WHERE database_id = $1`, + [databaseId] + ); + const secretsModule = await ctx.pg.one<{ + private_schema_id: string; + internal_secrets_table_name: string; + prefix: string; + }>( + `SELECT private_schema_id, internal_secrets_table_name, prefix + FROM metaschema_modules_public.internal_secrets_module + WHERE database_id = $1 AND scope = 'database'`, + [databaseId] + ); + + const [ + siteSchema, + catalogSchema, + privateSchema, + sessionsSchema, + usersSchema, + providersSchema, + secretsPrivateSchema + ] = + await Promise.all([ + schemaName(ctx, siteModule.schema_id), + schemaName(ctx, catalogModule.schema_id), + schemaName(ctx, unifiedModule.private_schema_id), + schemaName(ctx, sessionsModule.schema_id), + schemaName(ctx, usersModule.schema_id), + schemaName(ctx, providersModule.private_schema_id), + schemaName(ctx, secretsModule.private_schema_id) + ]); + const { schema_name: secretsPublicSchema } = await ctx.pg.one<{ + schema_name: string; + }>( + `SELECT schema_name + FROM metaschema_public.schema + WHERE database_id = $1 AND schema_name LIKE '%store-public' + ORDER BY schema_name + LIMIT 1`, + [databaseId] + ); + const [sitesTable, bucketsTable, sessionsTable, credentialsTable, authSettingsTable, usersTable] = + await Promise.all([ + tableName(ctx, siteModule.sites_table_id), + tableName(ctx, catalogModule.buckets_table_id), + tableName(ctx, sessionsModule.sessions_table_id), + tableName(ctx, sessionsModule.session_credentials_table_id), + tableName(ctx, sessionsModule.auth_settings_table_id), + tableName(ctx, usersModule.table_id) + ]); + + const bucket = await ctx.pg.one<{ id: string }>( + `INSERT INTO ${relation(catalogSchema, bucketsTable)} + (owner_scope, owner_key, is_visible, database_id, key, type) + VALUES ('platform', NULL, true, $1, 'sso-e2e-site', 'public') + RETURNING id`, + [databaseId] + ); + await ctx.pg.any( + `INSERT INTO ${relation(siteSchema, sitesTable)} + (id, name, title, bucket_id, is_published, unified_auth_enabled, + unified_auth_sign_in_mode, unified_auth_sso_group_key, database_id) + VALUES ($1, 'customer-portal', 'Customer Portal', $2, true, true, + 'confirm', 'customer-apps', $3)`, + [REAL_RUNTIME_FIXTURE.siteId, bucket.id, databaseId] + ); + await ctx.pg.any( + `INSERT INTO catalog_private.buckets + (id, owner_scope, owner_key, is_visible, database_id, key, type) + VALUES ($1, 'database', $2, true, $2, 'sso-e2e-runtime', 'public')`, + [REAL_RUNTIME_FIXTURE.runtimeBucketId, databaseId] + ); + await ctx.pg.any( + `INSERT INTO routing_public.sites + (id, database_id, name, title, bucket_id, is_published) + VALUES ($1, $2, 'customer-portal-runtime', 'Customer Portal', $3, true)`, + [ + REAL_RUNTIME_FIXTURE.siteId, + databaseId, + REAL_RUNTIME_FIXTURE.runtimeBucketId + ] + ); + await ctx.pg.any( + `INSERT INTO ${relation(siteSchema, unifiedModule.site_auth_callbacks_table_name)} + (site_id, callback_url, active, database_id) + VALUES ($1, $2, true, $3)`, + [ + REAL_RUNTIME_FIXTURE.siteId, + `https://${REAL_RUNTIME_FIXTURE.siteHost}/auth/complete`, + databaseId + ] + ); + + const siteApi = await ctx.pg.one<{ id: string }>( + `SELECT id FROM routing_public.apis + WHERE database_id = $1 AND name = 'api'`, + [databaseId] + ); + await ctx.pg.any( + `INSERT INTO ${relation(siteSchema, unifiedModule.site_runtime_clients_table_name)} + (site_id, api_id, principal_id, active, database_id) + VALUES ($1, $2, $3, true, $4)`, + [ + REAL_RUNTIME_FIXTURE.siteId, + siteApi.id, + REAL_RUNTIME_FIXTURE.servicePrincipalId, + databaseId + ] + ); + await ctx.pg.any( + `UPDATE routing_public.routes + SET runtime_site_id = $1 + WHERE database_id = $2 AND target_api_id = $3`, + [REAL_RUNTIME_FIXTURE.siteId, databaseId, siteApi.id] + ); + + await ctx.pg.any( + `UPDATE ${relation(sessionsSchema, authSettingsTable)} + SET require_csrf_for_auth = false, + allow_identity_sign_in = true, + allow_identity_sign_up = true` + ); + + const secretSetFunction = `${secretsModule.prefix}_internal_secrets_set`; + await ctx.pg.any("SELECT set_config('jwt.claims.database_id', $1, false)", [ + databaseId + ]); + await ctx.pg.any( + `SELECT ${relation( + secretsPublicSchema, + secretSetFunction + )}($1, 'github/client-secret', 'github-client-secret', uuid_nil(), 'pgp')`, + [databaseId] + ); + const providerSecret = await ctx.pg.one<{ id: string }>( + `SELECT id + FROM ${relation( + secretsPrivateSchema, + secretsModule.internal_secrets_table_name + )} + WHERE name = 'github/client-secret' + AND namespace_id = uuid_nil() + AND retired_at IS NULL`, + ); + await ctx.pg.any( + `INSERT INTO ${relation(providersSchema, providersModule.table_name)} + (slug, kind, display_name, enabled, client_id, client_secret_id, + authorization_url, token_url, userinfo_url, scopes, pkce_enabled) + VALUES ('github', 'github', 'GitHub', true, 'github-client', $1, + 'https://github.com/login/oauth/authorize', + 'https://github.com/login/oauth/access_token', + 'https://api.github.com/user', + ARRAY['read:user', 'user:email'], true)`, + [providerSecret.id] + ); + + const userColumns = ['id', 'username']; + const userValues: unknown[] = [REAL_RUNTIME_FIXTURE.serviceUserId, 'sso_site_runtime']; + if (await hasColumn(ctx, usersSchema, usersTable, 'database_id')) { + userColumns.push('database_id'); + userValues.push(databaseId); + } + await ctx.pg.any( + `INSERT INTO ${relation(usersSchema, usersTable)} + (${userColumns.map(quoteIdentifier).join(', ')}) + VALUES (${userValues.map((_, index) => `$${index + 1}`).join(', ')})`, + userValues + ); + + const sessionColumns = [ + 'id', + 'user_id', + 'is_anonymous', + 'expires_at', + 'csrf_secret', + 'fingerprint_mode', + 'auth_method' + ]; + const sessionValues: unknown[] = [ + REAL_RUNTIME_FIXTURE.serviceSessionId, + REAL_RUNTIME_FIXTURE.serviceUserId, + false, + new Date(Date.now() + 60 * 60 * 1000), + Buffer.alloc(32, 7), + 'none', + 'api_key' + ]; + if (await hasColumn(ctx, sessionsSchema, sessionsTable, 'database_id')) { + sessionColumns.push('database_id'); + sessionValues.push(databaseId); + } + await ctx.pg.any( + `INSERT INTO ${relation(sessionsSchema, sessionsTable)} + (${sessionColumns.map(quoteIdentifier).join(', ')}) + VALUES (${sessionValues.map((_, index) => `$${index + 1}`).join(', ')})`, + sessionValues + ); + + const credentialColumns = [ + 'id', + 'session_id', + 'kind', + 'secret_hash', + 'expires_at', + 'principal_id', + 'access_level' + ]; + const credentialValues: unknown[] = [ + REAL_RUNTIME_FIXTURE.serviceCredentialId, + REAL_RUNTIME_FIXTURE.serviceSessionId, + 'api_key', + createHash('sha256').update(REAL_RUNTIME_FIXTURE.serviceApiKey).digest(), + new Date(Date.now() + 60 * 60 * 1000), + REAL_RUNTIME_FIXTURE.servicePrincipalId, + 'full_access' + ]; + if (await hasColumn(ctx, sessionsSchema, credentialsTable, 'database_id')) { + credentialColumns.push('database_id'); + credentialValues.push(databaseId); + } + await ctx.pg.any( + `INSERT INTO ${relation(sessionsSchema, credentialsTable)} + (${credentialColumns.map(quoteIdentifier).join(', ')}) + VALUES (${credentialValues.map((_, index) => `$${index + 1}`).join(', ')})`, + credentialValues + ); + + await ctx.pg.any(` + CREATE TABLE public.oauth_sso_real_runtime_fixture ( + database_id uuid PRIMARY KEY, + private_schema text NOT NULL, + sessions_schema text NOT NULL, + sessions_table text NOT NULL, + credentials_table text NOT NULL, + site_api_id uuid NOT NULL + ) + `); + await ctx.pg.any( + `INSERT INTO public.oauth_sso_real_runtime_fixture + (database_id, private_schema, sessions_schema, sessions_table, + credentials_table, site_api_id) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + databaseId, + privateSchema, + sessionsSchema, + sessionsTable, + credentialsTable, + siteApi.id + ] + ); + } +}); diff --git a/graphql/server-test/__tests__/oauth-sso.integration.test.ts b/graphql/server-test/__tests__/oauth-sso.integration.test.ts new file mode 100644 index 0000000000..1cc72cf342 --- /dev/null +++ b/graphql/server-test/__tests__/oauth-sso.integration.test.ts @@ -0,0 +1,427 @@ +import { createHash } from 'node:crypto'; + +import type { PgTestClient } from 'pgsql-test/test-client'; +import type supertest from 'supertest'; + +import { + REAL_RUNTIME_FIXTURE, + seedRealUnifiedAuthRuntime +} from '../__fixtures__/seed/oauth-sso/real-runtime'; +import { + getConnections, + getConstructiveDbApplicationPath, + seed +} from '../src'; + +jest.setTimeout(600_000); + +const constructiveDbApplicationPath = getConstructiveDbApplicationPath(); +const describeRealRuntime = constructiveDbApplicationPath ? describe : describe.skip; +const browserBinding = 'b'.repeat(43); +const siteState = 's'.repeat(43); + +const metaSchemas = [ + 'catalog_private', + 'routing_public', + 'apps_public', + 'metaschema_public', + 'metaschema_modules_public' +]; + +interface RuntimeMetadata { + database_id: string; + private_schema: string; + sessions_schema: string; + sessions_table: string; + credentials_table: string; + site_api_id: string; +} + +const quoteIdentifier = (value: string): string => + `"${value.replaceAll('"', '""')}"`; + +describeRealRuntime('OAuth/SSO generated Constructive DB integration', () => { + let request: supertest.Agent; + let pg: PgTestClient; + let teardown: () => Promise; + let runtime: RuntimeMetadata; + + const postGraphQL = ( + host: string, + query: string, + variables?: Record, + token?: string + ) => { + const pending = request + .post('/graphql') + .set('Host', host) + .set('X-Forwarded-Proto', 'https') + .set('Cookie', `csrf_token=${browserBinding}`); + if (token) pending.set('Authorization', `Bearer ${token}`); + return pending.send({ query, variables }); + }; + + const startLogin = (token?: string) => postGraphQL( + REAL_RUNTIME_FIXTURE.authHost, + `mutation Start($input: StartUnifiedLoginInput!) { + startUnifiedLogin(input: $input) { + transactionId + reusableAuthentication + currentAccount { id displayName } + site { id displayName themeColor } + providers { key } + } + }`, + { + input: { + siteId: REAL_RUNTIME_FIXTURE.siteId, + returnTo: '/approvals/42', + siteState + } + }, + token + ); + + beforeAll(async () => { + if (!constructiveDbApplicationPath) { + throw new Error('The real Constructive DB application path is required.'); + } + ({ request, pg, teardown } = await getConnections( + { + schemas: ['constructive_public'], + authRole: 'anonymous', + server: { + useRouting: true, + trustProxy: true, + oauth: { + enabled: true, + providerRequestTimeoutMs: 2_000 + }, + api: { + isPublic: true, + metaSchemas + } + } + }, + [ + seed.pgpm(constructiveDbApplicationPath), + seedRealUnifiedAuthRuntime() + ] + )); + runtime = await pg.one( + 'SELECT * FROM public.oauth_sso_real_runtime_fixture' + ); + }); + + afterAll(async () => teardown()); + + it('routes the auth center without Site identity and the Site with trusted runtime_site_id', async () => { + const rows = await pg.any<{ + hostname: string; + runtime_site_id: string | null; + }>( + `SELECT $1::text AS hostname, runtime_site_id + FROM routing_public.resolve_route($1, '/', NULL) + UNION ALL + SELECT $2::text AS hostname, runtime_site_id + FROM routing_public.resolve_route($2, '/', NULL)`, + [REAL_RUNTIME_FIXTURE.authHost, REAL_RUNTIME_FIXTURE.siteHost] + ); + expect(rows).toEqual([ + { hostname: REAL_RUNTIME_FIXTURE.authHost, runtime_site_id: null }, + { + hostname: REAL_RUNTIME_FIXTURE.siteHost, + runtime_site_id: REAL_RUNTIME_FIXTURE.siteId + } + ]); + + const unknownHost = await postGraphQL( + 'unknown.example.test', + `mutation Start($input: StartUnifiedLoginInput!) { + startUnifiedLogin(input: $input) { transactionId } + }`, + { input: { siteId: REAL_RUNTIME_FIXTURE.siteId, siteState } } + ); + expect(unknownHost.status).toBe(404); + }); + + it('runs signup, reusable auth, handoff redemption, replay protection, and revocation end to end', async () => { + const startResponse = await startLogin(); + expect(startResponse.status).toBe(200); + expect(startResponse.body.errors).toBeUndefined(); + const transactionId = startResponse.body.data.startUnifiedLogin.transactionId as string; + expect(transactionId).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(startResponse.body.data.startUnifiedLogin).toMatchObject({ + reusableAuthentication: false, + currentAccount: null, + site: { + id: REAL_RUNTIME_FIXTURE.siteId, + displayName: 'Customer Portal' + }, + providers: [{ key: 'github' }] + }); + + const transactionRows = await pg.any<{ + token_hash: Buffer; + return_to: string; + }>( + `SELECT token_hash, return_to + FROM ${quoteIdentifier(runtime.private_schema)}.unified_login_transactions` + ); + expect(transactionRows).toHaveLength(1); + expect(transactionRows[0].token_hash.toString('hex')).toBe( + createHash('sha256').update(transactionId).digest('hex') + ); + expect(transactionRows[0].return_to).toBe('/approvals/42'); + + const signup = await postGraphQL( + REAL_RUNTIME_FIXTURE.authHost, + `mutation SignUp($input: UnifiedPasswordInput!) { + signUpUnifiedLogin(input: $input) { + credentialId + userId + accessToken + continuationUrl + } + }`, + { + input: { + transactionId, + email: 'unified-user@example.com', + password: 'Str0ngP@ssword!' + } + } + ); + expect(signup.status).toBe(200); + expect(signup.body.errors).toBeUndefined(); + const central = signup.body.data.signUpUnifiedLogin as { + credentialId: string; + userId: string; + accessToken: string; + continuationUrl: string; + }; + expect(central.accessToken).toMatch(/^cnc_live_bt_/); + + const centralCookies = (signup.headers['set-cookie'] ?? []) as string[]; + expect(centralCookies).toEqual(expect.arrayContaining([ + expect.stringContaining('constructive_session=') + ])); + const centralCookie = centralCookies.find(value => + value.startsWith('constructive_session=') + ) as string; + expect(centralCookie).toContain('Secure'); + expect(centralCookie).toContain('HttpOnly'); + expect(centralCookie).not.toContain('Domain='); + + const continuation = new URL(central.continuationUrl); + const handoff = continuation.searchParams.get('handoff'); + expect(continuation.origin).toBe(`https://${REAL_RUNTIME_FIXTURE.siteHost}`); + expect(continuation.pathname).toBe('/auth/complete'); + expect(continuation.searchParams.get('site_state')).toBe(siteState); + expect(handoff).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(continuation.toString()).not.toContain(central.accessToken); + + const storedHandoff = await pg.one<{ code_hash: Buffer }>( + `SELECT code_hash + FROM ${quoteIdentifier(runtime.private_schema)}.sso_handoffs` + ); + expect(storedHandoff.code_hash.toString('hex')).toBe( + createHash('sha256').update(handoff as string).digest('hex') + ); + + const reusable = await startLogin(central.accessToken); + expect(reusable.body.errors).toBeUndefined(); + expect(reusable.body.data.startUnifiedLogin).toMatchObject({ + reusableAuthentication: true, + currentAccount: { id: central.userId } + }); + + const redeem = await postGraphQL( + REAL_RUNTIME_FIXTURE.siteHost, + `mutation Redeem($input: RedeemUnifiedLoginHandoffInput!) { + redeemUnifiedLoginHandoff(input: $input) { + credentialId + userId + accessToken + returnTo + } + }`, + { input: { handoffCode: handoff } }, + REAL_RUNTIME_FIXTURE.serviceApiKey + ); + expect(redeem.body.errors).toBeUndefined(); + const siteCredential = redeem.body.data.redeemUnifiedLoginHandoff as { + credentialId: string; + userId: string; + accessToken: string; + returnTo: string; + }; + expect(siteCredential).toMatchObject({ + userId: central.userId, + returnTo: '/approvals/42' + }); + expect(siteCredential.accessToken).toMatch(/^cnc_live_bt_/); + expect(siteCredential.accessToken).not.toBe(central.accessToken); + // Constructive returns a distinct Site credential to the authenticated Site + // server; only that Site's own callback response may write its first-party + // cookie on the Site domain. + expect(redeem.headers['set-cookie']).toBeUndefined(); + + const replay = await postGraphQL( + REAL_RUNTIME_FIXTURE.siteHost, + `mutation Redeem($input: RedeemUnifiedLoginHandoffInput!) { + redeemUnifiedLoginHandoff(input: $input) { accessToken } + }`, + { input: { handoffCode: handoff } }, + REAL_RUNTIME_FIXTURE.serviceApiKey + ); + expect(replay.body.data).toBeNull(); + expect(replay.body.errors[0].extensions.code).toBe('SSO_HANDOFF_ALREADY_USED'); + + const protectedBeforeRevocation = await postGraphQL( + REAL_RUNTIME_FIXTURE.siteHost, + 'query SiteSession { __typename }', + undefined, + siteCredential.accessToken + ); + expect(protectedBeforeRevocation.body).toEqual({ + data: { __typename: 'Query' } + }); + + const centralSession = await pg.one<{ session_id: string }>( + `SELECT session_id + FROM ${quoteIdentifier(runtime.sessions_schema)}.${quoteIdentifier(runtime.credentials_table)} + WHERE id = $1`, + [central.credentialId] + ); + await pg.any( + `UPDATE ${quoteIdentifier(runtime.sessions_schema)}.${quoteIdentifier(runtime.sessions_table)} + SET revoked_at = clock_timestamp() + WHERE id = $1`, + [centralSession.session_id] + ); + + const protectedAfterRevocation = await postGraphQL( + REAL_RUNTIME_FIXTURE.siteHost, + 'query RevokedSiteSession { __typename }', + undefined, + siteCredential.accessToken + ); + expect(protectedAfterRevocation.status).toBe(200); + expect(protectedAfterRevocation.body.data).toBeUndefined(); + expect(protectedAfterRevocation.body.errors[0].extensions.code).toBe('INVALID_TOKEN'); + }); + + it('does not allow possession-only redemption from an auth-center browser request', async () => { + const response = await postGraphQL( + REAL_RUNTIME_FIXTURE.authHost, + `mutation Redeem($input: RedeemUnifiedLoginHandoffInput!) { + redeemUnifiedLoginHandoff(input: $input) { accessToken returnTo } + }`, + { input: { handoffCode: 'h'.repeat(43) } } + ); + + expect(response.status).toBe(200); + expect(response.body.data).toBeNull(); + expect(response.body.errors[0].extensions.code).toBe('UNAUTHENTICATED'); + }); + + it('runs the GitHub Provider boundary through real DB state and the shared handoff', async () => { + const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation( + async input => { + const url = String(input); + if (url === 'https://github.com/login/oauth/access_token') { + return new Response(JSON.stringify({ access_token: 'github-token' }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + if (url === 'https://api.github.com/user') { + return new Response(JSON.stringify({ + id: 424242, + login: 'unified-provider-user', + name: 'Unified Provider User', + email: 'provider-user@example.com' + }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + } + throw new Error(`Unexpected Provider request: ${url}`); + } + ); + + const startResponse = await startLogin(); + const transactionId = startResponse.body.data.startUnifiedLogin.transactionId; + const providerStart = await postGraphQL( + REAL_RUNTIME_FIXTURE.authHost, + `mutation Provider($input: StartProviderAuthenticationInput!) { + startProviderAuthentication(input: $input) { authorizationUrl } + }`, + { input: { transactionId, providerKey: 'github' } } + ); + expect(providerStart.body.errors).toBeUndefined(); + const authorizationEntry = providerStart.body.data + .startProviderAuthentication.authorizationUrl as string; + expect(authorizationEntry).toMatch(/^\/auth\/oauth\/authorize\?state=/); + expect(authorizationEntry).not.toContain(transactionId); + + const authorize = await request + .get(authorizationEntry) + .set('Host', REAL_RUNTIME_FIXTURE.authHost) + .set('X-Forwarded-Proto', 'https') + .set('Cookie', `csrf_token=${browserBinding}`); + expect(authorize.status).toBe(303); + const providerAuthorization = new URL(authorize.headers.location); + expect(providerAuthorization.origin).toBe('https://github.com'); + expect(providerAuthorization.pathname).toBe('/login/oauth/authorize'); + expect(providerAuthorization.searchParams.get('code_challenge_method')).toBe('S256'); + expect(providerAuthorization.searchParams.get('code_challenge')).toMatch( + /^[A-Za-z0-9_-]{43}$/ + ); + const oauthState = providerAuthorization.searchParams.get('state'); + expect(oauthState).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(providerAuthorization.toString()).not.toContain(transactionId); + + const callback = await request + .get(`/auth/oauth/callback?state=${encodeURIComponent(oauthState as string)}&code=provider-code`) + .set('Host', REAL_RUNTIME_FIXTURE.authHost) + .set('X-Forwarded-Proto', 'https') + .set('Cookie', `csrf_token=${browserBinding}`); + expect(callback.status).toBe(303); + const callbackCookies = (callback.headers['set-cookie'] ?? []) as string[]; + expect(callbackCookies).toEqual(expect.arrayContaining([ + expect.stringContaining('constructive_session=') + ])); + const centralProviderToken = decodeURIComponent( + callbackCookies + .find(value => value.startsWith('constructive_session='))! + .split(';')[0] + .split('=')[1] + ); + expect(centralProviderToken).toMatch(/^cnc_live_bt_/); + + const continuation = new URL(callback.headers.location); + const handoffCode = continuation.searchParams.get('handoff'); + expect(continuation.origin).toBe(`https://${REAL_RUNTIME_FIXTURE.siteHost}`); + expect(handoffCode).toMatch(/^[A-Za-z0-9_-]{43}$/); + const redeem = await postGraphQL( + REAL_RUNTIME_FIXTURE.siteHost, + `mutation Redeem($input: RedeemUnifiedLoginHandoffInput!) { + redeemUnifiedLoginHandoff(input: $input) { userId accessToken returnTo } + }`, + { input: { handoffCode } }, + REAL_RUNTIME_FIXTURE.serviceApiKey + ); + expect(redeem.body.errors).toBeUndefined(); + expect(redeem.body.data.redeemUnifiedLoginHandoff).toMatchObject({ + returnTo: '/approvals/42', + accessToken: expect.stringMatching(/^cnc_live_bt_/) + }); + expect(redeem.body.data.redeemUnifiedLoginHandoff.accessToken) + .not.toBe(centralProviderToken); + expect(fetchMock).toHaveBeenCalledTimes(2); + + fetchMock.mockRestore(); + }); +}); diff --git a/graphql/server-test/package.json b/graphql/server-test/package.json index 84c3c4f3e3..c69bdb266e 100644 --- a/graphql/server-test/package.json +++ b/graphql/server-test/package.json @@ -29,6 +29,7 @@ "test:watch": "jest --watch" }, "devDependencies": { + "12factor-env": "workspace:^", "@0no-co/graphql.web": "^1.3.3", "@agentic-kit/ollama": "workspace:*", "@constructive-io/graphql-codegen": "workspace:^", diff --git a/graphql/server-test/src/constructive-db-runtime.ts b/graphql/server-test/src/constructive-db-runtime.ts new file mode 100644 index 0000000000..1fa4f71116 --- /dev/null +++ b/graphql/server-test/src/constructive-db-runtime.ts @@ -0,0 +1,27 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +import { cleanEnv, str, withDefault } from '12factor-env'; + +const runtimeEnv = (): { applicationPath: string } => { + const parsed = cleanEnv(process.env, { + CONSTRUCTIVE_DB_APPLICATION_PATH: withDefault(str, '') + }); + return { applicationPath: parsed.CONSTRUCTIVE_DB_APPLICATION_PATH.trim() }; +}; + +/** + * Resolve an explicitly pinned generated Constructive DB application checkout. + * Empty means the cross-repository suite is not part of the current test run. + */ +export const getConstructiveDbApplicationPath = (): string | null => { + const configured = runtimeEnv().applicationPath; + if (!configured) return null; + const resolved = path.resolve(configured); + if (!existsSync(path.join(resolved, 'pgpm.plan'))) { + throw new Error( + `CONSTRUCTIVE_DB_APPLICATION_PATH does not contain a generated pgpm application: ${resolved}` + ); + } + return resolved; +}; diff --git a/graphql/server-test/src/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/index.ts b/graphql/server-test/src/index.ts index 5b4b7e7f82..1b53ec211c 100644 --- a/graphql/server-test/src/index.ts +++ b/graphql/server-test/src/index.ts @@ -1,3 +1,5 @@ +export { getConstructiveDbApplicationPath } from './constructive-db-runtime'; + // Export types export * from './types'; diff --git a/graphql/server-test/src/server.ts b/graphql/server-test/src/server.ts index c8fbfc1e2d..04fc181dc6 100644 --- a/graphql/server-test/src/server.ts +++ b/graphql/server-test/src/server.ts @@ -48,7 +48,10 @@ export const createTestServer = async ( server: { ...opts.server, host, - port + port, + ...(serverOpts.trustProxy !== undefined && { + trustProxy: serverOpts.trustProxy + }) } }; diff --git a/graphql/server-test/src/types.ts b/graphql/server-test/src/types.ts index 2d1119be2e..220992f10d 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'; @@ -12,6 +16,8 @@ export interface ServerOptions { port?: number; /** Host to bind the server to (defaults to localhost) */ host?: string; + /** Trust the forwarded protocol when a test exercises an HTTPS callback. */ + trustProxy?: boolean; /** * Which server to run this suite against: * - `true` (default): the production `@constructive-io/graphql-server`, which @@ -39,6 +45,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/oauth/__tests__/router.test.ts b/graphql/server/src/auth/oauth/__tests__/router.test.ts new file mode 100644 index 0000000000..9ed3bf8af6 --- /dev/null +++ b/graphql/server/src/auth/oauth/__tests__/router.test.ts @@ -0,0 +1,116 @@ +import { errors } from '@constructive-io/errors'; +import type { ConstructiveContext } from '@constructive-io/express-context'; +import express from 'express'; +import supertest from 'supertest'; + +import { createOAuthRouter } from '../router'; +import { + completeProviderAuthentication, + createProviderAuthorizationUrl +} from '../service'; + +jest.mock('../service', () => ({ + completeProviderAuthentication: jest.fn(), + createProviderAuthorizationUrl: jest.fn() +})); + +const mockedAuthorize = jest.mocked(createProviderAuthorizationUrl); +const mockedComplete = jest.mocked(completeProviderAuthentication); +const opaqueState = 's'.repeat(43); + +const makeApp = () => { + const app = express(); + const context = { + useModule: jest.fn(async () => ({ privateSchema: 'tenant_sso_private' })) + } as unknown as ConstructiveContext; + app.use((req, _res, next) => { + req.constructive = context; + req.cookies = { csrf_token: 'b'.repeat(64) }; + req.deviceToken = 'device-token'; + req.api = { + dbname: 'tenant', + anonRole: 'anonymous', + roleName: 'anonymous', + schema: [], + authSettings: { + cookieDomain: '.example.com', + cookieSecure: false, + cookieHttponly: false + } + }; + next(); + }); + app.use('/auth/oauth', createOAuthRouter({ requestTimeoutMs: 1000 })); + return app; +}; + +describe('OAuth HTTP routes', () => { + beforeEach(() => jest.clearAllMocks()); + + it('redirects authorize using only the server-restored adapter URL', async () => { + mockedAuthorize.mockResolvedValue( + 'https://github.com/login/oauth/authorize?state=provider-state' + ); + + const response = await supertest(makeApp()) + .get(`/auth/oauth/authorize?state=${opaqueState}`) + .expect(303); + + expect(response.headers.location).toBe( + 'https://github.com/login/oauth/authorize?state=provider-state' + ); + expect(response.headers['cache-control']).toBe('no-store'); + expect(response.headers['referrer-policy']).toBe('no-referrer'); + }); + + it('sets a Secure HttpOnly host-only auth-center cookie after callback', async () => { + mockedComplete.mockResolvedValue({ + credentialId: '00000000-0000-0000-0000-000000000001', + userId: '00000000-0000-0000-0000-000000000002', + accessToken: 'cnc_auth_center_token', + accessTokenExpiresAt: '2026-08-10T12:00:00.000Z', + isVerified: true, + totpEnabled: false, + continuationUrl: + '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(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(), + expect.anything(), + expect.objectContaining({ deviceToken: 'device-token' }) + ); + }); + + it('returns only a stable safe cancellation classification', async () => { + mockedComplete.mockRejectedValue(errors.OAUTH_AUTHORIZATION_CANCELLED()); + + const response = await supertest(makeApp()) + .get( + `/auth/oauth/callback?state=${opaqueState}` + + '&error=access_denied&error_description=provider-secret-detail' + ) + .expect(400); + + expect(response.text).toContain('OAUTH_AUTHORIZATION_CANCELLED'); + expect(response.text).not.toContain('provider-secret-detail'); + expect(mockedComplete).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ providerReturnedError: true }) + ); + }); +}); diff --git a/graphql/server/src/auth/oauth/__tests__/service.test.ts b/graphql/server/src/auth/oauth/__tests__/service.test.ts new file mode 100644 index 0000000000..203709c814 --- /dev/null +++ b/graphql/server/src/auth/oauth/__tests__/service.test.ts @@ -0,0 +1,184 @@ +import type { + ConstructiveContext, + IdentityProviderConfig, + SsoSurface +} from '@constructive-io/express-context'; +import type { PoolClient, QueryResult } from 'pg'; + +import { + completeProviderAuthentication, + createProviderAuthorizationUrl +} from '../service'; + +const opaqueState = 's'.repeat(43); +const browserBinding = 'b'.repeat(64); +const verifier = 'v'.repeat(43); +const surface: SsoSurface = { privateSchema: 'tenant_acme_sso_private' }; + +const githubProvider: IdentityProviderConfig = { + id: 'provider-id', + slug: 'github-enterprise', + kind: 'github', + displayName: 'GitHub', + enabled: true, + clientId: 'client-id', + clientSecret: 'client-secret', + authorizationUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + userinfoUrl: 'https://api.github.com/user', + issuerUrl: null, + discoveryUrlOverride: null, + discoveryDoc: null, + jwks: null, + jwksFetchedAt: null, + acceptableClientIds: [], + scopes: ['read:user', 'user:email'], + extraAuthorizationParams: {}, + emailOptional: false, + allowLinkByEmail: false, + skipNonceCheck: false, + pkceEnabled: true +}; + +const createContext = (results: Record[]) => { + const query = jest.fn(async (..._args: unknown[]) => ({ + rows: [{ result: results.shift() }] + } as unknown as QueryResult)); + const client = { query } as unknown as PoolClient; + const context = { + useModule: jest.fn(async (name: string) => name === 'identityProviders' + ? { + providers: { [githubProvider.slug]: githubProvider }, + source: { schemaName: 'private', tableName: 'identity_providers' } + } + : undefined), + withPgClient: jest.fn(async (callback: (pg: PoolClient) => Promise) => + callback(client) + ) + } as unknown as ConstructiveContext; + return { context, query }; +}; + +describe('Provider OAuth orchestration', () => { + it('rejects malformed state before database access', async () => { + const { context, query } = createContext([]); + await expect(createProviderAuthorizationUrl( + context, + surface, + 'not-a-state', + browserBinding + )).rejects.toMatchObject({ code: 'INVALID_OAUTH_STATE' }); + expect(query).not.toHaveBeenCalled(); + }); + + it('builds authorization through the configured adapter without exposing verifier', async () => { + const { context } = createContext([{ + oauth_request_id: '00000000-0000-0000-0000-000000000001', + provider_key: githubProvider.slug, + code_verifier: verifier, + nonce: null, + redirect_uri: 'https://auth.example.com/auth/oauth/callback' + }]); + + const url = await createProviderAuthorizationUrl( + context, + surface, + opaqueState, + browserBinding + ); + const parsed = new URL(url); + expect(parsed.origin).toBe('https://github.com'); + expect(parsed.searchParams.get('state')).toBe(opaqueState); + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256'); + expect(parsed.searchParams.get('code_challenge')).not.toBe(verifier); + expect(url).not.toContain(verifier); + }); + + it('consumes state, mocks only Provider HTTP, and applies normalized identity', async () => { + const { context, query } = createContext([ + { + oauth_request_id: '00000000-0000-0000-0000-000000000001', + provider_key: githubProvider.slug, + code_verifier: verifier, + nonce: null, + redirect_uri: 'https://auth.example.com/auth/oauth/callback' + }, + { + id: '00000000-0000-0000-0000-000000000002', + user_id: '00000000-0000-0000-0000-000000000003', + access_token: 'cnc_auth_center_token', + access_token_expires_at: '2026-08-10T12:00:00.000Z', + is_verified: true, + totp_enabled: false, + mfa_required: false, + 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() + .mockResolvedValueOnce(new Response(JSON.stringify({ + access_token: 'github-server-token' + }), { status: 200, headers: { 'content-type': 'application/json' } })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 12345, + login: 'octocat', + name: 'Octo Cat', + email: 'octo@example.com', + avatar_url: 'https://avatars.githubusercontent.com/u/12345' + }), { status: 200, headers: { 'content-type': 'application/json' } })); + + const result = await completeProviderAuthentication(context, surface, { + state: opaqueState, + code: 'provider-authorization-code', + providerReturnedError: false, + browserBinding, + deviceToken: null, + requestTimeoutMs: 1000, + fetch: providerFetch as typeof fetch + }); + + expect(result.accessToken).toBe('cnc_auth_center_token'); + expect(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([ + '00000000-0000-0000-0000-000000000001', + githubProvider.slug, + '12345', + 'octo@example.com', + JSON.stringify({ + name: 'Octo Cat', + username: 'octocat', + avatarUrl: 'https://avatars.githubusercontent.com/u/12345' + }), + 'bearer', + false, + null, + expect.stringMatching(/^\\x[0-9a-f]{64}$/), + expect.stringMatching(/^\\x[0-9a-f]{64}$/) + ]); + }); + + it('consumes a cancelled Provider callback before returning a safe error', async () => { + const { context, query } = createContext([{ + oauth_request_id: '00000000-0000-0000-0000-000000000001', + provider_key: githubProvider.slug, + code_verifier: verifier, + nonce: null, + redirect_uri: 'https://auth.example.com/auth/oauth/callback' + }]); + + await expect(completeProviderAuthentication(context, surface, { + state: opaqueState, + providerReturnedError: true, + browserBinding, + deviceToken: null, + requestTimeoutMs: 1000 + })).rejects.toMatchObject({ code: 'OAUTH_AUTHORIZATION_CANCELLED' }); + expect(query).toHaveBeenCalledTimes(1); + expect(context.useModule).not.toHaveBeenCalledWith('identityProviders'); + }); +}); diff --git a/graphql/server/src/auth/oauth/index.ts b/graphql/server/src/auth/oauth/index.ts new file mode 100644 index 0000000000..cf9900cf86 --- /dev/null +++ b/graphql/server/src/auth/oauth/index.ts @@ -0,0 +1 @@ +export { createOAuthRouter, type OAuthRouterOptions } from './router'; diff --git a/graphql/server/src/auth/oauth/page.ts b/graphql/server/src/auth/oauth/page.ts new file mode 100644 index 0000000000..0257e9b265 --- /dev/null +++ b/graphql/server/src/auth/oauth/page.ts @@ -0,0 +1,28 @@ +import type { ConstructiveError } from '@constructive-io/errors'; + +const escapeHtml = (value: string): string => + value.replace(/[&<>'"]/g, character => ({ + '&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"' + })[character] ?? character); + +const page = (title: string, body: string): string => ` + + + + + ${escapeHtml(title)} + + +
+

${escapeHtml(title)}

+

${escapeHtml(body)}

+
+ +`; + +export const renderOAuthFailurePage = (error: ConstructiveError): string => + page('External sign in failed', `${error.message} (${error.code})`); diff --git a/graphql/server/src/auth/oauth/router.ts b/graphql/server/src/auth/oauth/router.ts new file mode 100644 index 0000000000..bab2a4da3c --- /dev/null +++ b/graphql/server/src/auth/oauth/router.ts @@ -0,0 +1,132 @@ +import { DEFAULT_CSRF_COOKIE_NAME } from '@constructive-io/csrf'; +import { + ConstructiveError, + errors, + toError +} from '@constructive-io/errors'; +import { Logger } from '@pgpmjs/logger'; +import { type Request, type Response,Router } from 'express'; + +import { + type CookieConfig, + getSessionCookieConfig, + setSessionCookie +} from '../../middleware/cookie'; +import { renderOAuthFailurePage } from './page'; +import { + completeProviderAuthentication, + createProviderAuthorizationUrl +} from './service'; + +const log = new Logger('oauth-routes'); + +export interface OAuthRouterOptions { + requestTimeoutMs: number; +} + +const queryString = (req: Request, name: string): string | undefined => { + const value = req.query[name]; + return typeof value === 'string' ? value : undefined; +}; + +const requireRequestBoundary = async (req: Request) => { + const context = req.constructive; + if (!context) { + throw errors.INTERNAL_FAILURE({ + details: 'The Constructive request context is unavailable.' + }); + } + const surface = await context.useModule('ssoSurface'); + if (!surface) throw errors.SSO_SIGN_IN_DISABLED(); + const browserBinding = req.cookies?.[DEFAULT_CSRF_COOKIE_NAME]; + if (typeof browserBinding !== 'string') { + throw errors.INVALID_OAUTH_STATE(); + } + return { context, surface, browserBinding }; +}; + +const asSafeOAuthError = (cause: unknown): ConstructiveError => { + const error = toError(cause); + return error.isPublic + ? error + : errors.IDENTITY_PROVIDER_AUTHENTICATION_FAILED( + {}, + undefined, + { cause: error } + ); +}; + +const sendFailure = (req: Request, res: Response, cause: unknown): void => { + const error = asSafeOAuthError(cause); + log.warn({ + event: 'oauth_failure', + code: error.code, + requestId: req.requestId, + causeName: cause instanceof Error ? cause.name : typeof cause + }); + res.status(error.http).type('html').send(renderOAuthFailurePage(error)); +}; + +const setSecurityHeaders = (_req: Request, res: Response, next: () => void) => { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader( + 'Content-Security-Policy', + "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + ); + next(); +}; + +export const createOAuthRouter = (options: OAuthRouterOptions): Router => { + const router = Router(); + router.use(setSecurityHeaders); + + router.get('/authorize', async (req, res) => { + try { + const state = queryString(req, 'state') ?? ''; + const { context, surface, browserBinding } = await requireRequestBoundary(req); + const authorizationUrl = await createProviderAuthorizationUrl( + context, + surface, + state, + browserBinding + ); + res.redirect(303, authorizationUrl); + } catch (cause) { + sendFailure(req, res, cause); + } + }); + + router.get('/callback', async (req, res) => { + try { + const state = queryString(req, 'state') ?? ''; + const code = queryString(req, 'code'); + const providerReturnedError = req.query.error !== undefined; + const { context, surface, browserBinding } = await requireRequestBoundary(req); + const result = await completeProviderAuthentication(context, surface, { + state, + code, + providerReturnedError, + browserBinding, + deviceToken: req.deviceToken ?? null, + requestTimeoutMs: options.requestTimeoutMs + }); + + const cookieConfig: CookieConfig = { + ...getSessionCookieConfig(req.api?.authSettings), + domain: undefined, + httpOnly: true, + secure: true + }; + setSessionCookie(res, result.accessToken, cookieConfig); + res.redirect(303, result.continuationUrl); + } catch (cause) { + sendFailure(req, res, cause); + } + }); + + return router; +}; diff --git a/graphql/server/src/auth/oauth/service.ts b/graphql/server/src/auth/oauth/service.ts new file mode 100644 index 0000000000..fe327e6778 --- /dev/null +++ b/graphql/server/src/auth/oauth/service.ts @@ -0,0 +1,132 @@ +import { errors } from '@constructive-io/errors'; +import type { + ConstructiveContext, + SsoSurface +} from '@constructive-io/express-context'; +import { + deriveS256CodeChallenge, + isOpaqueOAuthValue, + ProviderAdapterError +} from '@constructive-io/oauth'; + +import { createHandoffMaterial } from '../sso/handoff'; +import { resolveConfiguredProvider } from '../sso/provider-config'; +import { + completeProviderUnifiedLogin, + consumeProviderOAuthRequest, + type ProviderCredentialResult, + readProviderOAuthRequest +} from '../sso/provider-db-contract'; + +const mapAdapterError = (cause: unknown): never => { + if (!(cause instanceof ProviderAdapterError)) { + throw errors.IDENTITY_PROVIDER_AUTHENTICATION_FAILED( + {}, + undefined, + { cause } + ); + } + if (cause.reason === 'INVALID_CONFIGURATION') { + throw errors.IDENTITY_PROVIDER_NOT_CONFIGURED({}, undefined, { cause }); + } + if (cause.reason === 'INVALID_AUTHORIZATION_INPUT') { + throw errors.INVALID_OAUTH_PKCE({}, undefined, { cause }); + } + throw errors.IDENTITY_PROVIDER_AUTHENTICATION_FAILED( + {}, + undefined, + { cause } + ); +}; + +const validateState = (state: string): void => { + if (!isOpaqueOAuthValue(state)) throw errors.INVALID_OAUTH_STATE(); +}; + +export const createProviderAuthorizationUrl = async ( + context: ConstructiveContext, + surface: SsoSurface, + state: string, + browserBinding: string +): Promise => { + validateState(state); + const request = await readProviderOAuthRequest( + context, + surface, + state, + browserBinding + ); + const { adapter, configuration } = await resolveConfiguredProvider( + context, + request.providerKey + ); + try { + return adapter.createAuthorizationRequest({ + config: configuration, + redirectUri: request.redirectUri, + state, + codeChallenge: deriveS256CodeChallenge(request.codeVerifier), + ...(request.nonce ? { nonce: request.nonce } : {}) + }).url; + } catch (cause) { + return mapAdapterError(cause); + } +}; + +export const completeProviderAuthentication = async ( + context: ConstructiveContext, + surface: SsoSurface, + input: { + state: string; + code?: string; + providerReturnedError: boolean; + browserBinding: string; + deviceToken: string | null; + requestTimeoutMs: number; + fetch?: typeof fetch; + } +): Promise => { + validateState(input.state); + + // Consume and restore server-held state before inspecting code/error. A + // cancellation, malformed callback, expiry, or replay never remains usable. + const request = await consumeProviderOAuthRequest( + context, + surface, + input.state, + input.browserBinding + ); + if (input.providerReturnedError) { + throw errors.OAUTH_AUTHORIZATION_CANCELLED(); + } + if (!input.code || input.code.length > 4096) { + throw errors.IDENTITY_PROVIDER_AUTHENTICATION_FAILED(); + } + + const { adapter, configuration } = await resolveConfiguredProvider( + context, + request.providerKey + ); + let identity; + try { + identity = await adapter.completeAuthorization({ + config: configuration, + redirectUri: request.redirectUri, + code: input.code, + codeVerifier: request.codeVerifier, + ...(request.nonce ? { nonce: request.nonce } : {}), + requestTimeoutMs: input.requestTimeoutMs, + ...(input.fetch ? { fetch: input.fetch } : {}) + }); + } catch (cause) { + return mapAdapterError(cause); + } + + return completeProviderUnifiedLogin(context, surface, { + requestId: request.requestId, + identity, + browserBinding: input.browserBinding, + deviceToken: input.deviceToken, + 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 new file mode 100644 index 0000000000..f0ebd69641 --- /dev/null +++ b/graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts @@ -0,0 +1,62 @@ +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', + 'startProviderAuthentication', + 'redeemUnifiedLoginHandoff' + ]) + ); + }); +}); 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..d171a84774 --- /dev/null +++ b/graphql/server/src/auth/sso/__tests__/service.test.ts @@ -0,0 +1,365 @@ +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; + runtime?: boolean; + } = {} +): { 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 = { + 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; + 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: { [googleProvider.slug]: 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, + callback_url: 'https://portal.example.com/auth/complete', + site_state: opaque, + handoff_expires_at: '2026-08-10T00:01:00.000Z' + }); + 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).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"' + ); + 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, + 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' + }, { providers: { [googleProvider.slug]: googleProvider } }); + const service = createUnifiedAuthService(true); + + const result = await service.startProvider( + { + constructive: context, + browserBinding: opaque + }, + { transactionId: opaque, providerKey: googleProvider.slug } + ); + + expect(result.authorizationUrl).toMatch( + /^\/auth\/oauth\/authorize\?state=[A-Za-z0-9_-]{43}$/ + ); + expect(result.authorizationUrl).not.toContain(opaque); + expect(query.mock.calls[0][0]).toContain( + '"tenant_acme_sso_private"."start_provider_oauth_request"' + ); + expect(query.mock.calls[0][1]).toEqual([ + expect.stringMatching(/^\\x[0-9a-f]{64}$/), + googleProvider.slug, + expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + 'https://auth.example.com/auth/oauth/callback', + expect.stringMatching(/^\\x[0-9a-f]{64}$/) + ]); + }); + + it('keeps the Provider-start field stable but fails while OAuth is disabled', async () => { + const { context, query } = makeContext(); + const service = createUnifiedAuthService(false); + await expect(service.startProvider( + { constructive: context, browserBinding: opaque }, + { transactionId: opaque, providerKey: googleProvider.slug } + )).rejects.toMatchObject({ code: 'OAUTH_SIGN_IN_DISABLED' }); + expect(query).not.toHaveBeenCalled(); + }); + + it('rejects a cross-origin return target before database access', async () => { + const { context, query } = makeContext(); + const service = createUnifiedAuthService(false); + + 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/__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 new file mode 100644 index 0000000000..3d69f89967 --- /dev/null +++ b/graphql/server/src/auth/sso/db-contract.ts @@ -0,0 +1,346 @@ +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, + 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, 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, 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 + * 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; + +export 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 } + ); + +export const asRecord = (value: unknown, operation: string): DatabaseRecord => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw invalidDatabaseResult(operation); + } + return value as DatabaseRecord; +}; + +export 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; +}; + +export 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; +}; + +export const requiredBoolean = ( + row: DatabaseRecord, + field: string, + operation: string +): boolean => { + const value = row[field]; + if (typeof value !== 'boolean') throw invalidDatabaseResult(operation); + return value; +}; + +export type SqlCast = 'boolean' | 'bytea' | 'jsonb' | '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 'jsonb': + return sql.fragment`${value}::jsonb`; + case 'text': + return sql.fragment`${value}::text`; + case 'uuid': + return sql.fragment`${value}::uuid`; + } +}; + +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, + 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, + handoff: HandoffMaterial +): Promise => { + const operation = SSO_DB_FUNCTIONS.confirm; + const row = await callFunction( + context, + surface, + operation, + [ + sql.value(hashOpaqueValue(input.transactionId)), + sql.value(hashOpaqueValue(browserBinding)), + sql.value(handoff.hash) + ], + ['bytea', 'bytea', 'bytea'] + ); + requiredString(row, 'user_id', operation); + return { + transactionId: input.transactionId, + authenticated: true, + continuationUrl: continuationFromDatabaseResult(row, operation, handoff) + }; +}; + +const authenticateWithPassword = async ( + functionName: typeof SSO_DB_FUNCTIONS.signIn | typeof SSO_DB_FUNCTIONS.signUp, + context: ConstructiveContext, + surface: SsoSurface, + input: UnifiedPasswordInput, + browserBinding: string, + handoff: HandoffMaterial +): 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), + sql.value(handoff.hash) + ], + [ + 'bytea', + 'text', + 'text', + 'boolean', + 'text', + 'bytea', + 'text', + 'text', + 'bytea' + ] + ); + + // 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), + continuationUrl: continuationFromDatabaseResult(row, functionName, handoff) + }; +}; + +export const signInUnifiedLogin = ( + context: ConstructiveContext, + surface: SsoSurface, + input: UnifiedPasswordInput, + browserBinding: string, + handoff: HandoffMaterial +): Promise => + authenticateWithPassword( + SSO_DB_FUNCTIONS.signIn, + context, + surface, + input, + browserBinding, + handoff + ); + +export const signUpUnifiedLogin = ( + context: ConstructiveContext, + surface: SsoSurface, + input: UnifiedPasswordInput, + browserBinding: string, + handoff: HandoffMaterial +): Promise => + authenticateWithPassword( + SSO_DB_FUNCTIONS.signUp, + context, + surface, + input, + 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/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..4158e68fb3 --- /dev/null +++ b/graphql/server/src/auth/sso/plugin.ts @@ -0,0 +1,172 @@ +import type { GraphileConfig } from 'graphile-config'; +import { extendSchema, gql } from 'graphile-utils'; + +import { createUnifiedAuthService } from './service'; +import type { + ContinueUnifiedLoginInput, + RedeemUnifiedLoginHandoffInput, + StartProviderAuthenticationInput, + 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 StartProviderAuthenticationPayload { + authorizationUrl: 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! + } + + type RedeemUnifiedLoginHandoffPayload { + credentialId: UUID! + userId: UUID! + accessToken: String! + accessTokenExpiresAt: Datetime! + isVerified: Boolean! + totpEnabled: Boolean! + returnTo: 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 + } + + input StartProviderAuthenticationInput { + transactionId: String! + providerKey: String! + } + + input RedeemUnifiedLoginHandoffInput { + handoffCode: 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! + startProviderAuthentication(input: StartProviderAuthenticationInput!): StartProviderAuthenticationPayload! + redeemUnifiedLoginHandoff(input: RedeemUnifiedLoginHandoffInput!): RedeemUnifiedLoginHandoffPayload! + } + `, + 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), + startProviderAuthentication: ( + _source: unknown, + args: InputArguments, + context: UnifiedAuthGraphQLContext + ) => 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-config.ts b/graphql/server/src/auth/sso/provider-config.ts new file mode 100644 index 0000000000..15229448ec --- /dev/null +++ b/graphql/server/src/auth/sso/provider-config.ts @@ -0,0 +1,106 @@ +import { errors } from '@constructive-io/errors'; +import type { + ConstructiveContext, + IdentityProviderConfig, + IdentityProvidersModule +} from '@constructive-io/express-context'; +import { + getProviderAdapter, + getProviderAdapterKinds, + type IdentityProviderConfiguration, + type ProviderAdapter, + type ValidatedProviderConfiguration +} from '@constructive-io/oauth'; + +import type { ProviderDisplayOption } from './types'; + +export const toOAuthConfiguration = ( + provider: IdentityProviderConfig +): IdentityProviderConfiguration => ({ + slug: provider.slug, + kind: provider.kind, + displayName: provider.displayName, + enabled: provider.enabled, + clientId: provider.clientId, + clientSecret: provider.clientSecret, + authorizationUrl: provider.authorizationUrl, + tokenUrl: provider.tokenUrl, + userinfoUrl: provider.userinfoUrl, + issuerUrl: provider.issuerUrl, + discoveryDoc: provider.discoveryDoc, + jwks: provider.jwks, + acceptableClientIds: provider.acceptableClientIds, + scopes: provider.scopes, + extraAuthorizationParams: provider.extraAuthorizationParams, + emailOptional: provider.emailOptional, + skipNonceCheck: provider.skipNonceCheck, + pkceEnabled: provider.pkceEnabled +}); + +const validateProvider = ( + provider: IdentityProviderConfig +): { + adapter: ProviderAdapter; + configuration: ValidatedProviderConfiguration; +} => { + let adapter: ProviderAdapter; + try { + adapter = getProviderAdapter(provider.kind); + } catch (cause) { + throw errors.IDENTITY_PROVIDER_UNSUPPORTED({}, undefined, { cause }); + } + + try { + return { + adapter, + configuration: adapter.validateConfiguration( + toOAuthConfiguration(provider) + ) + }; + } catch (cause) { + throw errors.IDENTITY_PROVIDER_NOT_CONFIGURED({}, undefined, { cause }); + } +}; + +export const resolveConfiguredProvider = async ( + context: ConstructiveContext, + providerKey: string +): Promise<{ + adapter: ProviderAdapter; + configuration: ValidatedProviderConfiguration; +}> => { + const module = await context.useModule('identityProviders'); + const provider = module?.providers[providerKey]; + if (!provider || !provider.enabled) { + throw errors.IDENTITY_PROVIDER_NOT_CONFIGURED(); + } + return validateProvider(provider); +}; + +const providerDisplayOptions = ( + module: IdentityProvidersModule | undefined +): ProviderDisplayOption[] => { + if (!module) return []; + const supportedKinds = new Set(getProviderAdapterKinds()); + const options: ProviderDisplayOption[] = []; + + for (const provider of Object.values(module.providers)) { + if (!provider.enabled || !supportedKinds.has(provider.kind)) continue; + validateProvider(provider); + options.push({ key: provider.slug, displayName: provider.displayName }); + } + + return options.sort((left, right) => + left.displayName.localeCompare(right.displayName) || + left.key.localeCompare(right.key) + ); +}; + +export const loadProviderDisplayOptions = async ( + context: ConstructiveContext, + oauthEnabled: boolean +): Promise => { + if (!oauthEnabled) return []; + const providers = await context.useModule('identityProviders'); + return providerDisplayOptions(providers); +}; diff --git a/graphql/server/src/auth/sso/provider-db-contract.ts b/graphql/server/src/auth/sso/provider-db-contract.ts new file mode 100644 index 0000000000..0e22474148 --- /dev/null +++ b/graphql/server/src/auth/sso/provider-db-contract.ts @@ -0,0 +1,230 @@ +import { errors } from '@constructive-io/errors'; +import type { + ConstructiveContext, + SsoSurface +} from '@constructive-io/express-context'; +import type { NormalizedExternalIdentity } from '@constructive-io/oauth'; +import sql from 'pg-sql2'; + +import { + callFunction, + continuationFromDatabaseResult, + optionalString, + requiredBoolean, + requiredString +} from './db-contract'; +import type { HandoffMaterial } from './handoff'; +import { hashOpaqueValue } from './opaque'; + +export const PROVIDER_DB_FUNCTIONS = { + start: 'start_provider_oauth_request', + read: 'read_provider_oauth_request', + consume: 'consume_provider_oauth_request', + complete: 'complete_provider_unified_login' +} as const; + +/** + * Fixed Constructive/DB signatures for the Provider subflow: + * + * - `start_provider_oauth_request(bytea, text, text, text, text, text, bytea)` + * accepts unified transaction token, Provider key, state, verifier, nonce, + * redirect URI, and browser binding; returns `oauth_request_id`. + * - `read_provider_oauth_request(text, bytea)` and + * `consume_provider_oauth_request(text, bytea)` accept state plus browser + * binding and return the request fields parsed below. Consume atomically + * marks the state used before Provider callback handling. + * - `complete_provider_unified_login(uuid, text, text, text, jsonb, text, + * boolean, text, bytea, 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 { + requestId: string; + providerKey: string; + codeVerifier: string; + nonce: string | null; + redirectUri: string; +} + +export interface ProviderCredentialResult { + credentialId: string; + userId: string; + accessToken: string; + accessTokenExpiresAt: string; + isVerified: boolean; + totpEnabled: boolean; + continuationUrl: string; +} + +/** + * Persist server-owned OAuth state before any browser redirect. + * + * The matching Tenant-private DB function validates the opaque unified login + * transaction and browser binding, links the existing OAuth request relation + * to that transaction, and enforces its ten-minute expiry. Only the opaque + * OAuth state crosses browser navigation; the verifier, nonce, transaction + * link, and Provider configuration identity remain server-side. + */ +export const startProviderOAuthRequest = async ( + context: ConstructiveContext, + surface: SsoSurface, + input: { + transactionId: string; + providerKey: string; + state: string; + codeVerifier: string; + nonce: string; + redirectUri: string; + browserBinding: string; + } +): Promise => { + const operation = PROVIDER_DB_FUNCTIONS.start; + const row = await callFunction( + context, + surface, + operation, + [ + sql.value(hashOpaqueValue(input.transactionId)), + sql.value(input.providerKey), + sql.value(input.state), + sql.value(input.codeVerifier), + sql.value(input.nonce), + sql.value(input.redirectUri), + sql.value(hashOpaqueValue(input.browserBinding)) + ], + ['bytea', 'text', 'text', 'text', 'text', 'text', 'bytea'] + ); + requiredString(row, 'oauth_request_id', operation); +}; + +const restoreProviderOAuthRequest = async ( + functionName: + | typeof PROVIDER_DB_FUNCTIONS.read + | typeof PROVIDER_DB_FUNCTIONS.consume, + context: ConstructiveContext, + surface: SsoSurface, + state: string, + browserBinding: string +): Promise => { + const row = await callFunction( + context, + surface, + functionName, + [sql.value(state), sql.value(hashOpaqueValue(browserBinding))], + ['text', 'bytea'] + ); + return { + requestId: requiredString(row, 'oauth_request_id', functionName), + providerKey: requiredString(row, 'provider_key', functionName), + codeVerifier: requiredString(row, 'code_verifier', functionName), + nonce: optionalString(row, 'nonce', functionName), + redirectUri: requiredString(row, 'redirect_uri', functionName) + }; +}; + +export const readProviderOAuthRequest = ( + context: ConstructiveContext, + surface: SsoSurface, + state: string, + browserBinding: string +): Promise => + restoreProviderOAuthRequest( + PROVIDER_DB_FUNCTIONS.read, + context, + surface, + state, + browserBinding + ); + +export const consumeProviderOAuthRequest = ( + context: ConstructiveContext, + surface: SsoSurface, + state: string, + browserBinding: string +): Promise => + restoreProviderOAuthRequest( + PROVIDER_DB_FUNCTIONS.consume, + context, + surface, + state, + browserBinding + ); + +/** + * Apply only the normalized Provider identity to the current Tenant. + * Account matching/provisioning, connected_accounts ownership, conflict rules, + * and association with the linked unified transaction stay inside the DB + * function and its unchanged sign_in_identity/sign_up_identity primitives. + */ +export const completeProviderUnifiedLogin = async ( + context: ConstructiveContext, + surface: SsoSurface, + input: { + requestId: string; + identity: NormalizedExternalIdentity; + browserBinding: string; + deviceToken: string | null; + handoff: HandoffMaterial; + } +): Promise => { + const operation = PROVIDER_DB_FUNCTIONS.complete; + const row = await callFunction( + context, + surface, + operation, + [ + sql.value(input.requestId), + sql.value(input.identity.providerKey), + sql.value(input.identity.subject), + sql.value(input.identity.email ?? null), + sql.value(JSON.stringify(input.identity.profile)), + sql.value('bearer'), + sql.value(false), + sql.value(input.deviceToken), + sql.value(hashOpaqueValue(input.browserBinding)), + sql.value(input.handoff.hash) + ], + [ + 'uuid', + 'text', + 'text', + 'text', + 'jsonb', + 'text', + 'boolean', + 'text', + 'bytea', + 'bytea' + ] + ); + + const mfaRequired = requiredBoolean( + row, + 'mfa_required', + operation + ); + if (mfaRequired) { + // strict-auth/MFA/step-up integration is outside v1 and must fail closed. + throw errors.AUTH_METHOD_NOT_ALLOWED({}); + } + + return { + credentialId: requiredString(row, 'id', operation), + userId: requiredString(row, 'user_id', operation), + accessToken: requiredString(row, 'access_token', operation), + accessTokenExpiresAt: requiredString( + row, + 'access_token_expires_at', + operation + ), + isVerified: requiredBoolean(row, 'is_verified', operation), + totpEnabled: requiredBoolean(row, 'totp_enabled', operation), + continuationUrl: continuationFromDatabaseResult( + row, + operation, + input.handoff + ) + }; +}; diff --git a/graphql/server/src/auth/sso/service.ts b/graphql/server/src/auth/sso/service.ts new file mode 100644 index 0000000000..3c322150fe --- /dev/null +++ b/graphql/server/src/auth/sso/service.ts @@ -0,0 +1,258 @@ +import { errors } from '@constructive-io/errors'; +import type { + ConstructiveContext, + SsoSurface +} from '@constructive-io/express-context'; +import { + generateCodeVerifier, + generateOidcNonce, + generateOpaqueState, + validateProviderCallbackUri +} from '@constructive-io/oauth'; + +import { + confirmUnifiedLogin, + signInUnifiedLogin, + signUpUnifiedLogin, + startUnifiedLogin +} from './db-contract'; +import { createHandoffMaterial } from './handoff'; +import { redeemUnifiedLoginHandoff } from './handoff-db-contract'; +import { + loadProviderDisplayOptions, + resolveConfiguredProvider +} from './provider-config'; +import { startProviderOAuthRequest } from './provider-db-contract'; +import type { + ContinueUnifiedLoginInput, + ProviderDisplayOption, + RedeemUnifiedLoginHandoffInput, + RedeemUnifiedLoginHandoffPayload, + StartProviderAuthenticationInput, + StartProviderAuthenticationPayload, + 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 requireRequestOrigin = (context: ConstructiveContext): string => { + if (!context.requestOrigin) { + throw errors.INTERNAL_FAILURE({ + details: 'The routed authentication-center origin is unavailable.' + }); + } + return context.requestOrigin; +}; + +const validateStartInput = (input: StartUnifiedLoginInput): void => { + if (!SITE_STATE.test(input.siteState)) { + throw errors.INVALID_SSO_SITE_STATE(); + } + 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(); + } +}; + +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; + startProvider( + context: UnifiedAuthGraphQLContext, + input: StartProviderAuthenticationInput + ): Promise; + redeem( + context: UnifiedAuthGraphQLContext, + input: RedeemUnifiedLoginHandoffInput + ): 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, + createHandoffMaterial() + ); + }, + + 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, + createHandoffMaterial() + ); + }, + + 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, + createHandoffMaterial() + ); + }, + + async startProvider(graphQLContext, input) { + if (!oauthEnabled) throw errors.OAUTH_SIGN_IN_DISABLED(); + validateTransactionInput(input); + if (!input.providerKey || input.providerKey.length > 128) { + throw errors.IDENTITY_PROVIDER_NOT_CONFIGURED(); + } + const context = requireContext(graphQLContext); + const browserBinding = requireBrowserBinding(graphQLContext); + const requestOrigin = requireRequestOrigin(context); + const surface = await resolveSsoSurface(context); + await resolveConfiguredProvider(context, input.providerKey); + + let redirectUri: string; + try { + redirectUri = validateProviderCallbackUri( + new URL('/auth/oauth/callback', requestOrigin).toString() + ); + } catch (cause) { + throw errors.INTERNAL_FAILURE( + { details: 'The authentication-center Provider callback is invalid.' }, + undefined, + { cause } + ); + } + + const state = generateOpaqueState(); + await startProviderOAuthRequest(context, surface, { + transactionId: input.transactionId, + providerKey: input.providerKey, + state, + codeVerifier: generateCodeVerifier(), + nonce: generateOidcNonce(), + redirectUri, + browserBinding + }); + + return { + authorizationUrl: `/auth/oauth/authorize?state=${encodeURIComponent(state)}` + }; + }, + + 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 new file mode 100644 index 0000000000..1c50162760 --- /dev/null +++ b/graphql/server/src/auth/sso/types.ts @@ -0,0 +1,91 @@ +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 StartProviderAuthenticationInput { + transactionId: string; + providerKey: string; +} + +export interface StartProviderAuthenticationPayload { + authorizationUrl: 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; +} + +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 + 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/__tests__/routing.test.ts b/graphql/server/src/middleware/__tests__/routing.test.ts index bcd013a888..5d8fb008b1 100644 --- a/graphql/server/src/middleware/__tests__/routing.test.ts +++ b/graphql/server/src/middleware/__tests__/routing.test.ts @@ -44,6 +44,7 @@ const matchedRoute = (overrides: Partial = {}): ResolvedRoute => verification_status: 'verified', tls_status: 'ready', tls_secret_name: 'tls-api-example-com', + runtime_site_id: 'site-1', ...overrides }); @@ -104,6 +105,7 @@ describe('routeToApiStructure', () => { expect(structure).toEqual( expect.objectContaining({ apiId: 'api-1', + siteId: 'site-1', databaseId: 'db-1', dbname: 'tenant_db', roleName: 'api_role', diff --git a/graphql/server/src/middleware/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 e6de98f7ad..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 @@ -224,6 +228,11 @@ const buildPreset = ( if (req.api?.apiId) { context['jwt.claims.api_id'] = req.api.apiId; } + // Independent trusted Site identity from scoped routing. A Site is + // not inferred from api_id because multiple Sites may share one API. + if (req.api?.siteId) { + context['jwt.claims.site_id'] = req.api.siteId; + } if (req.clientIp) { context['jwt.claims.ip_address'] = req.clientIp; } @@ -270,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 @@ -299,7 +308,7 @@ const buildPreset = ( if (req.requestId) { pgSettings['request.id'] = req.requestId; } - return { pgSettings }; + return createGrafastRequestContext(req, pgSettings); } } @@ -311,9 +320,7 @@ const buildPreset = ( anonSettings['request.id'] = req.requestId; } - return { - pgSettings: anonSettings - }; + return createGrafastRequestContext(req, anonSettings); } } }; @@ -403,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/middleware/observability/__tests__/request-logger-redaction.test.ts b/graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts new file mode 100644 index 0000000000..d7834475e4 --- /dev/null +++ b/graphql/server/src/middleware/observability/__tests__/request-logger-redaction.test.ts @@ -0,0 +1,23 @@ +import { redactSensitiveRequestUrl } from '../request-logger'; + +describe('redactSensitiveRequestUrl', () => { + it('redacts OAuth and handoff query secrets while preserving safe routing facts', () => { + expect(redactSensitiveRequestUrl( + '/auth/oauth/callback?code=secret-code&state=secret-state&safe=value' + )).toBe( + '/auth/oauth/callback?code=%5BREDACTED%5D&state=%5BREDACTED%5D&safe=value' + ); + expect(redactSensitiveRequestUrl('/callback?error=access_denied')).toBe( + '/callback?error=%5BREDACTED%5D' + ); + expect(redactSensitiveRequestUrl('/callback?handoff=secret&site_state=public')).toBe( + '/callback?handoff=%5BREDACTED%5D&site_state=%5BREDACTED%5D' + ); + }); + + it('does not alter requests without sensitive query parameters', () => { + expect(redactSensitiveRequestUrl('/graphql?operation=PublicQuery')).toBe( + '/graphql?operation=PublicQuery' + ); + }); +}); diff --git a/graphql/server/src/middleware/observability/request-logger.ts b/graphql/server/src/middleware/observability/request-logger.ts index 5280a682ab..cc10fea3fb 100644 --- a/graphql/server/src/middleware/observability/request-logger.ts +++ b/graphql/server/src/middleware/observability/request-logger.ts @@ -4,6 +4,34 @@ import type { RequestHandler } from 'express'; const log = new Logger('server'); const SAFE_REQUEST_ID = /^[a-zA-Z0-9\-_]{1,128}$/; +const SENSITIVE_QUERY_PARAMETERS = new Set([ + 'access_token', + 'code', + 'error', + 'error_description', + 'handoff', + 'id_token', + 'site_state', + 'state', + 'token' +]); + +export const redactSensitiveRequestUrl = (originalUrl: string): string => { + try { + const parsed = new URL(originalUrl, 'http://constructive.invalid'); + for (const name of [...parsed.searchParams.keys()]) { + if (SENSITIVE_QUERY_PARAMETERS.has(name.toLowerCase())) { + parsed.searchParams.set(name, '[REDACTED]'); + } + } + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + const queryStart = originalUrl.indexOf('?'); + return queryStart === -1 + ? originalUrl + : `${originalUrl.slice(0, queryStart)}?[REDACTED]`; + } +}; interface RequestLoggerOptions { observabilityEnabled: boolean; @@ -22,8 +50,9 @@ export const createRequestLogger = ({ observabilityEnabled }: RequestLoggerOptio const host = req.hostname || req.headers.host || 'unknown'; const ip = req.clientIp ?? req.ip ?? 'unknown'; + const safeUrl = redactSensitiveRequestUrl(req.originalUrl); - log.debug(`[${reqId}] -> ${req.method} ${req.originalUrl} host=${host} ip=${ip}`); + log.debug(`[${reqId}] -> ${req.method} ${safeUrl} host=${host} ip=${ip}`); res.on('finish', () => { finished = true; @@ -35,7 +64,7 @@ export const createRequestLogger = ({ observabilityEnabled }: RequestLoggerOptio const svcInfo = req.svc_key ? `svc=${req.svc_key}` : 'svc=unset'; log.debug( - `[${reqId}] <- ${res.statusCode} ${req.method} ${req.originalUrl} (${durationMs.toFixed( + `[${reqId}] <- ${res.statusCode} ${req.method} ${safeUrl} (${durationMs.toFixed( 1, )} ms) ${apiInfo} ${svcInfo} ${authInfo}`, ); @@ -54,7 +83,7 @@ export const createRequestLogger = ({ observabilityEnabled }: RequestLoggerOptio log.warn( `[${reqId}] connection closed before response completed ` + - `${req.method} ${req.originalUrl} (${durationMs.toFixed(1)} ms) ${apiInfo}`, + `${req.method} ${safeUrl} (${durationMs.toFixed(1)} ms) ${apiInfo}`, ); }); } diff --git a/graphql/server/src/middleware/routing.ts b/graphql/server/src/middleware/routing.ts index 752b32bdff..10bf6da955 100644 --- a/graphql/server/src/middleware/routing.ts +++ b/graphql/server/src/middleware/routing.ts @@ -37,6 +37,8 @@ export interface ResolvedRoute { verification_status: string | null; tls_status: string | null; tls_secret_name: string | null; + /** Optional Site security context bound to this route independently of API. */ + runtime_site_id: string | null; } const RESOLVER_FUNCTION = 'resolve_route'; @@ -128,6 +130,7 @@ export const routeToApiStructure = ( return { apiId: config.api_id ?? route.target_source_id ?? undefined, + siteId: route.runtime_site_id ?? undefined, // Scoped APIs leave dbname NULL when their schemas live in the serving // database; fall back to the server's own database in that case. dbname: config.dbname || opts.pg?.database || '', diff --git a/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts b/graphql/server/src/plugins/__tests__/auth-cookie-plugin.test.ts index f4f3ba25bd..ada719d3e4 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,121 @@ 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() } + ); + + const result = 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 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); + }); +}); + /** * 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..3a3e41a3b3 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,24 @@ const SIGN_IN_MUTATIONS = new Set([ 'signInCrossOrigin', ]); +const UNIFIED_AUTH_SIGN_IN_MUTATIONS = new Set([ + 'signInUnifiedLogin', + '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. */ @@ -105,30 +131,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 +240,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 +294,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,102 +306,144 @@ 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; } + 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 = 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; + return authResult; } - 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 authResult; + } - 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) { + 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); - // Also update the BufferResult headers for grafserv to pass through - const existingBufferCookie = bufferResult.headers['set-cookie']; - const updatedHeaders = { ...bufferResult.headers }; + // Set as array to get multiple Set-Cookie headers + res.setHeader('Set-Cookie', allCookies); + } - // Remove set-cookie from grafserv headers since we set it on Express - delete updatedHeaders['set-cookie']; + // Also update the BufferResult headers for grafserv to pass through + const updatedHeaders = { ...authResult.headers }; - return { - ...bufferResult, - headers: updatedHeaders, - }; - } - } catch (err) { - log.error('[auth-cookie] Error processing auth response:', err); + // Remove set-cookie from grafserv headers since we set it on Express + delete updatedHeaders['set-cookie']; + + return { + ...authResult, + headers: updatedHeaders, + }; } - return result; + return authResult; }, }, }, diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 8ddd11c483..c9a61b4bd1 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'; @@ -16,6 +22,8 @@ import { getPgPool } from 'pg-cache'; 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'; @@ -93,6 +101,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,9 +178,10 @@ class Server { app.use(authenticate); app.use(createContextMiddleware({ pg: effectiveOpts.pg, - loaders: createDefaultRegistry(), + loaders: contextLoaders, routingSchema: getRoutingSchema(effectiveOpts) })); + app.use(createSiteSessionValidationMiddleware()); app.use(createCaptchaMiddleware()); // CSRF protection for cookie-authenticated requests @@ -198,6 +212,11 @@ class Server { }; app.use(csrfSetToken); // Set CSRF token cookie on all requests app.use('/graphql', csrfProtect); // Enforce CSRF on GraphQL mutations + if (effectiveOpts.oauth?.enabled) { + app.use('/auth/oauth', createOAuthRouter({ + requestTimeoutMs: effectiveOpts.oauth.providerRequestTimeoutMs + })); + } // LLM Agent REST API — mounted before graphile so SSE streaming // routes are handled without going through PostGraphile diff --git a/packages/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/packages/errors/__tests__/sso.test.ts b/packages/errors/__tests__/sso.test.ts index 36eea0d62f..39a8270493 100644 --- a/packages/errors/__tests__/sso.test.ts +++ b/packages/errors/__tests__/sso.test.ts @@ -7,10 +7,12 @@ const PUBLIC_SSO_CODES = [ 'SSO_LOGIN_TRANSACTION_EXPIRED', 'SSO_LOGIN_TRANSACTION_ALREADY_USED', 'OAUTH_SIGN_IN_DISABLED', + 'OAUTH_AUTHORIZATION_CANCELLED', 'INVALID_OAUTH_STATE', 'INVALID_OAUTH_PKCE', 'IDENTITY_PROVIDER_NOT_CONFIGURED', 'IDENTITY_PROVIDER_UNSUPPORTED', + 'IDENTITY_PROVIDER_AUTHENTICATION_FAILED', 'SSO_ACCOUNT_CONFLICT', 'INVALID_SSO_HANDOFF', 'SSO_HANDOFF_EXPIRED', diff --git a/packages/errors/src/registry.ts b/packages/errors/src/registry.ts index 6886dbd34a..222539a2f2 100644 --- a/packages/errors/src/registry.ts +++ b/packages/errors/src/registry.ts @@ -154,6 +154,12 @@ export const registry = { http: 403, message: 'OAuth sign in is not enabled.' }), + OAUTH_AUTHORIZATION_CANCELLED: defineError({ + code: 'OAUTH_AUTHORIZATION_CANCELLED', + class: 'public', + http: 400, + message: 'External sign in was cancelled. Please restart sign in.' + }), INVALID_SSO_SITE_STATE: defineError({ code: 'INVALID_SSO_SITE_STATE', class: 'public', @@ -208,6 +214,12 @@ export const registry = { http: 400, message: 'This identity provider is not supported.' }), + IDENTITY_PROVIDER_AUTHENTICATION_FAILED: defineError({ + code: 'IDENTITY_PROVIDER_AUTHENTICATION_FAILED', + class: 'public', + http: 401, + message: 'External sign in failed. Please restart sign in.' + }), SSO_ACCOUNT_CONFLICT: defineError({ code: 'SSO_ACCOUNT_CONFLICT', class: 'public', diff --git a/packages/express-context/README.md b/packages/express-context/README.md index 72534d75cf..4927abda36 100644 --- a/packages/express-context/README.md +++ b/packages/express-context/README.md @@ -70,6 +70,24 @@ Each loader encapsulates a SQL query + type transform + per-databaseId LRU cache | `webauthnLoader` | `routing_public.webauthn_settings` | WebAuthn/passkey configuration | | `authSettingsLoader` | `metaschema_modules_public.sessions_module` | Cookie/captcha settings (two-step tenant DB discovery) | +### Opt-in authentication loaders + +`identityProvidersLoader` resolves enabled Tenant Provider configuration and +secrets. `ssoSurfaceLoader` resolves only the current database's provisioned +unified-auth private schema. Both are intentionally excluded from +`createDefaultRegistry()` and must be registered by the authentication service +that owns their cost and secret boundary: + +```typescript +const registry = createDefaultRegistry(); +registry.register(identityProvidersLoader); +registry.register(ssoSurfaceLoader); +``` + +`ssoSurfaceLoader` returns `undefined` when the current Tenant has no provisioned +unified-auth module. It never guesses a global `sso_private` schema or searches +another database. + ### Custom loaders ```typescript diff --git a/packages/express-context/__tests__/context.test.ts b/packages/express-context/__tests__/context.test.ts new file mode 100644 index 0000000000..b73c4252c2 --- /dev/null +++ b/packages/express-context/__tests__/context.test.ts @@ -0,0 +1,21 @@ +import type { Request } from 'express'; + +import { resolveRequestOrigin } from '../src'; + +describe('resolveRequestOrigin', () => { + it('derives the routed HTTPS request origin', () => { + const request = { + protocol: 'https', + get: (name: string) => name === 'host' ? 'auth.example.com:8443' : undefined + } as unknown as Request; + expect(resolveRequestOrigin(request)).toBe('https://auth.example.com:8443'); + }); + + it('rejects malformed request hosts', () => { + const request = { + protocol: 'https', + get: () => 'bad host' + } as unknown as Request; + expect(resolveRequestOrigin(request)).toBeNull(); + }); +}); diff --git a/packages/express-context/__tests__/loaders/auth-loaders.test.ts b/packages/express-context/__tests__/loaders/auth-loaders.test.ts index f09e8e31fe..d23d0772e7 100644 --- a/packages/express-context/__tests__/loaders/auth-loaders.test.ts +++ b/packages/express-context/__tests__/loaders/auth-loaders.test.ts @@ -141,8 +141,22 @@ describe('identityProvidersLoader', () => { }; const provisioned = (rows: unknown[]) => [ - { rows: [{ schema_name: 'tenant_a_auth_private', table_name: 'identity_providers' }] }, - { rows: [{ schema_name: 'tenant_a_secrets', table_name: 'internal_secrets' }] }, + { + rows: [{ + schema_name: 'tenant_a_auth_private', + table_name: 'identity_providers', + scope: 'database', + prefix: '' + }] + }, + { + rows: [{ + schema_name: 'tenant_a_secrets', + table_name: 'internal_secrets', + scope: 'database', + prefix: '' + }] + }, { rows } ]; @@ -152,9 +166,10 @@ describe('identityProvidersLoader', () => { const module = await identityProvidersLoader.resolve(ctx(pool, 'db-a')); expect(calls[0].values).toEqual(['db-a']); - expect(calls[1].values).toEqual(['db-a']); - expect(calls[2].text).toContain('"tenant_a_secrets"."internal_secrets_get"'); + expect(calls[1].values).toEqual(['db-a', 'database']); + expect(calls[2].text).toContain('"tenant_a_secrets"."_internal_secrets_get"'); expect(calls[2].text).toContain('"tenant_a_auth_private"."identity_providers"'); + expect(calls[2].values).toEqual(['db-a']); expect(module?.providers.google).toMatchObject({ clientId: 'client-abc', clientSecret: 'shh', @@ -170,7 +185,14 @@ describe('identityProvidersLoader', () => { it('fails when the secret store is absent instead of yielding a secretless client', async () => { const { pool } = fakePool([ - { rows: [{ schema_name: 'tenant_a_auth_private', table_name: 'identity_providers' }] }, + { + rows: [{ + schema_name: 'tenant_a_auth_private', + table_name: 'identity_providers', + scope: 'database', + prefix: '' + }] + }, { rows: [] } ]); await expect(identityProvidersLoader.resolve(ctx(pool))).rejects.toThrow( diff --git a/packages/express-context/__tests__/loaders/sso-surface.test.ts b/packages/express-context/__tests__/loaders/sso-surface.test.ts new file mode 100644 index 0000000000..b7c9802e8f --- /dev/null +++ b/packages/express-context/__tests__/loaders/sso-surface.test.ts @@ -0,0 +1,77 @@ +import type { Pool } from 'pg'; + +import { createDefaultRegistry } from '../../src/loaders'; +import { createLoaderRegistry } from '../../src/loaders/registry'; +import { ssoSurfaceLoader } from '../../src/loaders/sso-surface'; +import type { LoaderContext } from '../../src/loaders/types'; +import type { SsoSurface } from '../../src/types'; + +interface Call { + text: string; + values?: unknown[]; +} + +const fakePool = (rows: unknown[]) => { + const calls: Call[] = []; + const pool = { + query: jest.fn(async (text: string, values?: unknown[]) => { + calls.push({ text, values }); + return { rows }; + }) + } as unknown as Pool; + return { calls, pool }; +}; + +const ctx = (tenantPool: Pool, databaseId = 'db-1'): LoaderContext => ({ + routingPool: {} as Pool, + tenantPool, + databaseId, + dbname: 'tenant' +}); + +beforeEach(() => ssoSurfaceLoader.invalidate()); + +describe('ssoSurfaceLoader', () => { + it('resolves the database-scoped private schema from authoritative metadata', async () => { + const { calls, pool } = fakePool([ + { private_schema: 'tenant_a_sso_private' } + ]); + + const surface: SsoSurface | undefined = await ssoSurfaceLoader.resolve( + ctx(pool, 'db-a') + ); + + expect(surface).toEqual({ privateSchema: 'tenant_a_sso_private' }); + expect(calls).toHaveLength(1); + expect(calls[0].values).toEqual(['db-a']); + expect(calls[0].text).toMatch(/unified_auth\.database_id = \$1/); + expect(calls[0].text).toMatch(/unified_auth\.scope = 'database'/); + expect(calls[0].text).toMatch( + /private_schema\.id = unified_auth\.private_schema_id/ + ); + expect(calls[0].text).toMatch( + /private_schema\.schema_name AS private_schema/ + ); + }); + + it('returns undefined when this Tenant has no provisioned module', async () => { + const { pool } = fakePool([]); + await expect(ssoSurfaceLoader.resolve(ctx(pool))).resolves.toBeUndefined(); + }); + + it('does not run an unkeyed lookup without a database ID', async () => { + const { calls, pool } = fakePool([]); + await expect(ssoSurfaceLoader.resolve(ctx(pool, ''))).rejects.toThrow( + /no databaseId/ + ); + expect(calls).toHaveLength(0); + }); + + it('is typed but remains explicitly opt-in', async () => { + expect(createDefaultRegistry().has('ssoSurface')).toBe(false); + + const registry = createLoaderRegistry(); + registry.register(ssoSurfaceLoader); + expect(registry.has('ssoSurface')).toBe(true); + }); +}); diff --git a/packages/express-context/__tests__/pg-settings.test.ts b/packages/express-context/__tests__/pg-settings.test.ts index 6b87b83b15..16605b10d0 100644 --- a/packages/express-context/__tests__/pg-settings.test.ts +++ b/packages/express-context/__tests__/pg-settings.test.ts @@ -3,6 +3,7 @@ import type { ApiStructure, ConstructiveAPIToken } from '../src/types'; const api: ApiStructure = { apiId: '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + siteId: '87763e7e-8aeb-4e5c-98ce-95e16b6f62ac', dbname: 'testdb', anonRole: 'anonymous', roleName: 'authenticated', @@ -17,6 +18,7 @@ describe('buildPgSettings — jwt.claims.api_id provenance', () => { const settings = buildPgSettings({ api, token: null, requestId: 'r1' }); expect(settings['jwt.claims.api_id']).toBe(api.apiId); + expect(settings['jwt.claims.site_id']).toBe(api.siteId); expect(settings['role']).toBe('anonymous'); }); @@ -29,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 }, @@ -42,10 +76,22 @@ describe('buildPgSettings — jwt.claims.api_id provenance', () => { it('is derived only from the resolved api, never from the token', () => { const token = { user_id: 'u1', - api_id: 'attacker-controlled' + api_id: 'attacker-controlled', + site_id: 'attacker-controlled' } as unknown as ConstructiveAPIToken; const settings = buildPgSettings({ api, token, requestId: 'r1' }); expect(settings['jwt.claims.api_id']).toBe(api.apiId); + expect(settings['jwt.claims.site_id']).toBe(api.siteId); + }); + + it('omits jwt.claims.site_id when the route has no Site context', () => { + const settings = buildPgSettings({ + api: { ...api, siteId: undefined }, + token: null, + requestId: 'r1' + }); + + expect(settings['jwt.claims.site_id']).toBeUndefined(); }); }); diff --git a/packages/express-context/src/context.ts b/packages/express-context/src/context.ts index 82d87de9d3..0ce526cdc6 100644 --- a/packages/express-context/src/context.ts +++ b/packages/express-context/src/context.ts @@ -7,7 +7,7 @@ * - pgSettings (role, claims, request_id, database_id) * - Tenant database pool (via pg-cache) * - withPgClient (transaction-scoped RLS helper) - * - Convenience fields (userId, databaseId, requestId) + * - Convenience/request fact fields (userId, databaseId, siteId, requestId, origin) * - useModule (lazy, on-demand per-database module resolution) * * The result is a single `req.constructive` object that any downstream @@ -36,6 +36,24 @@ export interface ContextMiddlewareOptions { routingSchema?: string; } +/** Derive an origin from the already-routed Express request. */ +export function resolveRequestOrigin(req: Request): string | null { + const host = req.get('host'); + if (!host || (req.protocol !== 'http' && req.protocol !== 'https')) return null; + try { + const url = new URL(`${req.protocol}://${host}`); + return url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ? null + : url.origin; + } catch { + return null; + } +} + /** * Create a `useModule` function bound to the given loader context. * @@ -113,8 +131,10 @@ export function buildContext( token, pgSettings, databaseId: api.databaseId ?? null, + siteId: api.siteId ?? null, userId: token?.user_id ?? null, requestId, + requestOrigin: resolveRequestOrigin(req), pool: tenantPool, withPgClient, useModule, diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 013e195f5d..5a9ec6cb45 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -56,6 +56,7 @@ export type { LlmConfig, PubkeyChallengeSettings, RlsModule, + SsoSurface, WebauthnSettings, WithPgClient, } from './types'; @@ -76,7 +77,11 @@ export { requestIdMiddleware } from './request-id'; // Context middleware export type { ContextMiddlewareOptions } from './context'; -export { buildContext, createContextMiddleware } from './context'; +export { + buildContext, + createContextMiddleware, + resolveRequestOrigin +} from './context'; // Module loaders export type { @@ -103,6 +108,7 @@ export { requireDatabaseId, requireIdentityProvider, rlsLoader, + ssoSurfaceLoader, webauthnLoader, } from './loaders'; diff --git a/packages/express-context/src/loaders/identity-providers.ts b/packages/express-context/src/loaders/identity-providers.ts index 837a8292e3..ac44404280 100644 --- a/packages/express-context/src/loaders/identity-providers.ts +++ b/packages/express-context/src/loaders/identity-providers.ts @@ -28,7 +28,8 @@ import { requireDatabaseId } from './types'; // ─── SQL ──────────────────────────────────────────────────────────────────── const IDENTITY_PROVIDERS_DISCOVERY_SQL = ` - SELECT s.schema_name AS schema_name, m.table_name AS table_name + SELECT s.schema_name AS schema_name, m.table_name AS table_name, + m.scope, m.prefix FROM metaschema_modules_public.identity_providers_module m JOIN metaschema_public.schema s ON s.id = m.private_schema_id WHERE m.database_id = $1 @@ -36,29 +37,37 @@ const IDENTITY_PROVIDERS_DISCOVERY_SQL = ` `; const INTERNAL_SECRETS_DISCOVERY_SQL = ` - SELECT s.schema_name AS schema_name, m.internal_secrets_table_name AS table_name + SELECT s.schema_name AS schema_name, m.internal_secrets_table_name AS table_name, + m.scope, m.prefix FROM metaschema_modules_public.internal_secrets_module m JOIN metaschema_public.schema s ON s.id = m.private_schema_id - WHERE m.database_id = $1 + WHERE m.database_id = $1 AND m.scope = $2 LIMIT 1 `; interface DiscoveredLocation { schema_name: string; table_name: string; + scope: string; + prefix: string; } /** * The providers query, with the tenant's own secret getter inlined. * - * The getter is `_get(name, namespace_id)` in the - * discovered store schema — the same function the auth procedures use, so a - * secret rotated through the platform's rotate verb is picked up with no - * further coordination. A provider whose `client_secret_id` is set but whose - * secret does not resolve yields `clientSecret: null`, which the caller must - * treat as a configuration fault rather than as a public client. + * The getter is the generated internal-secrets getter in the discovered store + * schema — the same function the auth procedures use, so a secret rotated + * through the platform's rotate verb is picked up with no further + * coordination. Database-scoped stores take the current database ID as their + * first argument; app/platform stores do not. A provider whose + * `client_secret_id` is set but whose secret does not resolve yields + * `clientSecret: null`, which the caller must treat as a configuration fault + * rather than as a public client. */ -const buildProvidersQuery = (providers: DiscoveredLocation, secrets: DiscoveredLocation) => ` +const buildProvidersQuery = ( + providers: DiscoveredLocation, + secrets: DiscoveredLocation +) => ` SELECT p.id, p.slug, @@ -68,7 +77,8 @@ const buildProvidersQuery = (providers: DiscoveredLocation, secrets: DiscoveredL p.client_id, CASE WHEN p.client_secret_id IS NULL THEN NULL - ELSE "${secrets.schema_name}"."${secrets.table_name}_get"( + ELSE "${secrets.schema_name}"."${secrets.prefix}_internal_secrets_get"( + ${secrets.scope === 'database' ? '$1,' : ''} p.slug || '/client-secret', uuid_nil() ) @@ -155,16 +165,16 @@ const toProviderConfig = (row: ProviderRow): IdentityProviderConfig => { const discoverOne = async ( ctx: LoaderContext, sql: string, - moduleName: string + values: unknown[] ): Promise => { - const result = await ctx.tenantPool.query(sql, [ctx.databaseId]); + const result = await ctx.tenantPool.query(sql, values); const row = result.rows[0]; if (!row?.schema_name || !row?.table_name) { // Not provisioned for this tenant — the loader contract's undefined. The // module name is kept in the debug trail rather than guessed at by callers. return undefined; } - return { schema_name: row.schema_name, table_name: row.table_name }; + return row; }; // ─── Loader ───────────────────────────────────────────────────────────────── @@ -184,14 +194,14 @@ export const identityProvidersLoader: ModuleLoader = const providers = await discoverOne( ctx, IDENTITY_PROVIDERS_DISCOVERY_SQL, - 'identity_providers_module' + [databaseId] ); if (!providers) return undefined; const secrets = await discoverOne( ctx, INTERNAL_SECRETS_DISCOVERY_SQL, - 'internal_secrets_module' + [databaseId, providers.scope] ); // A provider table without its secret store cannot yield a usable client // secret, and silently returning secret-less providers would present a @@ -203,7 +213,10 @@ export const identityProvidersLoader: ModuleLoader = ); } - const result = await tenantPool.query(buildProvidersQuery(providers, secrets)); + const result = await tenantPool.query( + buildProvidersQuery(providers, secrets), + secrets.scope === 'database' ? [databaseId] : [] + ); const bySlug: Record = {}; for (const row of result.rows) { diff --git a/packages/express-context/src/loaders/index.ts b/packages/express-context/src/loaders/index.ts index a8a3203428..fc7081e870 100644 --- a/packages/express-context/src/loaders/index.ts +++ b/packages/express-context/src/loaders/index.ts @@ -16,6 +16,7 @@ * * Opt-in (not in the default registry, register it explicitly): * - identityProviders (three round trips, decrypts client secrets) + * - ssoSurface (current Tenant's provisioned unified-auth private schema) * * To add a new per-db lookup, implement a ModuleLoader and register it: * @@ -55,6 +56,7 @@ export { inferenceLogLoader } from './inference-log'; export { llmLoader } from './llm'; export { pubkeyLoader } from './pubkey'; export { rlsLoader } from './rls'; +export { ssoSurfaceLoader } from './sso-surface'; export { webauthnLoader } from './webauthn'; /** diff --git a/packages/express-context/src/loaders/sso-surface.ts b/packages/express-context/src/loaders/sso-surface.ts new file mode 100644 index 0000000000..ade9c99211 --- /dev/null +++ b/packages/express-context/src/loaders/sso-surface.ts @@ -0,0 +1,45 @@ +/** + * Unified-auth SSO Surface Loader (Tier 2 — tenant DB) + * + * Resolves only the private schema provisioned for the current database's + * database-scoped unified_auth_module. Procedure names are fixed by the DB + * module contract; policy, Site configuration, and Provider secrets remain in + * their owning loaders/functions. + * + * This loader is opt-in and is not registered by createDefaultRegistry(). + */ + +import type { SsoSurface } from '../types'; +import { createModuleLoader } from './create-loader'; +import type { LoaderContext, ModuleLoader } from './types'; +import { requireDatabaseId } from './types'; + +const SSO_SURFACE_SQL = ` + SELECT private_schema.schema_name AS private_schema + FROM metaschema_modules_public.unified_auth_module unified_auth + JOIN metaschema_public.schema private_schema + ON private_schema.id = unified_auth.private_schema_id + WHERE unified_auth.database_id = $1 + AND unified_auth.scope = 'database' + LIMIT 1 +`; + +interface SsoSurfaceRow { + private_schema: string; +} + +export const ssoSurfaceLoader: ModuleLoader = + createModuleLoader({ + name: 'ssoSurface', + ttlMs: 5 * 60_000, + async resolve(ctx: LoaderContext) { + const { tenantPool, databaseId } = ctx; + requireDatabaseId(databaseId, 'ssoSurface'); + + const result = await tenantPool.query(SSO_SURFACE_SQL, [ + databaseId + ]); + const row = result.rows[0]; + return row ? { privateSchema: row.private_schema } : undefined; + } + }); diff --git a/packages/express-context/src/pg-settings.ts b/packages/express-context/src/pg-settings.ts index cb86336456..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; @@ -64,6 +67,13 @@ export function buildPgSettings(input: PgSettingsInput): Record settings['jwt.claims.api_id'] = api.apiId; } + // Site provenance is an independent trusted routing fact. Multiple Sites + // may share an API, so it must never be reconstructed from api_id, Origin, + // Referer, or token claims. + if (api.siteId) { + settings['jwt.claims.site_id'] = api.siteId; + } + // Distributed tracing settings['request.id'] = requestId; diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 4316018209..8837563563 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -98,6 +98,11 @@ export interface AuthSurface { connectedAccountsView: string; } +/** Current Tenant's provisioned private unified-auth module surface. */ +export interface SsoSurface { + privateSchema: string; +} + /** One identity provider row, with its client secret resolved. */ export interface IdentityProviderConfig { id: string; @@ -136,6 +141,8 @@ export interface IdentityProvidersModule { export interface ApiStructure { apiId?: string; + /** Trusted Site runtime identity emitted by scoped routing, when present. */ + siteId?: string; dbname: string; anonRole: string; roleName: string; @@ -241,6 +248,7 @@ export interface BuiltinModuleMap { databaseSettings: DatabaseSettings; authSettings: AuthSettings; authSurface: AuthSurface; + ssoSurface: SsoSurface; identityProviders: IdentityProvidersModule; pubkeyChallengeSettings: PubkeyChallengeSettings; webauthnSettings: WebauthnSettings; @@ -274,10 +282,14 @@ export interface ConstructiveContext { pgSettings: Record; /** Database UUID from the API resolver */ databaseId: string | null; + /** Trusted Site UUID from the resolved route; never inferred from Origin. */ + siteId: string | null; /** Authenticated user ID from the JWT token */ userId: string | null; /** Per-request correlation ID for distributed tracing */ requestId: string; + /** Server-derived origin of the routed HTTP request. */ + requestOrigin: string | null; /** Tenant database connection pool */ pool: Pool; /** Execute a function within a tenant-scoped RLS transaction */ diff --git a/packages/oauth/README.md b/packages/oauth/README.md index f8c6866041..e38b082639 100644 --- a/packages/oauth/README.md +++ b/packages/oauth/README.md @@ -1,128 +1,69 @@ # @constructive-io/oauth -

- -

- -

- - - - - - - - - -

- -> Minimal OAuth 2.0 client for social authentication - -A lightweight OAuth 2.0 client for social authentication with Google, GitHub, Facebook, and LinkedIn. Uses [`@constructive-io/csrf`](../csrf) for secure state management. No external auth library dependencies - uses native fetch for HTTP requests. - -## Installation - -```bash -pnpm add @constructive-io/oauth -``` - -## Usage - -### Basic Setup - -```typescript -import { createOAuthClient } from '@constructive-io/oauth'; - -const client = createOAuthClient({ - providers: { - google: { - clientId: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - }, - github: { - clientId: process.env.GITHUB_CLIENT_ID, - clientSecret: process.env.GITHUB_CLIENT_SECRET, - }, - }, - baseUrl: 'https://api.example.com', -}); - -// Generate authorization URL -const { url, state } = client.getAuthorizationUrl({ provider: 'google' }); - -// After user authorizes, exchange code for profile -const profile = await client.handleCallback({ provider: 'google', code }); -``` - -### Express Middleware - -```typescript -import express from 'express'; -import cookieParser from 'cookie-parser'; -import { createOAuthMiddleware } from '@constructive-io/oauth'; - -const app = express(); -app.use(cookieParser()); - -const oauth = createOAuthMiddleware({ - providers: { - google: { clientId: '...', clientSecret: '...' }, - github: { clientId: '...', clientSecret: '...' }, - facebook: { clientId: '...', clientSecret: '...' }, - linkedin: { clientId: '...', clientSecret: '...' }, - }, - baseUrl: 'https://api.example.com', - onSuccess: async (profile, context) => { - // Handle successful authentication - // Create/update user in database, generate session token, etc. - return { user: profile }; - }, - onError: (error, context) => { - console.error('OAuth error:', error); - }, - successRedirect: 'https://app.example.com/dashboard', - errorRedirect: 'https://app.example.com/login?error=auth_failed', +Protocol primitives and Provider adapters for Constructive OAuth/OIDC sign-in. + +The package owns: + +- cryptographically random OAuth state, OIDC nonce, and RFC 7636 S256 PKCE; +- authorization URL construction with protected parameters; +- exact Provider endpoint allowlists and bounded, no-redirect JSON requests; +- a protocol-neutral `ProviderAdapter` contract; +- registered Google/OIDC and GitHub/OAuth adapter implementations; and +- safe normalized external identities without raw Provider payloads or tokens. + +It deliberately does not own Express routes, Cookies, Tenant resolution, +database state, account association, Constructive credentials, or Site handoff. +Those remain in their Constructive orchestration owners. + +## Security model + +Every Provider flow uses Authorization Code with S256 PKCE. Constructive creates +and persists the state, verifier, and optional nonce before navigation. Only the +state and S256 challenge reach the browser. The callback supplies the code to +Constructive, and the selected adapter exchanges it with the original +server-held verifier. + +Provider configuration is supplied by the caller after Tenant-scoped loader +resolution. Adapters do not read environment variables or databases. Endpoints +must match the concrete adapter's HTTPS allowlist, requests reject redirects, +and Provider response bodies are never included in errors. + +## Example + +```ts +import { + deriveS256CodeChallenge, + generateCodeVerifier, + generateOidcNonce, + generateOpaqueState, + getProviderAdapter +} from '@constructive-io/oauth'; + +const adapter = getProviderAdapter(provider.slug); +const config = adapter.validateConfiguration(provider); +const state = generateOpaqueState(); +const codeVerifier = generateCodeVerifier(); +const nonce = adapter.kind === 'google' ? generateOidcNonce() : undefined; + +const { url } = adapter.createAuthorizationRequest({ + config, + redirectUri, + state, + codeChallenge: deriveS256CodeChallenge(codeVerifier), + nonce }); - -// Mount routes -app.get('/auth/:provider', oauth.initiateAuth); -app.get('/auth/:provider/callback', oauth.handleCallback); -app.get('/auth/providers', oauth.getProviders); ``` -## Supported Providers +The common service persists the request artifacts before using `url`. It later +calls `completeAuthorization` with the callback code and server-held artifacts, +then consumes only the returned `NormalizedExternalIdentity`. -| Provider | Scopes | -|----------|--------| -| Google | `openid`, `email`, `profile` | -| GitHub | `user:email`, `read:user` | -| Facebook | `email`, `public_profile` | -| LinkedIn | `openid`, `profile`, `email` | +## Legacy surface -## API - -### `createOAuthClient(config)` - -Creates an OAuth client instance. - -### `createOAuthMiddleware(config)` - -Creates Express route handlers for OAuth flows. - -### `OAuthProfile` - -The normalized user profile returned after authentication: - -```typescript -interface OAuthProfile { - provider: string; // 'google', 'github', etc. - providerId: string; // Provider's unique user ID - email: string | null; - name: string | null; - picture: string | null; - raw: unknown; // Original provider response -} -``` +The previous hard-coded Provider registry, Express middleware, +`/auth/providers` discovery handler, browser state Cookie, and raw profile +payload are intentionally not part of this API. Provider discovery belongs to +Tenant-scoped GraphQL, and replay protection belongs to persisted server state. ## License diff --git a/packages/oauth/__tests__/adapters.test.ts b/packages/oauth/__tests__/adapters.test.ts new file mode 100644 index 0000000000..b9d65b6e74 --- /dev/null +++ b/packages/oauth/__tests__/adapters.test.ts @@ -0,0 +1,236 @@ +import { + exportJWK, + generateKeyPair, + type KeyLike, + SignJWT} from 'jose'; + +import { + getProviderAdapter, + getProviderAdapterKinds, + githubAdapter, + googleAdapter, + type IdentityProviderConfiguration, + ProviderAdapterError} from '../src'; + +const providerConfig = ( + overrides: Partial +): IdentityProviderConfiguration => ({ + slug: 'provider', + kind: 'oauth2', + displayName: 'Provider', + enabled: true, + clientId: 'client-id', + clientSecret: 'client-secret', + authorizationUrl: null, + tokenUrl: null, + userinfoUrl: null, + issuerUrl: null, + discoveryDoc: null, + jwks: null, + acceptableClientIds: [], + scopes: [], + extraAuthorizationParams: {}, + emailOptional: true, + skipNonceCheck: false, + pkceEnabled: true, + ...overrides +}); + +const jsonResponse = (value: unknown): Response => + new Response(JSON.stringify(value), { + headers: { 'content-type': 'application/json' } + }); + +describe('Provider adapter registry', () => { + it('registers Google and GitHub without a Provider-specific workflow API', () => { + expect(getProviderAdapterKinds()).toEqual(['google', 'github']); + expect(getProviderAdapter('google')).toBe(googleAdapter); + expect(getProviderAdapter('github')).toBe(githubAdapter); + expect(() => getProviderAdapter('not-registered')).toThrow( + ProviderAdapterError + ); + }); +}); + +describe('Google OIDC adapter', () => { + let privateKey: KeyLike; + let publicJwk: Awaited>; + + beforeAll(async () => { + const pair = await generateKeyPair('RS256'); + privateKey = pair.privateKey; + publicJwk = await exportJWK(pair.publicKey); + publicJwk.kid = 'test-key'; + publicJwk.alg = 'RS256'; + }); + + const googleConfig = (): IdentityProviderConfiguration => + providerConfig({ + slug: 'google', + kind: 'oidc', + displayName: 'Google', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + issuerUrl: 'https://accounts.google.com', + jwks: { keys: [publicJwk] }, + scopes: ['openid', 'email', 'profile'] + }); + + it('builds mandatory S256 and nonce authorization parameters', () => { + const config = googleAdapter.validateConfiguration(googleConfig()); + const url = new URL( + googleAdapter.createAuthorizationRequest({ + config, + redirectUri: 'https://auth.example.com/auth/oauth/callback', + state: 's'.repeat(43), + codeChallenge: 'c'.repeat(43), + nonce: 'n'.repeat(43) + }).url + ); + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + code_challenge: 'c'.repeat(43), + code_challenge_method: 'S256', + nonce: 'n'.repeat(43), + state: 's'.repeat(43) + }); + }); + + it('verifies the ID token and returns only normalized identity data', async () => { + const idToken = await new SignJWT({ + email: 'person@example.com', + email_verified: false, + name: 'Example Person', + nonce: 'n'.repeat(43), + picture: 'https://images.example.com/person.png' + }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer('https://accounts.google.com') + .setAudience('client-id') + .setSubject('google-subject') + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + jsonResponse({ access_token: 'never-return-this', id_token: idToken }) + ) as unknown as jest.MockedFunction; + + const identity = await googleAdapter.completeAuthorization({ + config: googleAdapter.validateConfiguration(googleConfig()), + redirectUri: 'https://auth.example.com/auth/oauth/callback', + code: 'authorization-code', + codeVerifier: 'a'.repeat(43), + nonce: 'n'.repeat(43), + requestTimeoutMs: 1000, + fetch: fetchMock + }); + + expect(identity).toEqual({ + providerKey: 'google', + subject: 'google-subject', + email: 'person@example.com', + profile: { + name: 'Example Person', + avatarUrl: 'https://images.example.com/person.png', + emailVerified: false + } + }); + expect(JSON.stringify(identity)).not.toContain('never-return-this'); + }); + + it('fails closed when PKCE or nonce verification is disabled', () => { + expect(() => + googleAdapter.validateConfiguration( + { ...googleConfig(), pkceEnabled: false } + ) + ).toThrow(ProviderAdapterError); + expect(() => + googleAdapter.validateConfiguration( + { ...googleConfig(), skipNonceCheck: true } + ) + ).toThrow(ProviderAdapterError); + }); + + it('rejects an ID token that is not bound to the original nonce', async () => { + const idToken = await new SignJWT({ nonce: 'different-nonce' }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer('https://accounts.google.com') + .setAudience('client-id') + .setSubject('google-subject') + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + jsonResponse({ id_token: idToken }) + ) as unknown as jest.MockedFunction; + + await expect( + googleAdapter.completeAuthorization({ + config: googleAdapter.validateConfiguration(googleConfig()), + redirectUri: 'https://auth.example.com/auth/oauth/callback', + code: 'authorization-code', + codeVerifier: 'a'.repeat(43), + nonce: 'n'.repeat(43), + requestTimeoutMs: 1000, + fetch: fetchMock + }) + ).rejects.toMatchObject({ reason: 'IDENTITY_VERIFICATION_FAILED' }); + }); +}); + +describe('GitHub OAuth adapter', () => { + const githubConfig = (): IdentityProviderConfiguration => + providerConfig({ + slug: 'github', + displayName: 'GitHub', + authorizationUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + userinfoUrl: 'https://api.github.com/user', + scopes: ['read:user', 'user:email'] + }); + + it('uses server-only access token for profile/email and normalizes stable ID', async () => { + const responses: unknown[] = [ + { access_token: 'github-server-token' }, + { + id: 42, + login: 'octocat', + name: 'Octo Cat', + email: null, + avatar_url: 'https://avatars.githubusercontent.com/u/42' + }, + [{ email: 'octo@example.com', primary: true, verified: true }] + ]; + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + jsonResponse(responses.shift()) + ) as unknown as jest.MockedFunction; + + const identity = await githubAdapter.completeAuthorization({ + config: githubAdapter.validateConfiguration(githubConfig()), + redirectUri: 'https://auth.example.com/auth/oauth/callback', + code: 'authorization-code', + codeVerifier: 'b'.repeat(43), + requestTimeoutMs: 1000, + fetch: fetchMock + }); + + expect(identity).toEqual({ + providerKey: 'github', + subject: '42', + email: 'octo@example.com', + profile: { + name: 'Octo Cat', + username: 'octocat', + avatarUrl: 'https://avatars.githubusercontent.com/u/42', + emailVerified: true + } + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({ + Authorization: 'Bearer github-server-token' + }); + expect(JSON.stringify(identity)).not.toContain('github-server-token'); + }); +}); diff --git a/packages/oauth/__tests__/http.test.ts b/packages/oauth/__tests__/http.test.ts new file mode 100644 index 0000000000..b66249d887 --- /dev/null +++ b/packages/oauth/__tests__/http.test.ts @@ -0,0 +1,91 @@ +import { + ProviderAdapterError, + requestProviderJson, + validateProviderEndpoint +} from '../src'; + +const endpoint = validateProviderEndpoint('https://api.example.com/token', [ + 'https://api.example.com/token' +]); + +describe('bounded Provider requests', () => { + it('uses no-redirect fetch and parses a bounded JSON response', async () => { + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify({ ok: true }), { + headers: { 'content-type': 'application/json' } + }) + ) as unknown as jest.MockedFunction; + + await expect( + requestProviderJson(endpoint, { method: 'POST' }, { + timeoutMs: 1000, + fetch: fetchMock + }) + ).resolves.toEqual({ ok: true }); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: 'POST', + redirect: 'error' + }); + }); + + it('does not expose an unsuccessful Provider response body', async () => { + const secretBody = 'provider-secret-response'; + const fetchMock = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(secretBody, { + status: 400, + headers: { 'content-type': 'application/json' } + }) + ) as unknown as jest.MockedFunction; + + const error = (await requestProviderJson(endpoint, {}, { + timeoutMs: 1000, + fetch: fetchMock + }).catch(value => value)) as ProviderAdapterError; + expect(error).toBeInstanceOf(ProviderAdapterError); + expect(error.reason).toBe('INVALID_RESPONSE'); + expect(error.message).not.toContain(secretBody); + }); + + it('rejects oversized and non-JSON responses', async () => { + const oversized = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response('{}', { + headers: { + 'content-length': '70000', + 'content-type': 'application/json' + } + }) + ) as unknown as jest.MockedFunction; + await expect( + requestProviderJson(endpoint, {}, { timeoutMs: 1000, fetch: oversized }) + ).rejects.toMatchObject({ reason: 'INVALID_RESPONSE' }); + + const wrongType = jest.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response('{}', { headers: { 'content-type': 'text/plain' } }) + ) as unknown as jest.MockedFunction; + await expect( + requestProviderJson(endpoint, {}, { timeoutMs: 1000, fetch: wrongType }) + ).rejects.toMatchObject({ reason: 'INVALID_RESPONSE' }); + }); + + it('classifies timeout without swallowing its cause', async () => { + const fetchMock = jest.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')) + ); + }) + ) as unknown as jest.MockedFunction; + + const error = (await requestProviderJson(endpoint, {}, { + timeoutMs: 1, + fetch: fetchMock + }).catch(value => value)) as ProviderAdapterError; + expect(error).toMatchObject({ reason: 'REQUEST_TIMEOUT' }); + expect(error.cause).toBeInstanceOf(DOMException); + }); +}); diff --git a/packages/oauth/__tests__/oauth-client.test.ts b/packages/oauth/__tests__/oauth-client.test.ts deleted file mode 100644 index 541192de35..0000000000 --- a/packages/oauth/__tests__/oauth-client.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { createOAuthClient } from '../src/oauth-client'; -import { getProvider, getProviderIds } from '../src/providers'; -import { generateState, verifyState } from '../src/utils/state'; - -describe('OAuthClient', () => { - const config = { - providers: { - google: { - clientId: 'test-google-client-id', - clientSecret: 'test-google-client-secret', - }, - github: { - clientId: 'test-github-client-id', - clientSecret: 'test-github-client-secret', - }, - }, - baseUrl: 'https://api.example.com', - }; - - describe('getAuthorizationUrl', () => { - it('should generate authorization URL for Google', () => { - const client = createOAuthClient(config); - const { url, state } = client.getAuthorizationUrl({ provider: 'google' }); - - expect(url).toContain('https://accounts.google.com/o/oauth2/v2/auth'); - expect(url).toContain('client_id=test-google-client-id'); - expect(url).toContain('redirect_uri='); - expect(url).toContain('response_type=code'); - expect(url).toContain('scope=openid+email+profile'); - expect(url).toContain(`state=${state}`); - expect(state).toHaveLength(64); - }); - - it('should generate authorization URL for GitHub', () => { - const client = createOAuthClient(config); - const { url, state } = client.getAuthorizationUrl({ provider: 'github' }); - - expect(url).toContain('https://github.com/login/oauth/authorize'); - expect(url).toContain('client_id=test-github-client-id'); - expect(url).toContain('scope=user%3Aemail+read%3Auser'); - expect(state).toHaveLength(64); - }); - - it('should use custom state when provided', () => { - const client = createOAuthClient(config); - const customState = 'my-custom-state-123'; - const { url, state } = client.getAuthorizationUrl({ - provider: 'google', - state: customState, - }); - - expect(state).toBe(customState); - expect(url).toContain(`state=${customState}`); - }); - - it('should use custom redirect URI when provided', () => { - const client = createOAuthClient(config); - const customRedirectUri = 'https://custom.example.com/callback'; - const { url } = client.getAuthorizationUrl({ - provider: 'google', - redirectUri: customRedirectUri, - }); - - expect(url).toContain(`redirect_uri=${encodeURIComponent(customRedirectUri)}`); - }); - - it('should use custom scopes when provided', () => { - const client = createOAuthClient(config); - const { url } = client.getAuthorizationUrl({ - provider: 'google', - scopes: ['email'], - }); - - expect(url).toContain('scope=email'); - expect(url).not.toContain('profile'); - }); - - it('should throw error for unknown provider', () => { - const client = createOAuthClient(config); - - expect(() => { - client.getAuthorizationUrl({ provider: 'unknown' }); - }).toThrow('Unknown provider: unknown'); - }); - - it('should throw error for unconfigured provider', () => { - const client = createOAuthClient(config); - - expect(() => { - client.getAuthorizationUrl({ provider: 'facebook' }); - }).toThrow('No credentials configured for provider: facebook'); - }); - }); - - describe('getConfig', () => { - it('should return config with defaults', () => { - const client = createOAuthClient(config); - const returnedConfig = client.getConfig(); - - expect(returnedConfig.callbackPath).toBe('/auth/{provider}/callback'); - expect(returnedConfig.stateCookieName).toBe('oauth_state'); - expect(returnedConfig.stateCookieMaxAge).toBe(600); - }); - - it('should allow overriding defaults', () => { - const client = createOAuthClient({ - ...config, - callbackPath: '/custom/callback/{provider}', - stateCookieName: 'custom_state', - stateCookieMaxAge: 300, - }); - const returnedConfig = client.getConfig(); - - expect(returnedConfig.callbackPath).toBe('/custom/callback/{provider}'); - expect(returnedConfig.stateCookieName).toBe('custom_state'); - expect(returnedConfig.stateCookieMaxAge).toBe(300); - }); - }); -}); - -describe('providers', () => { - it('should have all expected providers', () => { - const ids = getProviderIds(); - expect(ids).toContain('google'); - expect(ids).toContain('github'); - expect(ids).toContain('facebook'); - expect(ids).toContain('linkedin'); - }); - - it('should return provider config by id', () => { - const google = getProvider('google'); - expect(google).toBeDefined(); - expect(google!.id).toBe('google'); - expect(google!.name).toBe('Google'); - expect(google!.authorizationUrl).toBe('https://accounts.google.com/o/oauth2/v2/auth'); - }); - - it('should return undefined for unknown provider', () => { - const unknown = getProvider('unknown'); - expect(unknown).toBeUndefined(); - }); -}); - -describe('state utilities', () => { - describe('generateState', () => { - it('should generate random state of default length', () => { - const state = generateState(); - expect(state).toHaveLength(64); - }); - - it('should generate random state of custom length', () => { - const state = generateState(16); - expect(state).toHaveLength(32); - }); - - it('should generate unique states', () => { - const state1 = generateState(); - const state2 = generateState(); - expect(state1).not.toBe(state2); - }); - }); - - describe('verifyState', () => { - it('should return true for matching states', () => { - const state = generateState(); - expect(verifyState(state, state)).toBe(true); - }); - - it('should return false for non-matching states', () => { - const state1 = generateState(); - const state2 = generateState(); - expect(verifyState(state1, state2)).toBe(false); - }); - - it('should return false for undefined expected state', () => { - expect(verifyState(undefined, 'some-state')).toBe(false); - }); - - it('should return false for undefined actual state', () => { - expect(verifyState('some-state', undefined)).toBe(false); - }); - - it('should return false for different length states', () => { - expect(verifyState('short', 'much-longer-state')).toBe(false); - }); - }); -}); - -describe('provider profile mapping', () => { - it('should map Google profile correctly', () => { - const google = getProvider('google')!; - const profile = google.mapProfile({ - sub: '123456789', - email: 'test@gmail.com', - name: 'Test User', - picture: 'https://example.com/photo.jpg', - }); - - expect(profile.provider).toBe('google'); - expect(profile.providerId).toBe('123456789'); - expect(profile.email).toBe('test@gmail.com'); - expect(profile.name).toBe('Test User'); - expect(profile.picture).toBe('https://example.com/photo.jpg'); - }); - - it('should map GitHub profile correctly', () => { - const github = getProvider('github')!; - const profile = github.mapProfile({ - id: 12345, - login: 'testuser', - name: 'Test User', - email: 'test@github.com', - avatar_url: 'https://avatars.githubusercontent.com/u/12345', - }); - - expect(profile.provider).toBe('github'); - expect(profile.providerId).toBe('12345'); - expect(profile.email).toBe('test@github.com'); - expect(profile.name).toBe('Test User'); - expect(profile.picture).toBe('https://avatars.githubusercontent.com/u/12345'); - }); - - it('should map Facebook profile correctly', () => { - const facebook = getProvider('facebook')!; - const profile = facebook.mapProfile({ - id: '987654321', - name: 'Test User', - email: 'test@facebook.com', - picture: { data: { url: 'https://example.com/fb-photo.jpg' } }, - }); - - expect(profile.provider).toBe('facebook'); - expect(profile.providerId).toBe('987654321'); - expect(profile.email).toBe('test@facebook.com'); - expect(profile.name).toBe('Test User'); - expect(profile.picture).toBe('https://example.com/fb-photo.jpg'); - }); - - it('should map LinkedIn profile correctly', () => { - const linkedin = getProvider('linkedin')!; - const profile = linkedin.mapProfile({ - sub: 'linkedin-123', - email: 'test@linkedin.com', - name: 'Test User', - picture: 'https://example.com/li-photo.jpg', - }); - - expect(profile.provider).toBe('linkedin'); - expect(profile.providerId).toBe('linkedin-123'); - expect(profile.email).toBe('test@linkedin.com'); - expect(profile.name).toBe('Test User'); - expect(profile.picture).toBe('https://example.com/li-photo.jpg'); - }); - - it('should handle missing optional fields', () => { - const google = getProvider('google')!; - const profile = google.mapProfile({ - sub: '123456789', - }); - - expect(profile.provider).toBe('google'); - expect(profile.providerId).toBe('123456789'); - expect(profile.email).toBeNull(); - expect(profile.name).toBeNull(); - expect(profile.picture).toBeNull(); - }); -}); diff --git a/packages/oauth/__tests__/primitives.test.ts b/packages/oauth/__tests__/primitives.test.ts new file mode 100644 index 0000000000..7af99eab24 --- /dev/null +++ b/packages/oauth/__tests__/primitives.test.ts @@ -0,0 +1,102 @@ +import { + createAuthorizationUrl, + deriveS256CodeChallenge, + generateCodeVerifier, + generateOidcNonce, + generateOpaqueState, + isOpaqueOAuthValue, + ProviderAdapterError, + validateProviderEndpoint +} from '../src'; + +describe('OAuth protocol primitives', () => { + it('generates unique 32-byte browser-safe values', () => { + const values = [ + generateOpaqueState(), + generateOpaqueState(), + generateCodeVerifier(), + generateOidcNonce() + ]; + expect(new Set(values).size).toBe(values.length); + for (const value of values) { + expect(value).toHaveLength(43); + expect(isOpaqueOAuthValue(value)).toBe(true); + expect(value).toMatch(/^[A-Za-z0-9_-]+$/); + } + }); + + it('derives the RFC 7636 S256 example challenge', () => { + expect( + deriveS256CodeChallenge( + 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + ) + ).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'); + }); + + it('rejects a verifier outside the RFC 7636 shape', () => { + expect(() => deriveS256CodeChallenge('too-short')).toThrow( + ProviderAdapterError + ); + }); +}); + +describe('Provider endpoint and authorization URL safety', () => { + const endpoint = validateProviderEndpoint( + 'https://accounts.example.com/oauth/authorize', + ['https://accounts.example.com/oauth/authorize'] + ); + + it('requires an exact, clean, allowlisted HTTPS endpoint', () => { + for (const unsafe of [ + 'http://accounts.example.com/oauth/authorize', + 'https://accounts.example.com/oauth/authorize?next=unsafe', + 'https://user:secret@accounts.example.com/oauth/authorize', + 'https://127.0.0.1/oauth/authorize', + 'https://[::1]/oauth/authorize', + 'https://accounts.example.com/other' + ]) { + expect(() => + validateProviderEndpoint(unsafe, [ + 'https://accounts.example.com/oauth/authorize' + ]) + ).toThrow(ProviderAdapterError); + } + }); + + it('owns all security-sensitive authorization parameters', () => { + expect(() => + createAuthorizationUrl({ + endpoint, + clientId: 'client-id', + redirectUri: 'https://auth.example.com/auth/oauth/callback', + scopes: ['openid'], + state: 's'.repeat(43), + codeChallenge: 'c'.repeat(43), + extraParameters: { prompt: 'select_account', state: 'overridden' } + }) + ).toThrow(/owned by the OAuth flow/); + + const url = new URL( + createAuthorizationUrl({ + endpoint, + clientId: 'client-id', + redirectUri: 'https://auth.example.com/auth/oauth/callback', + scopes: ['openid', 'email'], + state: 's'.repeat(43), + codeChallenge: 'c'.repeat(43), + nonce: 'n'.repeat(43), + extraParameters: { prompt: 'select_account' } + }) + ); + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + client_id: 'client-id', + code_challenge: 'c'.repeat(43), + code_challenge_method: 'S256', + nonce: 'n'.repeat(43), + prompt: 'select_account', + response_type: 'code', + scope: 'openid email', + state: 's'.repeat(43) + }); + }); +}); diff --git a/packages/oauth/jest.config.js b/packages/oauth/jest.config.js index 047b2ae4ee..610b872561 100644 --- a/packages/oauth/jest.config.js +++ b/packages/oauth/jest.config.js @@ -4,5 +4,6 @@ module.exports = { '^.+\\.tsx?$': ['ts-jest', { useESM: false }], }, testMatch: ['**/__tests__/**/*.test.ts'], + modulePathIgnorePatterns: ['/dist/'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], }; diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 2cfd50f3c0..7ddb16fa0f 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -2,7 +2,7 @@ "name": "@constructive-io/oauth", "version": "0.27.0", "author": "Constructive ", - "description": "OAuth 2.0 client for social authentication (Google, GitHub, Facebook, LinkedIn)", + "description": "OAuth/OIDC protocol primitives and Provider adapters for Constructive authentication", "main": "index.js", "module": "esm/index.js", "types": "index.d.ts", @@ -29,7 +29,7 @@ "test:watch": "jest --watch" }, "dependencies": { - "@constructive-io/csrf": "workspace:^" + "jose": "^5.10.0" }, "devDependencies": { "@types/node": "^22.19.11", @@ -42,9 +42,9 @@ "authentication", "google", "github", - "facebook", - "linkedin", - "social-login", + "oidc", + "pkce", + "provider-adapter", "constructive" ] } diff --git a/packages/oauth/src/adapter.ts b/packages/oauth/src/adapter.ts new file mode 100644 index 0000000000..c61bccdea1 --- /dev/null +++ b/packages/oauth/src/adapter.ts @@ -0,0 +1,25 @@ +import type { + IdentityProviderConfiguration, + NormalizedExternalIdentity, + ProviderAuthorizationInput, + ProviderAuthorizationResult, + ProviderCallbackInput, + ValidatedProviderConfiguration +} from './types'; + +/** + * Protocol-neutral Provider boundary. Common login orchestration remains + * outside adapters and no inheritance hierarchy is required. + */ +export interface ProviderAdapter< + C extends ValidatedProviderConfiguration = ValidatedProviderConfiguration +> { + readonly kind: string; + validateConfiguration(input: IdentityProviderConfiguration): C; + createAuthorizationRequest( + input: ProviderAuthorizationInput + ): ProviderAuthorizationResult; + completeAuthorization( + input: ProviderCallbackInput + ): Promise; +} diff --git a/packages/oauth/src/authorization.ts b/packages/oauth/src/authorization.ts new file mode 100644 index 0000000000..a85e10fac6 --- /dev/null +++ b/packages/oauth/src/authorization.ts @@ -0,0 +1,86 @@ +import { isOpaqueOAuthValue } from './primitives'; +import type { ValidatedEndpoint } from './types'; +import { ProviderAdapterError } from './types'; + +const PROTECTED_PARAMETERS = new Set([ + 'client_id', + 'code_challenge', + 'code_challenge_method', + 'nonce', + 'redirect_uri', + 'response_type', + 'scope', + 'state' +]); + +export interface AuthorizationUrlInput { + endpoint: ValidatedEndpoint; + clientId: string; + redirectUri: string; + scopes: readonly string[]; + state: string; + codeChallenge: string; + nonce?: string; + extraParameters?: Readonly>; +} + +export const validateProviderCallbackUri = (value: string): string => { + let redirectUri: URL; + try { + redirectUri = new URL(value); + } catch (cause) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'The Provider callback URI is invalid.', + { cause } + ); + } + if ( + redirectUri.protocol !== 'https:' || + redirectUri.username || + redirectUri.password || + redirectUri.hash + ) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'The Provider callback URI is invalid.' + ); + } + return redirectUri.toString(); +}; + +export const createAuthorizationUrl = (input: AuthorizationUrlInput): string => { + const redirectUri = validateProviderCallbackUri(input.redirectUri); + if ( + !isOpaqueOAuthValue(input.state) || + !isOpaqueOAuthValue(input.codeChallenge) || + (input.nonce !== undefined && !isOpaqueOAuthValue(input.nonce)) + ) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'The Provider authorization input is invalid.' + ); + } + + const url = new URL(input.endpoint); + for (const [key, value] of Object.entries(input.extraParameters ?? {})) { + if (PROTECTED_PARAMETERS.has(key.toLowerCase())) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + `The Provider parameter "${key}" is owned by the OAuth flow.` + ); + } + url.searchParams.set(key, value); + } + + url.searchParams.set('client_id', input.clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', input.scopes.join(' ')); + url.searchParams.set('state', input.state); + url.searchParams.set('code_challenge', input.codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + if (input.nonce) url.searchParams.set('nonce', input.nonce); + + return url.toString(); +}; diff --git a/packages/oauth/src/endpoint.ts b/packages/oauth/src/endpoint.ts new file mode 100644 index 0000000000..44915ca026 --- /dev/null +++ b/packages/oauth/src/endpoint.ts @@ -0,0 +1,102 @@ +import { isIP } from 'net'; + +import { ProviderAdapterError, type ValidatedEndpoint } from './types'; + +const isUnsafeIpv4 = (hostname: string): boolean => { + const octets = hostname.split('.').map(Number); + if (octets.length !== 4 || octets.some(value => !Number.isInteger(value))) { + return true; + } + const [a, b] = octets; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + a >= 224 + ); +}; + +const isUnsafeIpv6 = (hostname: string): boolean => { + const value = hostname.toLowerCase(); + return ( + value === '::' || + value === '::1' || + value.startsWith('fc') || + value.startsWith('fd') || + /^fe[89ab]/.test(value) || + value.startsWith('ff') + ); +}; + +const isUnsafeHostname = (hostname: string): boolean => { + const normalized = hostname + .toLowerCase() + .replace(/^\[/, '') + .replace(/\]$/, '') + .replace(/\.$/, ''); + if (normalized === 'localhost' || normalized.endsWith('.localhost')) { + return true; + } + const family = isIP(normalized); + return family === 4 + ? isUnsafeIpv4(normalized) + : family === 6 + ? isUnsafeIpv6(normalized) + : false; +}; + +const canonicalEndpoint = (value: string): string => { + let url: URL; + try { + url = new URL(value); + } catch (cause) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Provider endpoint is not a valid URL.', + { cause } + ); + } + + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.search || + url.hash || + isUnsafeHostname(url.hostname) + ) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Provider endpoint is not an approved HTTPS URL.' + ); + } + return url.toString(); +}; + +/** Validate one configured endpoint against a concrete adapter's exact list. */ +export const validateProviderEndpoint = ( + value: string | null | undefined, + allowed: readonly string[] +): ValidatedEndpoint => { + if (!value) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'A required Provider endpoint is not configured.' + ); + } + const endpoint = canonicalEndpoint(value); + const allowlist = allowed.map(canonicalEndpoint); + if (!allowlist.includes(endpoint)) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The configured Provider endpoint is not supported.' + ); + } + return endpoint as ValidatedEndpoint; +}; diff --git a/packages/oauth/src/http.ts b/packages/oauth/src/http.ts new file mode 100644 index 0000000000..cccec0f108 --- /dev/null +++ b/packages/oauth/src/http.ts @@ -0,0 +1,118 @@ +import { + ProviderAdapterError, + type ValidatedEndpoint +} from './types'; + +const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024; + +export interface ProviderJsonRequestOptions { + timeoutMs: number; + fetch?: typeof fetch; + maxResponseBytes?: number; +} + +const readBoundedBody = async ( + response: Response, + maxBytes: number +): Promise => { + const contentLength = response.headers.get('content-length'); + if (contentLength && Number(contentLength) > maxBytes) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider response exceeded the allowed size.' + ); + } + + if (!response.body) return ''; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) { + await reader.cancel(); + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider response exceeded the allowed size.' + ); + } + chunks.push(value); + } + return Buffer.concat(chunks.map(chunk => Buffer.from(chunk))).toString('utf8'); +}; + +/** Bounded, no-redirect JSON request for already allowlisted endpoints. */ +export const requestProviderJson = async ( + endpoint: ValidatedEndpoint, + init: Omit, + options: ProviderJsonRequestOptions +): Promise => { + if (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Provider request timeout is invalid.' + ); + } + + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, options.timeoutMs); + + try { + const response = await (options.fetch ?? fetch)(endpoint, { + ...init, + redirect: 'error', + signal: controller.signal + }); + + if (!response.ok) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider returned an unsuccessful response.', + { status: response.status } + ); + } + + const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; + if ( + !contentType.includes('application/json') && + !contentType.includes('+json') + ) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider returned an unsupported response type.' + ); + } + + const body = await readBoundedBody( + response, + options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES + ); + try { + return JSON.parse(body); + } catch (cause) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider returned invalid JSON.', + { cause } + ); + } + } catch (cause) { + if (cause instanceof ProviderAdapterError) throw cause; + throw new ProviderAdapterError( + timedOut ? 'REQUEST_TIMEOUT' : 'NETWORK_FAILURE', + timedOut + ? 'The Provider request timed out.' + : 'The Provider request failed.', + { cause } + ); + } finally { + clearTimeout(timeout); + } +}; diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 42d1c94d82..0d27d33c3c 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -1,30 +1,39 @@ +export type { ProviderAdapter } from './adapter'; export { - createOAuthMiddleware, - generateState, - OAuthCallbackContext, - OAuthErrorContext, - OAuthMiddlewareConfig, - OAuthRouteHandlers, - verifyState, -} from './middleware/express'; -export { createOAuthClient,OAuthClient } from './oauth-client'; + type AuthorizationUrlInput, + createAuthorizationUrl, + validateProviderCallbackUri} from './authorization'; +export { validateProviderEndpoint } from './endpoint'; export { - facebookProvider, - getProvider, - getProviderIds, - githubProvider, - googleProvider, - linkedinProvider, - providers, + type ProviderJsonRequestOptions, + requestProviderJson} from './http'; +export { + constantTimeEqual, + deriveS256CodeChallenge, + generateCodeVerifier, + generateOidcNonce, + generateOpaqueState, + isOpaqueOAuthValue +} from './primitives'; +export type { + ValidatedGitHubConfiguration, + ValidatedGoogleConfiguration +} from './providers'; +export { + getProviderAdapter, + getProviderAdapterKinds, + githubAdapter, + googleAdapter } from './providers'; export { - AuthorizationUrlParams, - CallbackParams, - createOAuthError, - OAuthClientConfig, - OAuthCredentials, - OAuthError, - OAuthProfile, - OAuthProviderConfig, - TokenResponse, + type IdentityProviderConfiguration, + type NormalizedExternalIdentity, + ProviderAdapterError, + type ProviderAuthorizationInput, + type ProviderAuthorizationResult, + type ProviderCallbackInput, + type ProviderFailureReason, + type SafeExternalProfile, + type ValidatedEndpoint, + type ValidatedProviderConfiguration } from './types'; diff --git a/packages/oauth/src/middleware/express.ts b/packages/oauth/src/middleware/express.ts deleted file mode 100644 index 607d2d76a4..0000000000 --- a/packages/oauth/src/middleware/express.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { OAuthClient } from '../oauth-client'; -import { getProviderIds } from '../providers'; -import { createOAuthError,OAuthClientConfig, OAuthProfile } from '../types'; -import { generateState, verifyState } from '../utils/state'; - -export interface OAuthMiddlewareConfig extends OAuthClientConfig { - onSuccess: (profile: OAuthProfile, context: OAuthCallbackContext) => Promise; - onError?: (error: Error, context: OAuthErrorContext) => void; - successRedirect?: string; - errorRedirect?: string; -} - -export interface OAuthCallbackContext { - provider: string; - profile: OAuthProfile; - query: Record; -} - -export interface OAuthErrorContext { - provider?: string; - error: Error; - query: Record; -} - -export interface OAuthRouteHandlers { - initiateAuth: ( - req: { params: { provider: string }; query: Record }, - res: { - redirect: (url: string) => void; - cookie: (name: string, value: string, options: Record) => void; - status: (code: number) => { json: (data: unknown) => void }; - } - ) => void; - - handleCallback: ( - req: { - params: { provider: string }; - query: Record; - cookies: Record; - }, - res: { - redirect: (url: string) => void; - clearCookie: (name: string) => void; - status: (code: number) => { json: (data: unknown) => void }; - json: (data: unknown) => void; - } - ) => Promise; - - getProviders: ( - req: unknown, - res: { json: (data: unknown) => void } - ) => void; -} - -export function createOAuthMiddleware(config: OAuthMiddlewareConfig): OAuthRouteHandlers { - const client = new OAuthClient(config); - const clientConfig = client.getConfig(); - - const initiateAuth: OAuthRouteHandlers['initiateAuth'] = (req, res) => { - const { provider } = req.params; - - try { - const { url, state } = client.getAuthorizationUrl({ provider }); - - res.cookie(clientConfig.stateCookieName!, state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - maxAge: (clientConfig.stateCookieMaxAge || 600) * 1000, - sameSite: 'lax', - }); - - res.redirect(url); - } catch (error) { - if (config.onError) { - config.onError(error as Error, { - provider, - error: error as Error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', (error as Error).message); - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(400).json({ - error: 'oauth_error', - message: (error as Error).message, - provider, - }); - } - } - }; - - const handleCallback: OAuthRouteHandlers['handleCallback'] = async (req, res) => { - const { provider } = req.params; - const { code, state, error: oauthError, error_description } = req.query as Record< - string, - string - >; - - const storedState = req.cookies[clientConfig.stateCookieName!]; - res.clearCookie(clientConfig.stateCookieName!); - - if (oauthError) { - const error = createOAuthError( - error_description || oauthError, - 'OAUTH_PROVIDER_ERROR', - provider - ); - - if (config.onError) { - config.onError(error, { - provider, - error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', oauthError); - if (error_description) { - errorUrl.searchParams.set('error_description', error_description); - } - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(400).json({ - error: 'oauth_error', - message: error_description || oauthError, - provider, - }); - } - return; - } - - if (!verifyState(storedState, state)) { - const error = createOAuthError('Invalid state parameter', 'INVALID_STATE', provider); - - if (config.onError) { - config.onError(error, { - provider, - error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', 'invalid_state'); - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(400).json({ - error: 'invalid_state', - message: 'Invalid state parameter', - provider, - }); - } - return; - } - - if (!code) { - const error = createOAuthError('Missing authorization code', 'MISSING_CODE', provider); - - if (config.onError) { - config.onError(error, { - provider, - error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', 'missing_code'); - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(400).json({ - error: 'missing_code', - message: 'Missing authorization code', - provider, - }); - } - return; - } - - try { - const profile = await client.handleCallback({ provider, code }); - - const result = await config.onSuccess(profile, { - provider, - profile, - query: req.query as Record, - }); - - if (config.successRedirect) { - res.redirect(config.successRedirect); - } else { - res.json({ success: true, data: result }); - } - } catch (error) { - if (config.onError) { - config.onError(error as Error, { - provider, - error: error as Error, - query: req.query as Record, - }); - } - - if (config.errorRedirect) { - const errorUrl = new URL(config.errorRedirect); - errorUrl.searchParams.set('error', 'callback_failed'); - errorUrl.searchParams.set('message', (error as Error).message); - errorUrl.searchParams.set('provider', provider); - res.redirect(errorUrl.toString()); - } else { - res.status(500).json({ - error: 'callback_failed', - message: (error as Error).message, - provider, - }); - } - } - }; - - const getProviders: OAuthRouteHandlers['getProviders'] = (_req, res) => { - const configuredProviders = Object.keys(config.providers); - const availableProviders = getProviderIds().filter((id) => configuredProviders.includes(id)); - res.json({ providers: availableProviders }); - }; - - return { - initiateAuth, - handleCallback, - getProviders, - }; -} - -export { generateState, verifyState }; diff --git a/packages/oauth/src/oauth-client.ts b/packages/oauth/src/oauth-client.ts deleted file mode 100644 index cbdd348e50..0000000000 --- a/packages/oauth/src/oauth-client.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { extractPrimaryEmail,getProvider, GITHUB_EMAILS_URL } from './providers'; -import { - AuthorizationUrlParams, - CallbackParams, - createOAuthError, - OAuthClientConfig, - OAuthProfile, - TokenResponse, -} from './types'; -import { generateState } from './utils/state'; - -export class OAuthClient { - private config: OAuthClientConfig; - - constructor(config: OAuthClientConfig) { - this.config = { - callbackPath: '/auth/{provider}/callback', - stateCookieName: 'oauth_state', - stateCookieMaxAge: 600, - ...config, - }; - } - - getAuthorizationUrl(params: AuthorizationUrlParams): { url: string; state: string } { - const { provider: providerId, state: customState, redirectUri, scopes } = params; - - const provider = getProvider(providerId); - if (!provider) { - throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId); - } - - const credentials = this.config.providers[providerId]; - if (!credentials) { - throw createOAuthError( - `No credentials configured for provider: ${providerId}`, - 'MISSING_CREDENTIALS', - providerId - ); - } - - const state = customState || generateState(); - const callbackUrl = this.getCallbackUrl(providerId, redirectUri || credentials.redirectUri); - const effectiveScopes = scopes || provider.scopes; - - const url = new URL(provider.authorizationUrl); - url.searchParams.set('client_id', credentials.clientId); - url.searchParams.set('redirect_uri', callbackUrl); - url.searchParams.set('response_type', 'code'); - url.searchParams.set('scope', effectiveScopes.join(' ')); - url.searchParams.set('state', state); - - return { url: url.toString(), state }; - } - - async exchangeCode(params: CallbackParams): Promise { - const { provider: providerId, code, redirectUri } = params; - - const provider = getProvider(providerId); - if (!provider) { - throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId); - } - - const credentials = this.config.providers[providerId]; - if (!credentials) { - throw createOAuthError( - `No credentials configured for provider: ${providerId}`, - 'MISSING_CREDENTIALS', - providerId - ); - } - - const callbackUrl = this.getCallbackUrl(providerId, redirectUri || credentials.redirectUri); - - const body: Record = { - client_id: credentials.clientId, - client_secret: credentials.clientSecret, - code, - redirect_uri: callbackUrl, - grant_type: 'authorization_code', - }; - - const headers: Record = { - Accept: 'application/json', - }; - - let requestBody: string; - if (provider.tokenRequestContentType === 'json') { - headers['Content-Type'] = 'application/json'; - requestBody = JSON.stringify(body); - } else { - headers['Content-Type'] = 'application/x-www-form-urlencoded'; - requestBody = new URLSearchParams(body).toString(); - } - - const response = await fetch(provider.tokenUrl, { - method: 'POST', - headers, - body: requestBody, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw createOAuthError( - `Token exchange failed: ${errorText}`, - 'TOKEN_EXCHANGE_FAILED', - providerId, - response.status - ); - } - - const data = await response.json(); - - if (data.error) { - throw createOAuthError( - `Token exchange error: ${data.error_description || data.error}`, - 'TOKEN_EXCHANGE_ERROR', - providerId - ); - } - - return data as TokenResponse; - } - - async getUserProfile(providerId: string, accessToken: string): Promise { - const provider = getProvider(providerId); - if (!provider) { - throw createOAuthError(`Unknown provider: ${providerId}`, 'UNKNOWN_PROVIDER', providerId); - } - - const headers: Record = { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }; - - if (providerId === 'github') { - headers['User-Agent'] = 'Constructive-OAuth'; - } - - const response = await fetch(provider.userInfoUrl, { - method: provider.userInfoMethod || 'GET', - headers, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw createOAuthError( - `Failed to fetch user profile: ${errorText}`, - 'USER_PROFILE_FAILED', - providerId, - response.status - ); - } - - const data = await response.json(); - let profile = provider.mapProfile(data); - - if (providerId === 'github' && !profile.email) { - profile = await this.fetchGitHubEmail(accessToken, profile); - } - - return profile; - } - - async handleCallback(params: CallbackParams): Promise { - const tokens = await this.exchangeCode(params); - return this.getUserProfile(params.provider, tokens.access_token); - } - - private async fetchGitHubEmail( - accessToken: string, - profile: OAuthProfile - ): Promise { - try { - const response = await fetch(GITHUB_EMAILS_URL, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - 'User-Agent': 'Constructive-OAuth', - }, - }); - - if (response.ok) { - const emails = await response.json(); - const email = extractPrimaryEmail(emails); - if (email) { - return { ...profile, email }; - } - } - } catch { - // Ignore email fetch errors, return profile without email - } - return profile; - } - - private getCallbackUrl(providerId: string, customRedirectUri?: string): string { - if (customRedirectUri) { - return customRedirectUri; - } - const path = this.config.callbackPath!.replace('{provider}', providerId); - return `${this.config.baseUrl}${path}`; - } - - getConfig(): OAuthClientConfig { - return this.config; - } -} - -export function createOAuthClient(config: OAuthClientConfig): OAuthClient { - return new OAuthClient(config); -} diff --git a/packages/oauth/src/primitives.ts b/packages/oauth/src/primitives.ts new file mode 100644 index 0000000000..c510da7e00 --- /dev/null +++ b/packages/oauth/src/primitives.ts @@ -0,0 +1,35 @@ +import { createHash, randomBytes, timingSafeEqual } from 'crypto'; + +import { ProviderAdapterError } from './types'; + +const BASE64URL_VALUE = /^[A-Za-z0-9_-]+$/; +const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/; + +export const generateOpaqueState = (): string => randomBytes(32).toString('base64url'); + +export const generateCodeVerifier = (): string => randomBytes(32).toString('base64url'); + +export const generateOidcNonce = (): string => randomBytes(32).toString('base64url'); + +export const deriveS256CodeChallenge = (verifier: string): string => { + if (!PKCE_VERIFIER.test(verifier)) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'The PKCE verifier does not satisfy RFC 7636.' + ); + } + return createHash('sha256').update(verifier, 'ascii').digest('base64url'); +}; + +export const isOpaqueOAuthValue = (value: string, byteLength = 32): boolean => + value.length === Math.ceil((byteLength * 4) / 3) && + BASE64URL_VALUE.test(value); + +export const constantTimeEqual = (expected: string, actual: string): boolean => { + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(actual); + return ( + expectedBuffer.length === actualBuffer.length && + timingSafeEqual(expectedBuffer, actualBuffer) + ); +}; diff --git a/packages/oauth/src/providers/common.ts b/packages/oauth/src/providers/common.ts new file mode 100644 index 0000000000..387483e8f8 --- /dev/null +++ b/packages/oauth/src/providers/common.ts @@ -0,0 +1,105 @@ +import { + type IdentityProviderConfiguration, + ProviderAdapterError, + type SafeExternalProfile, + type ValidatedEndpoint, + type ValidatedProviderConfiguration +} from '../types'; + +export const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const optionalString = ( + input: Record, + key: string +): string | undefined => { + const value = input[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +}; + +export const requiredString = ( + input: Record, + key: string +): string => { + const value = optionalString(input, key); + if (!value) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'The Provider response is missing a required value.' + ); + } + return value; +}; + +export const configurationValue = ( + config: IdentityProviderConfiguration, + direct: string | null, + discoveryKey: string +): string | null => { + if (direct) return direct; + const discovered = config.discoveryDoc?.[discoveryKey]; + return typeof discovered === 'string' ? discovered : null; +}; + +export const validateCommonConfiguration = ( + input: IdentityProviderConfiguration, + adapterKind: string, + authorizationEndpoint: ValidatedEndpoint, + tokenEndpoint: ValidatedEndpoint +): ValidatedProviderConfiguration => { + if (!input.enabled || !input.clientId || !input.clientSecret) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The selected Provider is not enabled or is missing credentials.' + ); + } + if (!input.pkceEnabled) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'S256 PKCE is required for every Provider.' + ); + } + if (!input.scopes.length) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The selected Provider has no configured scopes.' + ); + } + return { + adapterKind, + providerKey: input.slug, + displayName: input.displayName, + clientId: input.clientId, + clientSecret: input.clientSecret, + authorizationEndpoint, + tokenEndpoint, + scopes: [...input.scopes], + extraAuthorizationParams: { ...input.extraAuthorizationParams } + }; +}; + +export const safeProfileValue = ( + value: unknown, + maxLength = 512 +): string | undefined => + typeof value === 'string' && value.length > 0 && value.length <= maxLength + ? value + : undefined; + +export const safeAvatarUrl = (value: unknown): string | undefined => { + const candidate = safeProfileValue(value, 2048); + if (!candidate) return undefined; + try { + const url = new URL(candidate); + return url.protocol === 'https:' && !url.username && !url.password + ? url.toString() + : undefined; + } catch { + return undefined; + } +}; + +export const compactProfile = (profile: SafeExternalProfile): SafeExternalProfile => + Object.fromEntries( + Object.entries(profile).filter(([, value]) => value !== undefined) + ) as SafeExternalProfile; diff --git a/packages/oauth/src/providers/facebook.ts b/packages/oauth/src/providers/facebook.ts deleted file mode 100644 index 41ed451bff..0000000000 --- a/packages/oauth/src/providers/facebook.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { OAuthProfile,OAuthProviderConfig } from '../types'; - -interface FacebookProfile { - id: string; - name?: string; - email?: string; - picture?: { - data?: { - url?: string; - }; - }; -} - -const FACEBOOK_API_VERSION = 'v18.0'; - -export const facebookProvider: OAuthProviderConfig = { - id: 'facebook', - name: 'Facebook', - authorizationUrl: `https://www.facebook.com/${FACEBOOK_API_VERSION}/dialog/oauth`, - tokenUrl: `https://graph.facebook.com/${FACEBOOK_API_VERSION}/oauth/access_token`, - userInfoUrl: `https://graph.facebook.com/me?fields=id,name,email,picture`, - scopes: ['email', 'public_profile'], - tokenRequestContentType: 'form', - mapProfile: (data: unknown): OAuthProfile => { - const profile = data as FacebookProfile; - return { - provider: 'facebook', - providerId: profile.id, - email: profile.email || null, - name: profile.name || null, - picture: profile.picture?.data?.url || null, - raw: data, - }; - }, -}; diff --git a/packages/oauth/src/providers/github.ts b/packages/oauth/src/providers/github.ts index 72ec51c4aa..f59047ce0c 100644 --- a/packages/oauth/src/providers/github.ts +++ b/packages/oauth/src/providers/github.ts @@ -1,46 +1,207 @@ -import { OAuthProfile,OAuthProviderConfig } from '../types'; - -interface GitHubProfile { - id: number; - login: string; - name?: string; - email?: string; - avatar_url?: string; -} +import type { ProviderAdapter } from '../adapter'; +import { + createAuthorizationUrl, + validateProviderCallbackUri +} from '../authorization'; +import { validateProviderEndpoint } from '../endpoint'; +import { requestProviderJson } from '../http'; +import { deriveS256CodeChallenge } from '../primitives'; +import { + type IdentityProviderConfiguration, + type NormalizedExternalIdentity, + ProviderAdapterError, + type ValidatedEndpoint, + type ValidatedProviderConfiguration +} from '../types'; +import { + compactProfile, + configurationValue, + isRecord, + requiredString, + safeAvatarUrl, + safeProfileValue, + validateCommonConfiguration +} from './common'; + +const GITHUB_AUTHORIZATION_ENDPOINTS = [ + 'https://github.com/login/oauth/authorize' +] as const; +const GITHUB_TOKEN_ENDPOINTS = [ + 'https://github.com/login/oauth/access_token' +] as const; +const GITHUB_USER_ENDPOINTS = ['https://api.github.com/user'] as const; +const GITHUB_EMAIL_ENDPOINTS = ['https://api.github.com/user/emails'] as const; -interface GitHubEmail { - email: string; - primary: boolean; - verified: boolean; +export interface ValidatedGitHubConfiguration + extends ValidatedProviderConfiguration { + userEndpoint: ValidatedEndpoint; + emailEndpoint: ValidatedEndpoint; } -export const githubProvider: OAuthProviderConfig = { - id: 'github', - name: 'GitHub', - authorizationUrl: 'https://github.com/login/oauth/authorize', - tokenUrl: 'https://github.com/login/oauth/access_token', - userInfoUrl: 'https://api.github.com/user', - scopes: ['user:email', 'read:user'], - tokenRequestContentType: 'json', - mapProfile: (data: unknown): OAuthProfile => { - const profile = data as GitHubProfile; - return { - provider: 'github', - providerId: String(profile.id), - email: profile.email || null, - name: profile.name || profile.login || null, - picture: profile.avatar_url || null, - raw: data, - }; - }, +const validateGitHubConfiguration = ( + input: IdentityProviderConfiguration +): ValidatedGitHubConfiguration => { + const authorizationEndpoint = validateProviderEndpoint( + configurationValue(input, input.authorizationUrl, 'authorization_endpoint'), + GITHUB_AUTHORIZATION_ENDPOINTS + ); + const tokenEndpoint = validateProviderEndpoint( + configurationValue(input, input.tokenUrl, 'token_endpoint'), + GITHUB_TOKEN_ENDPOINTS + ); + const userEndpoint = validateProviderEndpoint( + configurationValue(input, input.userinfoUrl, 'userinfo_endpoint'), + GITHUB_USER_ENDPOINTS + ); + const configuredEmailEndpoint = configurationValue( + input, + null, + 'emails_endpoint' + ); + const emailEndpoint = validateProviderEndpoint( + configuredEmailEndpoint ?? `${userEndpoint}/emails`, + GITHUB_EMAIL_ENDPOINTS + ); + + return { + ...validateCommonConfiguration( + input, + 'github', + authorizationEndpoint, + tokenEndpoint + ), + userEndpoint, + emailEndpoint + }; }; -export const GITHUB_EMAILS_URL = 'https://api.github.com/user/emails'; +const githubHeaders = (accessToken?: string): Record => ({ + Accept: 'application/vnd.github+json', + 'User-Agent': 'Constructive-OAuth', + ...(accessToken && { Authorization: `Bearer ${accessToken}` }) +}); -export function extractPrimaryEmail(emails: GitHubEmail[]): string | null { - const primary = emails.find((e) => e.primary && e.verified); - if (primary) return primary.email; - const verified = emails.find((e) => e.verified); - if (verified) return verified.email; - return emails[0]?.email || null; -} +const findEmail = ( + response: unknown +): { email?: string; verified?: boolean } => { + if (!Array.isArray(response)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'GitHub returned an invalid email response.' + ); + } + const emails = response.filter(isRecord); + const selected = + emails.find(value => value.primary === true && value.verified === true) ?? + emails.find(value => value.verified === true) ?? + emails.find(value => typeof value.email === 'string'); + return selected + ? { + email: safeProfileValue(selected.email), + verified: + typeof selected.verified === 'boolean' ? selected.verified : undefined + } + : {}; +}; + +export const githubAdapter: ProviderAdapter = { + kind: 'github', + + validateConfiguration: validateGitHubConfiguration, + + createAuthorizationRequest: input => ({ + url: createAuthorizationUrl({ + endpoint: input.config.authorizationEndpoint, + clientId: input.config.clientId, + redirectUri: input.redirectUri, + scopes: input.config.scopes, + state: input.state, + codeChallenge: input.codeChallenge, + extraParameters: input.config.extraAuthorizationParams + }) + }), + + completeAuthorization: async input => { + deriveS256CodeChallenge(input.codeVerifier); + validateProviderCallbackUri(input.redirectUri); + if (!input.code) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'GitHub callback verification requires an authorization code.' + ); + } + const tokenResponse = await requestProviderJson( + input.config.tokenEndpoint, + { + method: 'POST', + headers: { + ...githubHeaders(), + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: input.config.clientId, + client_secret: input.config.clientSecret, + code: input.code, + code_verifier: input.codeVerifier, + redirect_uri: input.redirectUri + }).toString() + }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ); + if (!isRecord(tokenResponse)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'GitHub returned an invalid token response.' + ); + } + const accessToken = requiredString(tokenResponse, 'access_token'); + + const user = await requestProviderJson( + input.config.userEndpoint, + { headers: githubHeaders(accessToken) }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ); + if (!isRecord(user)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'GitHub returned an invalid profile response.' + ); + } + const id = user.id; + if ( + (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) && + (typeof id !== 'string' || !id) + ) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'GitHub returned an invalid stable identifier.' + ); + } + + let email = safeProfileValue(user.email); + let emailVerified: boolean | undefined; + if (!email) { + const emailResult = findEmail( + await requestProviderJson( + input.config.emailEndpoint, + { headers: githubHeaders(accessToken) }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ) + ); + email = emailResult.email; + emailVerified = emailResult.verified; + } + + return { + providerKey: input.config.providerKey, + subject: String(id), + email, + profile: compactProfile({ + name: safeProfileValue(user.name), + username: safeProfileValue(user.login), + avatarUrl: safeAvatarUrl(user.avatar_url), + emailVerified + }) + } satisfies NormalizedExternalIdentity; + } +}; diff --git a/packages/oauth/src/providers/google.ts b/packages/oauth/src/providers/google.ts index dd1c399bb8..8e87f5db0a 100644 --- a/packages/oauth/src/providers/google.ts +++ b/packages/oauth/src/providers/google.ts @@ -1,32 +1,235 @@ -import { OAuthProfile,OAuthProviderConfig } from '../types'; - -interface GoogleProfile { - sub: string; - email?: string; - email_verified?: boolean; - name?: string; - given_name?: string; - family_name?: string; - picture?: string; +import { + createLocalJWKSet, + type JSONWebKeySet, + jwtVerify +} from 'jose'; + +import type { ProviderAdapter } from '../adapter'; +import { + createAuthorizationUrl, + validateProviderCallbackUri +} from '../authorization'; +import { validateProviderEndpoint } from '../endpoint'; +import { requestProviderJson } from '../http'; +import { deriveS256CodeChallenge } from '../primitives'; +import { + type IdentityProviderConfiguration, + type NormalizedExternalIdentity, + ProviderAdapterError, + type ValidatedEndpoint, + type ValidatedProviderConfiguration +} from '../types'; +import { + compactProfile, + configurationValue, + isRecord, + optionalString, + safeAvatarUrl, + safeProfileValue, + validateCommonConfiguration +} from './common'; + +const GOOGLE_AUTHORIZATION_ENDPOINTS = [ + 'https://accounts.google.com/o/oauth2/v2/auth' +] as const; +const GOOGLE_TOKEN_ENDPOINTS = ['https://oauth2.googleapis.com/token'] as const; +const GOOGLE_ISSUERS = ['https://accounts.google.com'] as const; +const GOOGLE_JWKS_ENDPOINTS = [ + 'https://www.googleapis.com/oauth2/v3/certs', + 'https://www.googleapis.com/oauth2/v1/certs' +] as const; + +export interface ValidatedGoogleConfiguration + extends ValidatedProviderConfiguration { + issuer: string; + acceptableAudiences: readonly string[]; + jwks?: JSONWebKeySet; + jwksEndpoint?: ValidatedEndpoint; } -export const googleProvider: OAuthProviderConfig = { - id: 'google', - name: 'Google', - authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', - tokenUrl: 'https://oauth2.googleapis.com/token', - userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo', - scopes: ['openid', 'email', 'profile'], - tokenRequestContentType: 'form', - mapProfile: (data: unknown): OAuthProfile => { - const profile = data as GoogleProfile; +const validateJwks = (value: Record | null): JSONWebKeySet | undefined => { + if (!value) return undefined; + if (!Array.isArray(value.keys)) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Google Provider JWKS configuration is invalid.' + ); + } + return value as unknown as JSONWebKeySet; +}; + +const validateGoogleConfiguration = ( + input: IdentityProviderConfiguration +): ValidatedGoogleConfiguration => { + if (input.skipNonceCheck) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'OIDC nonce verification is required for Google.' + ); + } + if (!input.scopes.includes('openid')) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Google Provider must include the openid scope.' + ); + } + + const authorizationEndpoint = validateProviderEndpoint( + configurationValue(input, input.authorizationUrl, 'authorization_endpoint'), + GOOGLE_AUTHORIZATION_ENDPOINTS + ); + const tokenEndpoint = validateProviderEndpoint( + configurationValue(input, input.tokenUrl, 'token_endpoint'), + GOOGLE_TOKEN_ENDPOINTS + ); + const issuerEndpoint = validateProviderEndpoint( + configurationValue(input, input.issuerUrl, 'issuer'), + GOOGLE_ISSUERS + ); + const jwks = validateJwks(input.jwks); + const jwksValue = configurationValue(input, null, 'jwks_uri'); + const jwksEndpoint = jwksValue + ? validateProviderEndpoint(jwksValue, GOOGLE_JWKS_ENDPOINTS) + : undefined; + if (!jwks && !jwksEndpoint) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The Google Provider has no configured JWKS source.' + ); + } + + return { + ...validateCommonConfiguration( + input, + 'google', + authorizationEndpoint, + tokenEndpoint + ), + issuer: issuerEndpoint.replace(/\/$/, ''), + acceptableAudiences: [input.clientId, ...input.acceptableClientIds], + jwks, + jwksEndpoint + }; +}; + +export const googleAdapter: ProviderAdapter = { + kind: 'google', + + validateConfiguration: validateGoogleConfiguration, + + createAuthorizationRequest: input => { + if (!input.nonce) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'Google authorization requires an OIDC nonce.' + ); + } return { - provider: 'google', - providerId: profile.sub, - email: profile.email || null, - name: profile.name || null, - picture: profile.picture || null, - raw: data, + url: createAuthorizationUrl({ + endpoint: input.config.authorizationEndpoint, + clientId: input.config.clientId, + redirectUri: input.redirectUri, + scopes: input.config.scopes, + state: input.state, + codeChallenge: input.codeChallenge, + nonce: input.nonce, + extraParameters: input.config.extraAuthorizationParams + }) }; }, + + completeAuthorization: async input => { + deriveS256CodeChallenge(input.codeVerifier); + validateProviderCallbackUri(input.redirectUri); + if (!input.code || !input.nonce) { + throw new ProviderAdapterError( + 'INVALID_AUTHORIZATION_INPUT', + 'Google callback verification requires a code and the original nonce.' + ); + } + + const tokenResponse = await requestProviderJson( + input.config.tokenEndpoint, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: input.config.clientId, + client_secret: input.config.clientSecret, + code: input.code, + code_verifier: input.codeVerifier, + grant_type: 'authorization_code', + redirect_uri: input.redirectUri + }).toString() + }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ); + if (!isRecord(tokenResponse)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'Google returned an invalid token response.' + ); + } + const identityToken = optionalString(tokenResponse, 'id_token'); + if (!identityToken) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'Google did not return an identity token.' + ); + } + + let jwks = input.config.jwks; + if (!jwks && input.config.jwksEndpoint) { + const remote = await requestProviderJson( + input.config.jwksEndpoint, + { headers: { Accept: 'application/json' } }, + { timeoutMs: input.requestTimeoutMs, fetch: input.fetch } + ); + if (!isRecord(remote) || !Array.isArray(remote.keys)) { + throw new ProviderAdapterError( + 'INVALID_RESPONSE', + 'Google returned an invalid JWKS response.' + ); + } + jwks = remote as unknown as JSONWebKeySet; + } + + try { + const { payload } = await jwtVerify(identityToken, createLocalJWKSet(jwks!), { + algorithms: ['RS256'], + audience: [...input.config.acceptableAudiences], + issuer: input.config.issuer + }); + if (payload.nonce !== input.nonce || typeof payload.sub !== 'string') { + throw new ProviderAdapterError( + 'IDENTITY_VERIFICATION_FAILED', + 'Google identity verification failed.' + ); + } + + return { + providerKey: input.config.providerKey, + subject: payload.sub, + email: safeProfileValue(payload.email), + profile: compactProfile({ + name: safeProfileValue(payload.name), + avatarUrl: safeAvatarUrl(payload.picture), + emailVerified: + typeof payload.email_verified === 'boolean' + ? payload.email_verified + : undefined + }) + } satisfies NormalizedExternalIdentity; + } catch (cause) { + if (cause instanceof ProviderAdapterError) throw cause; + throw new ProviderAdapterError( + 'IDENTITY_VERIFICATION_FAILED', + 'Google identity verification failed.', + { cause } + ); + } + } }; diff --git a/packages/oauth/src/providers/index.ts b/packages/oauth/src/providers/index.ts index 23927d410c..2415066ff9 100644 --- a/packages/oauth/src/providers/index.ts +++ b/packages/oauth/src/providers/index.ts @@ -1,29 +1,28 @@ -import { OAuthProviderConfig } from '../types'; -import { facebookProvider } from './facebook'; -import { extractPrimaryEmail,GITHUB_EMAILS_URL, githubProvider } from './github'; -import { googleProvider } from './google'; -import { linkedinProvider } from './linkedin'; +import type { ProviderAdapter } from '../adapter'; +import { ProviderAdapterError } from '../types'; +import { githubAdapter } from './github'; +import { googleAdapter } from './google'; -export const providers: Record = { - google: googleProvider, - github: githubProvider, - facebook: facebookProvider, - linkedin: linkedinProvider, -}; +const providerAdapters = new Map([ + [googleAdapter.kind, googleAdapter as ProviderAdapter], + [githubAdapter.kind, githubAdapter as ProviderAdapter] +]); -export function getProvider(id: string): OAuthProviderConfig | undefined { - return providers[id]; -} +export const getProviderAdapter = (providerKey: string): ProviderAdapter => { + const adapter = providerAdapters.get(providerKey); + if (!adapter) { + throw new ProviderAdapterError( + 'INVALID_CONFIGURATION', + 'The selected identity Provider is not supported.' + ); + } + return adapter; +}; -export function getProviderIds(): string[] { - return Object.keys(providers); -} +export const getProviderAdapterKinds = (): readonly string[] => + [...providerAdapters.keys()]; -export { - extractPrimaryEmail, - facebookProvider, - GITHUB_EMAILS_URL, - githubProvider, - googleProvider, - linkedinProvider, -}; +export type { ValidatedGitHubConfiguration } from './github'; +export { githubAdapter } from './github'; +export type { ValidatedGoogleConfiguration } from './google'; +export { googleAdapter } from './google'; diff --git a/packages/oauth/src/providers/linkedin.ts b/packages/oauth/src/providers/linkedin.ts deleted file mode 100644 index 9050c9be6a..0000000000 --- a/packages/oauth/src/providers/linkedin.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { OAuthProfile,OAuthProviderConfig } from '../types'; - -interface LinkedInProfile { - sub: string; - email?: string; - email_verified?: boolean; - name?: string; - given_name?: string; - family_name?: string; - picture?: string; -} - -export const linkedinProvider: OAuthProviderConfig = { - id: 'linkedin', - name: 'LinkedIn', - authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization', - tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken', - userInfoUrl: 'https://api.linkedin.com/v2/userinfo', - scopes: ['openid', 'profile', 'email'], - tokenRequestContentType: 'form', - mapProfile: (data: unknown): OAuthProfile => { - const profile = data as LinkedInProfile; - return { - provider: 'linkedin', - providerId: profile.sub, - email: profile.email || null, - name: profile.name || null, - picture: profile.picture || null, - raw: data, - }; - }, -}; diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts index db4ef07437..2c99238a54 100644 --- a/packages/oauth/src/types.ts +++ b/packages/oauth/src/types.ts @@ -1,74 +1,108 @@ -export interface OAuthProviderConfig { - id: string; - name: string; - authorizationUrl: string; - tokenUrl: string; - userInfoUrl: string; +export interface IdentityProviderConfiguration { + slug: string; + kind: string; + displayName: string; + enabled: boolean; + clientId: string; + clientSecret: string | null; + authorizationUrl: string | null; + tokenUrl: string | null; + userinfoUrl: string | null; + issuerUrl: string | null; + discoveryDoc: Record | null; + jwks: Record | null; + acceptableClientIds: string[]; scopes: string[]; - tokenRequestContentType?: 'json' | 'form'; - userInfoMethod?: 'GET' | 'POST'; - mapProfile: (data: unknown) => OAuthProfile; + extraAuthorizationParams: Record; + emailOptional: boolean; + skipNonceCheck: boolean; + pkceEnabled: boolean; } -export interface OAuthProfile { - provider: string; - providerId: string; - email: string | null; - name: string | null; - picture: string | null; - raw: unknown; -} +declare const validatedEndpoint: unique symbol; -export interface OAuthCredentials { +/** An HTTPS endpoint that has passed a concrete adapter's exact allowlist. */ +export type ValidatedEndpoint = string & { + readonly [validatedEndpoint]: true; +}; + +export interface ValidatedProviderConfiguration { + adapterKind: string; + providerKey: string; + displayName: string; clientId: string; clientSecret: string; - redirectUri?: string; + authorizationEndpoint: ValidatedEndpoint; + tokenEndpoint: ValidatedEndpoint; + scopes: readonly string[]; + extraAuthorizationParams: Readonly>; } -export interface OAuthClientConfig { - providers: Record; - baseUrl: string; - callbackPath?: string; - stateCookieName?: string; - stateCookieMaxAge?: number; +export interface ProviderAuthorizationInput< + C extends ValidatedProviderConfiguration = ValidatedProviderConfiguration +> { + config: C; + redirectUri: string; + state: string; + codeChallenge: string; + nonce?: string; } -export interface TokenResponse { - access_token: string; - token_type: string; - expires_in?: number; - refresh_token?: string; - scope?: string; +export interface ProviderAuthorizationResult { + url: string; } -export interface AuthorizationUrlParams { - provider: string; - state?: string; - redirectUri?: string; - scopes?: string[]; +export interface ProviderCallbackInput< + C extends ValidatedProviderConfiguration = ValidatedProviderConfiguration +> { + config: C; + redirectUri: string; + code: string; + codeVerifier: string; + nonce?: string; + requestTimeoutMs: number; + fetch?: typeof fetch; } -export interface CallbackParams { - provider: string; - code: string; - redirectUri?: string; +export interface SafeExternalProfile { + name?: string; + username?: string; + avatarUrl?: string; + emailVerified?: boolean; } -export interface OAuthError extends Error { - code: string; - provider?: string; - statusCode?: number; +/** The only Provider result consumed by common Constructive orchestration. */ +export interface NormalizedExternalIdentity { + providerKey: string; + subject: string; + email?: string; + profile: SafeExternalProfile; } -export function createOAuthError( - message: string, - code: string, - provider?: string, - statusCode?: number -): OAuthError { - const error = new Error(message) as OAuthError; - error.code = code; - error.provider = provider; - error.statusCode = statusCode; - return error; +export type ProviderFailureReason = + | 'INVALID_CONFIGURATION' + | 'INVALID_AUTHORIZATION_INPUT' + | 'NETWORK_FAILURE' + | 'REQUEST_TIMEOUT' + | 'INVALID_RESPONSE' + | 'IDENTITY_VERIFICATION_FAILED'; + +/** + * Package-local failure classification. Transport owners map it to canonical + * Constructive errors and never expose Provider response bodies. + */ +export class ProviderAdapterError extends Error { + readonly reason: ProviderFailureReason; + readonly status?: number; + + constructor( + reason: ProviderFailureReason, + message: string, + options?: ErrorOptions & { status?: number } + ) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'ProviderAdapterError'; + this.reason = reason; + this.status = options?.status; + } } diff --git a/packages/oauth/src/utils/state.ts b/packages/oauth/src/utils/state.ts deleted file mode 100644 index c7a60f2818..0000000000 --- a/packages/oauth/src/utils/state.ts +++ /dev/null @@ -1 +0,0 @@ -export { generateToken as generateState, verifyToken as verifyState } from '@constructive-io/csrf'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 257ab13c58..2bf0115a49 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 @@ -2140,6 +2143,9 @@ importers: specifier: ^7.0.0 version: 7.2.2 devDependencies: + 12factor-env: + specifier: workspace:^ + version: link:../../packages/12factor-env/dist '@0no-co/graphql.web': specifier: ^1.3.3 version: 1.3.3(graphql@16.13.0) @@ -2586,9 +2592,9 @@ importers: packages/oauth: dependencies: - '@constructive-io/csrf': - specifier: workspace:^ - version: link:../csrf/dist + jose: + specifier: ^5.10.0 + version: 5.10.0 devDependencies: '@types/node': specifier: ^22.19.11 @@ -11645,6 +11651,12 @@ packages: } hasBin: true + jose@5.10.0: + resolution: + { + integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==, + } + js-tokens@4.0.0: resolution: { @@ -21173,6 +21185,8 @@ snapshots: jiti@2.7.0: {} + jose@5.10.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: