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 000000000..f6b7a5234 --- /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 000000000..1cc72cf34 --- /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 84c3c4f3e..c69bdb266 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 000000000..1fa4f7111 --- /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/index.ts b/graphql/server-test/src/index.ts index 5b4b7e7f8..1b53ec211 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 c8fbfc1e2..04fc181dc 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 7736a9721..220992f10 100644 --- a/graphql/server-test/src/types.ts +++ b/graphql/server-test/src/types.ts @@ -16,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 diff --git a/packages/express-context/__tests__/loaders/sso-surface.test.ts b/packages/express-context/__tests__/loaders/sso-surface.test.ts index 92af9c3e3..b7c9802e8 100644 --- a/packages/express-context/__tests__/loaders/sso-surface.test.ts +++ b/packages/express-context/__tests__/loaders/sso-surface.test.ts @@ -49,6 +49,9 @@ describe('ssoSurfaceLoader', () => { 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 () => { diff --git a/packages/express-context/src/loaders/sso-surface.ts b/packages/express-context/src/loaders/sso-surface.ts index 212319214..ade9c9921 100644 --- a/packages/express-context/src/loaders/sso-surface.ts +++ b/packages/express-context/src/loaders/sso-surface.ts @@ -15,7 +15,7 @@ import type { LoaderContext, ModuleLoader } from './types'; import { requireDatabaseId } from './types'; const SSO_SURFACE_SQL = ` - SELECT private_schema.name AS private_schema + 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e316102d2..2bf0115a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2143,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)