diff --git a/docs/plan/oauth/tenant-shared-session-sso.md b/docs/plan/oauth/tenant-shared-session-sso.md new file mode 100644 index 0000000000..e8644b3ca6 --- /dev/null +++ b/docs/plan/oauth/tenant-shared-session-sso.md @@ -0,0 +1,120 @@ +# Tenant shared-session SSO + +This design supports one tenant using a shared CNC session across multiple API +hosts: + +```text +auth.tenanta.com/auth/google + -> Google + -> auth.tenanta.com/auth/google/callback + -> Set-Cookie: constructive_session; Domain=tenanta.com; Path=/; ... + -> api1.tenanta.com +``` + +CNC remains an OAuth client of Google, GitHub, or another external identity +provider. It is not an OIDC provider and does not issue OIDC tokens to the +tenant APIs. + +## Required topology + +The Auth API and every API that shares the session must: + +- resolve through the CNC scoped routing plane; +- have the same explicit CNC `databaseId` and physical database; +- share the users, connected accounts, sessions, and session credentials + auth schema; +- use the same session cookie name, Domain, and Path; and +- run with `strictAuth=false`. + +They may have different `apiId` values and expose different business schemas. + +> Current shared-session SSO requires `strictAuth=false`. Cross-origin support +> for strict authentication is a separate design and PR. This flow does not +> modify, bypass, or downgrade `authenticate_strict`. + +## Session cookie configuration + +Use the existing database auth settings. For the example above: + +```text +cookie_domain = tenanta.com +cookie_path = / +cookie_secure = true +cookie_httponly = true +cookie_samesite = lax +``` + +`constructive_session` is set and cleared with the same settings by the common +cookie configuration helpers. When `cookie_domain` is absent, the cookie +remains host-only. + +Every subdomain that can receive a parent-domain session cookie must be in the +same security boundary. Do not use a parent domain that contains +user-controlled or lower-trust hosts. Production deployments must use HTTPS. + +OAuth `oauth_state` and `oauth_pkce` cookies remain host-only and scoped to +`/auth`; they are never parent-domain cookies. The provider callback therefore +continues to return to the Auth API: + +```text +https://auth.tenanta.com/auth/{provider}/callback +``` + +The existing trusted-device cookie currently derives its Domain from the same +`cookie_domain` auth setting. That pre-existing coupling can broaden the device +cookie along with the session cookie and should be included in the deployment +security review. This SSO change does not introduce a separate device-cookie +scope. + +When migrating from a host-only `constructive_session` to a domain cookie with +the same name, a browser may temporarily send both cookies. Deployments should +clear the old host-only cookie first or use a planned versioned migration to +avoid ambiguous authentication. + +## OAuth redirect trust + +A relative redirect stays on the Auth API. An absolute cross-origin redirect +is accepted only when all of these are true: + +1. The URL is HTTP(S), contains no username/password, and uses HTTPS in + production. +2. Its normalized host resolves through the configured scoped routing schema's + `resolve_route()` contract. +3. The result is an API surface with explicit `apiId` and `databaseId`. +4. Its `databaseId` exactly matches the Auth API's `databaseId`. + +The API IDs may differ. Similar hostnames, parent-domain suffixes, CORS +allowlists, client-provided database IDs, and legacy/default database routing +do not establish trust. + +Signed OAuth state binds both sides: + +- provider, Auth API database/API IDs, and Auth origin; +- normalized final redirect URI; and +- target database/API IDs and target origin. + +The callback re-resolves the target and compares it with the signed values. +Route removal or reassignment during login fails safely. OAuth and validation +errors stay on the Auth API. An MFA redirect receives only the already +validated final redirect URI. + +OAuth redirect validation, CORS, and CSRF are separate mechanisms: + +- scoped routing plus matching `databaseId` authorizes the login redirect; +- each API still needs its own credentialed CORS policy; and +- each API still enforces its normal CSRF protections for state-changing + requests. + +## Boundaries + +This design does not provide: + +- cross-tenant or cross-database identity/session sharing; +- SSO across different top-level domains; +- a CNC OIDC provider; +- authorization-code-style session handoff; +- `request_cross_origin_token` or `sign_in_cross_origin`; or +- cross-origin `authenticate_strict` semantics. + +Logout must execute the normal database sign-out/revoke operation and clear the +parent-domain session cookie with the same Domain and Path used at sign-in. diff --git a/graphql/server-test/__tests__/oauth-scoped-routing.integration.test.ts b/graphql/server-test/__tests__/oauth-scoped-routing.integration.test.ts index 3f54ef3162..8689b60df9 100644 --- a/graphql/server-test/__tests__/oauth-scoped-routing.integration.test.ts +++ b/graphql/server-test/__tests__/oauth-scoped-routing.integration.test.ts @@ -1,12 +1,11 @@ /** * OAuth route integration on the production scoped-routing server. * - * The shared fixture deliberately does not provision OAuth modules. These - * assertions prove the route still receives an explicit database/API scope - * from routing_public and that OAUTH_STATE_SECRET is required lazily only - * after a configured provider can start a flow. + * Exercises the production middleware chain with four scoped API hosts: + * Auth/API1/API2 share database A, while api.other.test belongs to database B. */ +import { verifySignedState } from '@constructive-io/oauth'; import path from 'path'; import type supertest from 'supertest'; @@ -33,7 +32,25 @@ const metaSchemas = [ 'metaschema_public', 'metaschema_modules_public' ]; -const API_HOST = 'app.test.constructive.io'; +const OAUTH_STATE_SECRET = 'scoped-sso-state-secret'; +const DATABASE_A_ID = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9'; +const AUTH_API_ID = '6c9997a4-591b-4cb3-9313-4ef45d6f134e'; +const API1_ID = '28199444-da40-40b1-8a4c-53edbf91c738'; +const API2_ID = 'cc1e8389-e69d-4e12-9089-a98bf11fc75f'; +const AUTH_HOST = 'auth.tenanta.test'; +const API1_HOST = 'api1.tenanta.test'; +const API2_HOST = 'api2.tenanta.test'; +const OTHER_HOST = 'api.other.test'; + +interface OAuthStatePayload { + redirect_uri: string; + database_id: string; + api_id: string | null; + origin: string; + redirect_target_database_id: string; + redirect_target_api_id: string | null; + redirect_target_origin: string; +} const seedAdapters = [ seed.pgpm(pgpmWorkspace), @@ -41,7 +58,8 @@ const seedAdapters = [ shared('app-schemas', 'simple-pets', 'schema.sql'), shared('scoped', 'test-data.sql'), shared('app-schemas', 'simple-pets', 'test-data.sql'), - path.join(__dirname, '..', 'sql', 'oauth-scoped.sql') + path.join(__dirname, '..', 'sql', 'oauth-scoped.sql'), + path.join(__dirname, '..', 'sql', 'oauth-sso-scoped.sql') ]) ]; @@ -53,8 +71,12 @@ beforeAll(async () => { { schemas, authRole: 'anonymous', + oauth: { + stateSecret: OAUTH_STATE_SECRET + }, server: { useRouting: true, + strictAuth: false, api: { isPublic: true, metaSchemas @@ -71,33 +93,131 @@ afterAll(async () => { describe('OAuth routes over scoped routing', () => { it('resolves the API host and returns the database-scoped provider list', async () => { - const response = await request.get('/auth/providers').set('Host', API_HOST); + const response = await request.get('/auth/providers').set('Host', AUTH_HOST); expect(response.status).toBe(200); expect(response.body).toEqual({ providers: ['github'] }); }); - it('requires the state secret lazily only when a configured flow starts', async () => { - const response = await request.get('/auth/github').set('Host', API_HOST); + it.each([ + [API1_HOST, API1_ID], + [API2_HOST, API2_ID] + ])( + 'allows %s as a same-database redirect and binds both API scopes in state', + async (targetHost, targetApiId) => { + const target = `http://${targetHost}/after-login?from=oauth#ready`; + const response = await request + .get('/auth/github') + .query({ redirect_uri: target }) + .set('Host', AUTH_HOST); + + expect(response.status).toBe(302); + const providerRedirect = new URL(response.headers.location); + expect(providerRedirect.origin).toBe('https://github.example.test'); + + const setCookieHeader = response.headers['set-cookie']; + const setCookies: string[] = Array.isArray(setCookieHeader) + ? setCookieHeader + : setCookieHeader + ? [setCookieHeader] + : []; + const stateCookie = setCookies + .find((cookie) => cookie.startsWith('oauth_state=')) + ?.split(';')[0] + .slice('oauth_state='.length); + expect(stateCookie).toBeDefined(); + expect(setCookies.join('\n')).not.toContain('Domain='); + + const state = verifySignedState( + decodeURIComponent(stateCookie!), + { secret: OAUTH_STATE_SECRET } + ); + expect(state).toMatchObject({ + redirect_uri: target, + database_id: DATABASE_A_ID, + api_id: AUTH_API_ID, + origin: `http://${AUTH_HOST}`, + redirect_target_database_id: DATABASE_A_ID, + redirect_target_api_id: targetApiId, + redirect_target_origin: `http://${targetHost}` + }); + } + ); + + it('rejects a registered API from another database', async () => { + const response = await request + .get('/auth/github') + .query({ redirect_uri: `http://${OTHER_HOST}/after-login` }) + .set('Host', AUTH_HOST); - expect(response.status).toBe(302); const redirect = new URL(response.headers.location); - expect(redirect.searchParams.get('error')).toBe('OAUTH_INIT_FAILED'); + expect(redirect.origin).toBe(`http://${AUTH_HOST}`); + expect(redirect.searchParams.get('error')).toBe('INVALID_REDIRECT_URI'); const setCookieHeader = response.headers['set-cookie']; - const setCookies: string[] = Array.isArray(setCookieHeader) - ? setCookieHeader - : setCookieHeader - ? [setCookieHeader] - : []; - expect(setCookies.join('\n')).not.toContain('oauth_state='); - expect(setCookies.join('\n')).not.toContain('oauth_pkce='); + const setCookieText = Array.isArray(setCookieHeader) + ? setCookieHeader.join('\n') + : (setCookieHeader ?? ''); + expect(setCookieText).not.toContain('oauth_state='); }); - it('does not fall back to a default database for an unknown host', async () => { + it('rejects an unregistered redirect without a default-database fallback', async () => { + const response = await request + .get('/auth/github') + .query({ + redirect_uri: 'http://unknown.tenanta.test/after-login' + }) + .set('Host', AUTH_HOST); + + const redirect = new URL(response.headers.location); + expect(redirect.origin).toBe(`http://${AUTH_HOST}`); + expect(redirect.searchParams.get('error')).toBe('INVALID_REDIRECT_URI'); + }); + + it('does not leak database-A provider config into database B', async () => { const response = await request .get('/auth/providers') - .set('Host', 'unknown.test.constructive.io'); + .set('Host', OTHER_HOST); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ providers: [] }); + }); + + it('keeps API1 and API2 business schema exposure distinct', async () => { + const query = '{ animals { nodes { name } } }'; + const postGraphQL = async (host: string) => { + const bootstrap = await request.get('/auth/providers').set('Host', host); + const bootstrapCookieHeader = bootstrap.headers['set-cookie']; + const bootstrapCookies: string[] = Array.isArray(bootstrapCookieHeader) + ? bootstrapCookieHeader + : bootstrapCookieHeader + ? [bootstrapCookieHeader] + : []; + const csrfCookie = bootstrapCookies.find((cookie: string) => + cookie.startsWith('csrf_token=') + ); + const csrfToken = csrfCookie?.split(';')[0].slice('csrf_token='.length); + expect(csrfToken).toBeDefined(); + + return request + .post('/graphql') + .set('Host', host) + .set('Cookie', `csrf_token=${csrfToken}`) + .set('x-csrf-token', csrfToken!) + .send({ query }); + }; + + const api1Response = await postGraphQL(API1_HOST); + const api2Response = await postGraphQL(API2_HOST); - expect(response.status).toBe(404); + expect(api1Response.status).toBe(400); + expect(api1Response.body.errors).toBeDefined(); + expect(api2Response.status).toBe(200); + expect(api2Response.body.errors).toBeUndefined(); + expect(api2Response.body.data.animals.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Buddy' }), + expect.objectContaining({ name: 'Whiskers' }) + ]) + ); }); }); diff --git a/graphql/server-test/package.json b/graphql/server-test/package.json index a1f298b6ff..6ccf4f3c78 100644 --- a/graphql/server-test/package.json +++ b/graphql/server-test/package.json @@ -33,6 +33,7 @@ "@agentic-kit/ollama": "workspace:*", "@constructive-io/graphql-codegen": "workspace:^", "@constructive-io/graphql-query": "workspace:^", + "@constructive-io/oauth": "workspace:^", "@types/express": "^5.0.6", "@types/pg": "^8.20.0", "@types/supertest": "^7.2.0", diff --git a/graphql/server-test/sql/oauth-sso-scoped.sql b/graphql/server-test/sql/oauth-sso-scoped.sql new file mode 100644 index 0000000000..a0c82f05bb --- /dev/null +++ b/graphql/server-test/sql/oauth-sso-scoped.sql @@ -0,0 +1,365 @@ +-- Tenant shared-session SSO routing fixture. +-- +-- Auth, API1, and API2 are distinct API surfaces in database A. The "other" +-- API is resolvable by the same routing plane but belongs to database B. OAuth +-- redirect validation must trust only the first three as database-A targets. + +SET session_replication_role TO replica; + +INSERT INTO metaschema_public.database (id, owner_id, name, hash) +VALUES ( + '90a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + NULL, + 'other-tenant', + '525a0f10-0170-5760-85df-2a980c378224' +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO metaschema_public.schema ( + id, + database_id, + name, + schema_name, + description, + is_public +) +VALUES ( + '9dbae92a-5450-401b-1ed5-d69e7754940d', + '90a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'public', + 'other-tenant-public', + NULL, + true +) +ON CONFLICT (id) DO NOTHING; + +CREATE SCHEMA IF NOT EXISTS "other-tenant-public"; +GRANT USAGE ON SCHEMA "other-tenant-public" + TO administrator, authenticated, anonymous; + +-- API1 exposes only simple-pets-public; API2 exposes the animals schema. +INSERT INTO routing_public.api_schemas (id, database_id, schema_id, api_id) +VALUES + ( + '7a181146-890e-4991-9da7-3dddf87d9e01', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '6dbae92a-5450-401b-1ed5-d69e7754940d', + '28199444-da40-40b1-8a4c-53edbf91c738' + ), + ( + '7a181146-890e-4991-9da7-3dddf87d9e02', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '6dba6f21-0193-43f4-3bdb-61b4b956b6b6', + 'cc1e8389-e69d-4e12-9089-a98bf11fc75f' + ) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO catalog_public.apis ( + id, + owner_scope, + owner_key, + is_visible, + database_id, + name, + dbname, + role_name, + anon_role, + config +) +VALUES ( + '9c9997a4-591b-4cb3-9313-4ef45d6f134e', + 'database', + '90a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + true, + '90a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'other', + current_database(), + 'authenticated', + 'anonymous', + jsonb_build_object( + 'api_id', '9c9997a4-591b-4cb3-9313-4ef45d6f134e', + 'database_id', '90a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'is_public', true, + 'schemas', jsonb_build_array('other-tenant-public') + ) +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO routing_public.apis ( + id, + database_id, + name, + dbname, + role_name, + anon_role, + is_published +) +VALUES ( + '9c9997a4-591b-4cb3-9313-4ef45d6f134e', + '90a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'other', + current_database(), + 'authenticated', + 'anonymous', + true +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO routing_public.api_schemas (id, database_id, schema_id, api_id) +VALUES ( + '7a181146-890e-4991-9da7-3dddf87d9e03', + '90a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '9dbae92a-5450-401b-1ed5-d69e7754940d', + '9c9997a4-591b-4cb3-9313-4ef45d6f134e' +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO catalog_public.domains ( + id, + owner_scope, + owner_key, + is_visible, + database_id, + hostname, + is_wildcard, + parent_hostname, + managed, + verification_status, + tls_status +) +VALUES + ( + '4a181146-890e-4991-9da7-3dddf87d9e01', + 'database', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + true, + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'auth.tenanta.test', + false, + NULL, + false, + 'verified', + 'ready' + ), + ( + '4a181146-890e-4991-9da7-3dddf87d9e02', + 'database', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + true, + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'api1.tenanta.test', + false, + NULL, + false, + 'verified', + 'ready' + ), + ( + '4a181146-890e-4991-9da7-3dddf87d9e03', + 'database', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + true, + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'api2.tenanta.test', + false, + NULL, + false, + 'verified', + 'ready' + ), + ( + '4a181146-890e-4991-9da7-3dddf87d9e04', + 'database', + '90a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + true, + '90a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'api.other.test', + false, + NULL, + false, + 'verified', + 'ready' + ) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO routing_public.domains ( + id, + database_id, + hostname, + managed, + is_wildcard, + parent_hostname, + verification_status, + tls_status, + tls_secret_name, + is_published +) +VALUES + ( + '4a181146-890e-4991-9da7-3dddf87d9e01', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'auth.tenanta.test', + false, + false, + NULL, + 'verified', + 'ready', + NULL, + true + ), + ( + '4a181146-890e-4991-9da7-3dddf87d9e02', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'api1.tenanta.test', + false, + false, + NULL, + 'verified', + 'ready', + NULL, + true + ), + ( + '4a181146-890e-4991-9da7-3dddf87d9e03', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'api2.tenanta.test', + false, + false, + NULL, + 'verified', + 'ready', + NULL, + true + ), + ( + '4a181146-890e-4991-9da7-3dddf87d9e04', + '90a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + 'api.other.test', + false, + false, + NULL, + 'verified', + 'ready', + NULL, + true + ) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO routing_public.routes ( + id, + database_id, + domain_id, + target_api_id, + path, + method, + priority, + is_active +) +VALUES + ( + '9a181146-890e-4991-9da7-3dddf87d9e01', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '4a181146-890e-4991-9da7-3dddf87d9e01', + '6c9997a4-591b-4cb3-9313-4ef45d6f134e', + '/', + NULL, + 0, + true + ), + ( + '9a181146-890e-4991-9da7-3dddf87d9e02', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '4a181146-890e-4991-9da7-3dddf87d9e02', + '28199444-da40-40b1-8a4c-53edbf91c738', + '/', + NULL, + 0, + true + ), + ( + '9a181146-890e-4991-9da7-3dddf87d9e03', + '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '4a181146-890e-4991-9da7-3dddf87d9e03', + 'cc1e8389-e69d-4e12-9089-a98bf11fc75f', + '/', + NULL, + 0, + true + ), + ( + '9a181146-890e-4991-9da7-3dddf87d9e04', + '90a2eaaf-f77e-4bfe-8506-df929ef1b8d9', + '4a181146-890e-4991-9da7-3dddf87d9e04', + '9c9997a4-591b-4cb3-9313-4ef45d6f134e', + '/', + NULL, + 0, + true + ) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO routing_public.hostname_bindings ( + id, + hostname, + domain_id, + is_wildcard, + parent_hostname, + managed, + verification_status, + tls_status, + tls_secret_name, + updated_at +) +SELECT + (md5(concat(id::text, '|', hostname)))::uuid, + hostname, + id, + false, + NULL, + false, + 'verified', + 'ready', + NULL, + now() +FROM routing_public.domains +WHERE hostname IN ( + 'auth.tenanta.test', + 'api1.tenanta.test', + 'api2.tenanta.test', + 'api.other.test' +) +ON CONFLICT (hostname) DO NOTHING; + +INSERT INTO routing_public.route_bindings ( + id, + domain_id, + target_api_id, + target_site_id, + target_function_id, + path, + method, + priority, + is_active, + updated_at +) +SELECT + id, + domain_id, + target_api_id, + NULL, + NULL, + path, + method, + priority, + is_active, + now() +FROM routing_public.routes +WHERE id IN ( + '9a181146-890e-4991-9da7-3dddf87d9e01', + '9a181146-890e-4991-9da7-3dddf87d9e02', + '9a181146-890e-4991-9da7-3dddf87d9e03', + '9a181146-890e-4991-9da7-3dddf87d9e04' +) +ON CONFLICT (id) DO NOTHING; + +SET session_replication_role TO DEFAULT; diff --git a/graphql/server-test/src/get-connections.ts b/graphql/server-test/src/get-connections.ts index 0bf1ce214e..cb67d148e4 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.oauth }); // Start the HTTP server. Suites default to the production scoped-routing diff --git a/graphql/server-test/src/server.ts b/graphql/server-test/src/server.ts index c8fbfc1e2d..ad88d4446e 100644 --- a/graphql/server-test/src/server.ts +++ b/graphql/server-test/src/server.ts @@ -47,6 +47,9 @@ export const createTestServer = async ( ...opts, server: { ...opts.server, + ...(serverOpts.strictAuth !== undefined && { + strictAuth: serverOpts.strictAuth + }), host, port } diff --git a/graphql/server-test/src/types.ts b/graphql/server-test/src/types.ts index 2d1119be2e..28fbffd168 100644 --- a/graphql/server-test/src/types.ts +++ b/graphql/server-test/src/types.ts @@ -1,4 +1,5 @@ import type { ApiOptions,GraphileOptions } from '@constructive-io/graphql-types'; +import type { OAuthOptions } from '@pgpmjs/types'; import type { DocumentNode, GraphQLError } from 'graphql'; import type { Server } from 'http'; import type { PgTestClient } from 'pgsql-test/test-client'; @@ -23,6 +24,8 @@ export interface ServerOptions { * schemas directly. For local/static suites only. */ useRouting?: boolean; + /** Explicit auth mode for production-server integration tests. */ + strictAuth?: boolean; /** * API configuration options for the GraphQL server. * These options control how the server handles requests and which features are enabled. @@ -71,6 +74,8 @@ export interface GetConnectionsInput { authRole?: string; /** Graphile/PostGraphile configuration options */ graphile?: GraphileOptions; + /** Runtime OAuth options passed through to the Constructive server. */ + oauth?: OAuthOptions; /** Server configuration options (port, host, and API configuration) */ server?: ServerOptions; } diff --git a/graphql/server/src/middleware/__tests__/auth-shared-session.test.ts b/graphql/server/src/middleware/__tests__/auth-shared-session.test.ts new file mode 100644 index 0000000000..674a3c2a2a --- /dev/null +++ b/graphql/server/src/middleware/__tests__/auth-shared-session.test.ts @@ -0,0 +1,136 @@ +jest.mock('@pgpmjs/env', () => ({ + getNodeEnv: jest.fn(() => 'test') +})); + +jest.mock('@pgpmjs/logger', () => ({ + Logger: jest.fn(() => ({ + error: jest.fn(), + info: jest.fn() + })) +})); + +jest.mock('pg-cache', () => ({ + getPgPool: jest.fn() +})); + +jest.mock('pg-query-context', () => ({ + __esModule: true, + default: jest.fn() +})); + +import type { NextFunction, Request, Response } from 'express'; +import { getPgPool } from 'pg-cache'; +import pgQueryContext from 'pg-query-context'; + +import { createAuthenticateMiddleware } from '../auth'; +import { SESSION_COOKIE_NAME } from '../cookie'; + +const mockGetPgPool = getPgPool as jest.MockedFunction; +const mockPgQueryContext = pgQueryContext as jest.MockedFunction< + typeof pgQueryContext +>; + +const sessionToken = 'shared-session-token'; +const authenticatedClaims = { + role: 'authenticated', + user_id: 'user-1', + session_id: 'session-1' +}; + +function createRequest(origin: string): Request { + const request = Object.create(null) as Request; + Object.assign(request, { + headers: { + cookie: `${SESSION_COOKIE_NAME}=${sessionToken}` + }, + clientIp: '127.0.0.1', + api: { + apiId: origin.includes('api1') ? 'api-1' : 'api-2', + databaseId: 'database-a', + dbname: 'tenant_a', + anonRole: 'anonymous', + roleName: 'authenticated', + schema: [origin.includes('api1') ? 'api1_public' : 'api2_public'], + domains: [], + rlsModule: { + authenticate: 'authenticate', + authenticateStrict: 'authenticate_strict', + privateSchema: { + schemaName: 'auth_private' + } + } + }, + get: jest.fn((name: string) => { + if (name.toLowerCase() === 'origin') return origin; + if (name.toLowerCase() === 'user-agent') return 'shared-session-test'; + return undefined; + }) + }); + return request; +} + +function createResponse(): Response { + const response = Object.create(null) as Response; + Object.assign(response, { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis() + }); + return response; +} + +describe('tenant shared-session authentication', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetPgPool.mockReturnValue({} as never); + mockPgQueryContext.mockResolvedValue({ + rowCount: 1, + rows: [authenticatedClaims] + } as never); + }); + + it('uses the same ordinary session credential for two APIs when strictAuth=false', async () => { + const middleware = createAuthenticateMiddleware({ + pg: { database: 'tenant_a' }, + server: { strictAuth: false } + }); + const api1Request = createRequest('https://api1.tenanta.test'); + const api2Request = createRequest('https://api2.tenanta.test'); + const next = jest.fn() as NextFunction; + + await middleware(api1Request, createResponse(), next); + await middleware(api2Request, createResponse(), next); + + expect(mockPgQueryContext).toHaveBeenCalledTimes(2); + for (const call of mockPgQueryContext.mock.calls) { + expect(call[0].query).toContain('"auth_private"."authenticate"'); + expect(call[0].query).not.toContain('authenticate_strict'); + expect(call[0].variables).toEqual([sessionToken]); + } + expect(api1Request.token).toEqual(authenticatedClaims); + expect(api2Request.token).toEqual(authenticatedClaims); + expect(api1Request.api?.schema).toEqual(['api1_public']); + expect(api2Request.api?.schema).toEqual(['api2_public']); + expect(next).toHaveBeenCalledTimes(2); + }); + + it('does not downgrade strictAuth=true to ordinary authenticate', async () => { + const middleware = createAuthenticateMiddleware({ + pg: { database: 'tenant_a' }, + server: { strictAuth: true } + }); + + await middleware( + createRequest('https://api2.tenanta.test'), + createResponse(), + jest.fn() + ); + + expect(mockPgQueryContext).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'SELECT * FROM "auth_private"."authenticate_strict"($1)', + variables: [sessionToken] + }) + ); + }); +}); diff --git a/graphql/server/src/middleware/__tests__/oauth.test.ts b/graphql/server/src/middleware/__tests__/oauth.test.ts index c54ec73f03..e856707dbe 100644 --- a/graphql/server/src/middleware/__tests__/oauth.test.ts +++ b/graphql/server/src/middleware/__tests__/oauth.test.ts @@ -3,17 +3,23 @@ import { deriveCodeChallenge, verifySignedState } from '@constructive-io/oauth'; +import { getNodeEnv } from '@pgpmjs/env'; import express from 'express'; import http from 'http'; import type { AddressInfo } from 'net'; +import { getPgPool } from 'pg-cache'; import { createOAuthRoutes } from '../oauth'; +import type { ResolvedRoute } from '../routing'; const OAUTH_STATE_SECRET = 'test-oauth-state-secret'; const DATABASE_ID = '00000000-0000-4000-8000-000000000001'; const API_ID = '00000000-0000-4000-8000-000000000002'; +const TARGET_API_ID = '00000000-0000-4000-8000-000000000003'; +const OTHER_DATABASE_ID = '00000000-0000-4000-8000-000000000099'; const originalFetch = global.fetch; const authQueryMock = jest.fn(); +const routingQueryMock = jest.fn(); jest.mock('@pgpmjs/env', () => ({ getNodeEnv: jest.fn(() => 'test') @@ -27,6 +33,13 @@ jest.mock('@pgpmjs/logger', () => ({ })) })); +jest.mock('pg-cache', () => ({ + getPgPool: jest.fn() +})); + +const mockGetNodeEnv = getNodeEnv as jest.MockedFunction; +const mockGetPgPool = getPgPool as jest.MockedFunction; + interface TestHttpResponse { statusCode: number; headers: http.IncomingHttpHeaders; @@ -39,6 +52,9 @@ interface OAuthStatePayload { database_id: string; api_id: string | null; origin: string; + redirect_target_database_id: string; + redirect_target_api_id: string | null; + redirect_target_origin: string; } interface OAuthPkcePayload { @@ -67,9 +83,17 @@ const providerConfig = { afterEach(() => { global.fetch = originalFetch; authQueryMock.mockReset(); + routingQueryMock.mockReset(); + mockGetNodeEnv.mockReturnValue('test'); +}); + +beforeEach(() => { + mockGetPgPool.mockReturnValue({ query: routingQueryMock } as never); }); -function createConstructiveContext() { +function createConstructiveContext( + authSettingsOverrides: Record = {} +) { return { api: { apiId: API_ID @@ -98,7 +122,8 @@ function createConstructiveContext() { return { cookieHttponly: true, cookieSecure: false, - cookieSamesite: 'lax' + cookieSamesite: 'lax', + ...authSettingsOverrides }; } if (name === 'connectedAccountsModule') { @@ -191,10 +216,59 @@ function createStatePayload( database_id: DATABASE_ID, api_id: API_ID, origin: baseUrl, + redirect_target_database_id: DATABASE_ID, + redirect_target_api_id: API_ID, + redirect_target_origin: baseUrl, ...overrides }; } +function createResolvedApiRoute({ + databaseId = DATABASE_ID, + apiId = TARGET_API_ID, + targetModule = 'apis', + includeDatabaseId = true +}: { + databaseId?: string; + apiId?: string; + targetModule?: string; + includeDatabaseId?: boolean; +} = {}): ResolvedRoute { + return { + route_binding_id: '00000000-0000-4000-8000-000000000010', + hostname: 'api1.tenanta.test', + matched_wildcard: false, + matched_path: '/', + method: null, + priority: 0, + domain_id: '00000000-0000-4000-8000-000000000011', + target_catalog_id: '00000000-0000-4000-8000-000000000012', + target_module: targetModule, + target_source_id: apiId, + target_owner_scope: 'database', + target_owner_key: databaseId, + resolved_config: { + api_id: apiId, + ...(includeDatabaseId ? { database_id: databaseId } : {}), + dbname: 'tenant_a', + role_name: 'authenticated', + anon_role: 'anonymous', + is_public: true, + schemas: ['tenant_a_public'] + }, + verification_status: 'verified', + tls_status: 'ready', + tls_secret_name: null + }; +} + +function noMatchingRoute(): ResolvedRoute { + return { + ...createResolvedApiRoute(), + route_binding_id: null + }; +} + describe('OAuth routes', () => { it('binds PKCE verifier to the signed state cookie without exposing it in the redirect URL', async () => { await withOAuthServer(async (baseUrl) => { @@ -226,7 +300,10 @@ describe('OAuth routes', () => { provider: 'github', database_id: DATABASE_ID, api_id: API_ID, - origin: baseUrl + origin: baseUrl, + redirect_target_database_id: DATABASE_ID, + redirect_target_api_id: API_ID, + redirect_target_origin: baseUrl }); const pkcePayload = verifySignedState(pkceCookie, { @@ -259,6 +336,152 @@ describe('OAuth routes', () => { }); }); + it('normalizes an absolute same-origin redirect to the compatible relative form', async () => { + await withOAuthServer(async (baseUrl) => { + const target = `${baseUrl}/dashboard?tab=profile#security`; + const response = await request( + `${baseUrl}/auth/github?redirect_uri=${encodeURIComponent(target)}` + ); + + expect(response.statusCode).toBe(302); + const state = readCookie( + getSetCookieValues(response.headers), + 'oauth_state' + ); + expect( + verifySignedState(state, { + secret: OAUTH_STATE_SECRET + }) + ).toMatchObject({ + redirect_uri: '/dashboard?tab=profile#security', + redirect_target_database_id: DATABASE_ID, + redirect_target_api_id: API_ID, + redirect_target_origin: baseUrl + }); + expect(routingQueryMock).not.toHaveBeenCalled(); + }); + }); + + it('allows a registered cross-origin API in the same database and binds its scope in state', async () => { + routingQueryMock.mockResolvedValue({ rows: [createResolvedApiRoute()] }); + + await withOAuthServer(async (baseUrl) => { + const target = + 'http://api1.tenanta.test/dashboard?tab=profile#security'; + const response = await request( + `${baseUrl}/auth/github?redirect_uri=${encodeURIComponent(target)}` + ); + + expect(response.statusCode).toBe(302); + const state = readCookie( + getSetCookieValues(response.headers), + 'oauth_state' + ); + expect( + verifySignedState(state, { + secret: OAUTH_STATE_SECRET + }) + ).toMatchObject({ + redirect_uri: target, + database_id: DATABASE_ID, + api_id: API_ID, + origin: baseUrl, + redirect_target_database_id: DATABASE_ID, + redirect_target_api_id: TARGET_API_ID, + redirect_target_origin: 'http://api1.tenanta.test' + }); + expect(routingQueryMock).toHaveBeenCalledWith( + expect.stringContaining('"routing_public".resolve_route'), + ['api1.tenanta.test'] + ); + }); + }); + + it.each([ + { + name: 'another database', + target: 'http://api1.tenanta.test/dashboard', + route: createResolvedApiRoute({ databaseId: OTHER_DATABASE_ID }) + }, + { + name: 'an unregistered hostname', + target: 'http://unregistered.tenanta.test/dashboard', + route: noMatchingRoute() + }, + { + name: 'a non-API route', + target: 'http://api1.tenanta.test/dashboard', + route: createResolvedApiRoute({ targetModule: 'sites' }) + }, + { + name: 'an API route without databaseId', + target: 'http://api1.tenanta.test/dashboard', + route: createResolvedApiRoute({ includeDatabaseId: false }) + }, + { + name: 'a similar but unregistered hostname', + target: 'http://api1.tenanta.test.attacker.test/dashboard', + route: noMatchingRoute() + } + ])('rejects a cross-origin redirect resolved to $name', async ({ target, route }) => { + routingQueryMock.mockResolvedValue({ rows: [route] }); + + await withOAuthServer(async (baseUrl) => { + const response = await request( + `${baseUrl}/auth/github?redirect_uri=${encodeURIComponent(target)}` + ); + + expect(response.statusCode).toBe(302); + const redirect = new URL(response.headers.location!); + expect(redirect.origin).toBe(baseUrl); + expect(redirect.pathname).toBe('/auth/error'); + expect(redirect.searchParams.get('error')).toBe( + 'INVALID_REDIRECT_URI' + ); + expect(getSetCookieValues(response.headers)).toHaveLength(0); + }); + }); + + it.each([ + 'javascript:alert(1)', + 'data:text/html,hello', + 'file:///tmp/session', + '//api1.tenanta.test/dashboard', + 'http://user:password@api1.tenanta.test/dashboard', + 'http://[invalid' + ])('rejects unsafe redirect URI %s before routing', async (target) => { + await withOAuthServer(async (baseUrl) => { + const response = await request( + `${baseUrl}/auth/github?redirect_uri=${encodeURIComponent(target)}` + ); + + const redirect = new URL(response.headers.location!); + expect(redirect.origin).toBe(baseUrl); + expect(redirect.searchParams.get('error')).toBe( + 'INVALID_REDIRECT_URI' + ); + expect(routingQueryMock).not.toHaveBeenCalled(); + }); + }); + + it('rejects an HTTP cross-origin target in production', async () => { + mockGetNodeEnv.mockReturnValueOnce('production'); + + await withOAuthServer(async (baseUrl) => { + const response = await request( + `${baseUrl}/auth/github?redirect_uri=${encodeURIComponent( + 'http://api1.tenanta.test/dashboard' + )}` + ); + + const redirect = new URL(response.headers.location!); + expect(redirect.searchParams.get('error')).toBe( + 'INVALID_REDIRECT_URI' + ); + expect(routingQueryMock).not.toHaveBeenCalled(); + }); + }); + it('rejects callback requests when the PKCE verifier is not bound to the returned state', async () => { await withOAuthServer(async (baseUrl) => { const stateCookie = createSignedState( @@ -318,6 +541,32 @@ describe('OAuth routes', () => { }); }); + it('rejects a state value that was modified after signing', async () => { + await withOAuthServer(async (baseUrl) => { + const fetchMock = jest + .spyOn(global, 'fetch') + .mockRejectedValue(new Error('fetch must not be called')); + const signedState = createSignedState( + createStatePayload(baseUrl), + { secret: OAUTH_STATE_SECRET, maxAgeMs: 60_000 } + ); + const replacement = signedState.endsWith('a') ? 'b' : 'a'; + const tamperedState = `${signedState.slice(0, -1)}${replacement}`; + const callbackUrl = new URL('/auth/github/callback', baseUrl); + callbackUrl.searchParams.set('code', 'callback-code'); + callbackUrl.searchParams.set('state', tamperedState); + + const response = await request(callbackUrl.toString(), { + Cookie: `oauth_state=${encodeURIComponent(tamperedState)}` + }); + + const redirect = new URL(response.headers.location!); + expect(redirect.searchParams.get('error')).toBe('INVALID_STATE'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(authQueryMock).not.toHaveBeenCalled(); + }); + }); + it('rejects callback state bound to another database', async () => { await withOAuthServer(async (baseUrl) => { const fetchMock = jest.fn(); @@ -434,9 +683,14 @@ describe('OAuth routes', () => { }); it('uses the identity function schema for successful sign-up callbacks', async () => { + routingQueryMock.mockResolvedValue({ rows: [createResolvedApiRoute()] }); + await withOAuthServer(async (baseUrl) => { + const redirectTarget = 'http://api1.tenanta.test/dashboard'; const beginResponse = await request( - `${baseUrl}/auth/github?redirect_uri=%2Fdashboard` + `${baseUrl}/auth/github?redirect_uri=${encodeURIComponent( + redirectTarget + )}` ); const setCookies = getSetCookieValues(beginResponse.headers); const stateCookie = readCookie(setCookies, 'oauth_state'); @@ -511,16 +765,56 @@ describe('OAuth routes', () => { }); expect(callbackResponse.statusCode).toBe(302); - expect(callbackResponse.headers.location).toBe('/dashboard'); + expect(callbackResponse.headers.location).toBe(redirectTarget); expect(authQueryMock).toHaveBeenCalledTimes(1); expect(authQueryMock.mock.calls[0][0]).toContain( 'constructive_auth_private.sign_up_identity' ); - expect( - getSetCookieValues(callbackResponse.headers).some((cookie) => - cookie.startsWith('constructive_session=') - ) - ).toBe(true); + const sessionCookie = getSetCookieValues(callbackResponse.headers).find( + (cookie) => cookie.startsWith('constructive_session=') + ); + expect(sessionCookie).toContain('Domain=tenanta.test'); + expect(sessionCookie).toContain('Path=/'); + expect(sessionCookie).toContain('HttpOnly'); + expect(sessionCookie).toContain('SameSite=Lax'); + expect(routingQueryMock).toHaveBeenCalledTimes(2); + }, OAUTH_STATE_SECRET, () => + createConstructiveContext({ cookieDomain: 'tenanta.test' }) + ); + }); + + it('rejects callback when the redirect route changes database after initiation', async () => { + const fetchMock = jest + .spyOn(global, 'fetch') + .mockRejectedValue(new Error('fetch must not be called')); + routingQueryMock + .mockResolvedValueOnce({ rows: [createResolvedApiRoute()] }) + .mockResolvedValueOnce({ + rows: [createResolvedApiRoute({ databaseId: OTHER_DATABASE_ID })] + }); + + await withOAuthServer(async (baseUrl) => { + const target = 'http://api1.tenanta.test/dashboard'; + const beginResponse = await request( + `${baseUrl}/auth/github?redirect_uri=${encodeURIComponent(target)}` + ); + const stateCookie = readCookie( + getSetCookieValues(beginResponse.headers), + 'oauth_state' + ); + const callbackUrl = new URL('/auth/github/callback', baseUrl); + callbackUrl.searchParams.set('code', 'unused-code'); + callbackUrl.searchParams.set('state', stateCookie); + + const response = await request(callbackUrl.toString(), { + Cookie: `oauth_state=${encodeURIComponent(stateCookie)}` + }); + + const redirect = new URL(response.headers.location!); + expect(redirect.origin).toBe(baseUrl); + expect(redirect.searchParams.get('error')).toBe('INVALID_STATE'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(authQueryMock).not.toHaveBeenCalled(); }); }); }); diff --git a/graphql/server/src/middleware/__tests__/routing.test.ts b/graphql/server/src/middleware/__tests__/routing.test.ts index bcd013a888..c4f6d53e44 100644 --- a/graphql/server/src/middleware/__tests__/routing.test.ts +++ b/graphql/server/src/middleware/__tests__/routing.test.ts @@ -15,7 +15,7 @@ import { getPgPool } from 'pg-cache'; import type { ApiOptions } from '../../types'; import { getApiConfig } from '../api'; -import { ResolvedRoute, resolveRoute, routeToApiStructure } from '../routing'; +import { ResolvedRoute, resolveApiHost, resolveRoute, routeToApiStructure } from '../routing'; const mockGetPgPool = getPgPool as jest.MockedFunction; @@ -123,6 +123,48 @@ describe('routeToApiStructure', () => { }); }); +describe('resolveApiHost', () => { + const opts: ApiOptions = { + pg: { database: 'constructive' }, + api: { + isPublic: true, + routingSchema: 'tenant_routing_public' + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses the cached configured routing pool and schema', async () => { + const query = jest.fn().mockResolvedValue({ rows: [matchedRoute()] }); + mockGetPgPool.mockReturnValue(createPool(query) as never); + + const result = await resolveApiHost(opts, 'api.example.com:8443'); + + expect(mockGetPgPool).toHaveBeenCalledWith(opts.pg); + expect(query).toHaveBeenCalledWith( + expect.stringContaining('"tenant_routing_public".resolve_route'), + ['api.example.com:8443'] + ); + expect(result).toEqual( + expect.objectContaining({ apiId: 'api-1', databaseId: 'db-1' }) + ); + }); + + it('returns null rather than using a legacy fallback for an unknown host', async () => { + const query = jest.fn().mockResolvedValue({ rows: [noMatchRoute()] }); + mockGetPgPool.mockReturnValue(createPool(query) as never); + + await expect(resolveApiHost(opts, 'unknown.example.com')).resolves.toBeNull(); + expect( + query.mock.calls.some(([sql]) => + String(sql).includes('services_public') + ) + ).toBe(false); + }); +}); + describe('getApiConfig with scoped routing enabled', () => { const createRequest = (headers: Record): Request => { const normalized = new Map( diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index 3f715d8572..21ea8ea6df 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -45,6 +45,7 @@ import { setDeviceTokenCookie, setSessionCookie } from './cookie'; +import { resolveApiHost } from './routing'; const log = new Logger('oauth'); @@ -60,6 +61,9 @@ interface OAuthStatePayload { database_id: string; api_id: string | null; origin: string; + redirect_target_database_id: string; + redirect_target_api_id: string | null; + redirect_target_origin: string; } interface OAuthPkcePayload { @@ -167,24 +171,86 @@ interface SignInIdentityResult { function getBaseUrl(req: Request): string { const protocol = req.protocol || 'http'; const host = req.get('host') || 'localhost:3000'; - return `${protocol}://${host}`; + return new URL(`${protocol}://${host}`).origin; } -function normalizeRedirectUri( +interface OAuthRedirectTarget { + uri: string; + origin: string; + databaseId: string; + apiId: string | null; +} + +async function resolveRedirectTarget( redirectUri: string | undefined, - baseUrl: string -): string | null { - const requestedRedirectUri = redirectUri || '/'; + baseUrl: string, + ctx: ConstructiveContext, + opts: ConstructiveOptions, + isProduction: boolean +): Promise { + const requestedRedirectUri = redirectUri?.trim() || '/'; + + // WHATWG URL parsing treats //host/path as an authority-relative URL. Reject + // it explicitly so a path-looking input cannot select another host. + if (requestedRedirectUri.startsWith('//')) return null; try { const url = new URL(requestedRedirectUri, baseUrl); - if (url.origin !== new URL(baseUrl).origin) return null; - return `${url.pathname}${url.search}${url.hash}`; + const authOrigin = new URL(baseUrl).origin; + + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username || + url.password + ) { + return null; + } + + if (!ctx.databaseId) return null; + + if (url.origin === authOrigin) { + return { + uri: `${url.pathname}${url.search}${url.hash}`, + origin: authOrigin, + databaseId: ctx.databaseId, + apiId: ctx.api.apiId ?? null + }; + } + + if (isProduction && url.protocol !== 'https:') return null; + + const targetApi = await resolveApiHost(opts, url.host); + if ( + !targetApi?.databaseId || + !targetApi.apiId || + targetApi.databaseId !== ctx.databaseId + ) { + return null; + } + + return { + uri: url.toString(), + origin: url.origin, + databaseId: targetApi.databaseId, + apiId: targetApi.apiId ?? null + }; } catch { return null; } } +function redirectTargetMatchesState( + target: OAuthRedirectTarget, + state: OAuthStatePayload +): boolean { + return ( + target.uri === state.redirect_uri && + target.databaseId === state.redirect_target_database_id && + target.apiId === state.redirect_target_api_id && + target.origin === state.redirect_target_origin + ); +} + /** * Check if the user's email is verified by the OAuth provider. */ @@ -277,9 +343,15 @@ export function createOAuthRoutes(opts: ConstructiveOptions): Router { const errorRedirectPath = authSettings?.oauthErrorRedirectPath || DEFAULT_ERROR_REDIRECT_PATH; - const redirectUri = normalizeRedirectUri(requestedRedirectUri, baseUrl); - if (!redirectUri) { - log.warn(`[oauth] Rejected cross-origin redirect_uri for ${provider}`); + const redirectTarget = await resolveRedirectTarget( + requestedRedirectUri, + baseUrl, + ctx, + opts, + isProduction + ); + if (!redirectTarget) { + log.warn(`[oauth] Rejected untrusted redirect_uri for ${provider}`); return redirectToError( res, baseUrl, @@ -317,11 +389,14 @@ export function createOAuthRoutes(opts: ConstructiveOptions): Router { } const state = createSignedState( { - redirect_uri: redirectUri, + redirect_uri: redirectTarget.uri, provider, database_id: ctx.databaseId, api_id: ctx.api.apiId ?? null, - origin: baseUrl + origin: baseUrl, + redirect_target_database_id: redirectTarget.databaseId, + redirect_target_api_id: redirectTarget.apiId, + redirect_target_origin: redirectTarget.origin }, { secret: requireStateSecret(opts), @@ -484,17 +559,27 @@ export function createOAuthRoutes(opts: ConstructiveOptions): Router { const requireVerifiedEmail = authSettings?.oauthRequireVerifiedEmail ?? true; - const redirectUri = normalizeRedirectUri(redirectUriFromState, baseUrl); - if (!redirectUri) { - log.warn(`[oauth] Rejected cross-origin redirect_uri for ${provider}`); + const redirectTarget = await resolveRedirectTarget( + redirectUriFromState, + baseUrl, + ctx, + opts, + isProduction + ); + if ( + !redirectTarget || + !redirectTargetMatchesState(redirectTarget, statePayload) + ) { + log.warn(`[oauth] Redirect target scope changed for ${provider}`); return redirectToError( res, baseUrl, errorRedirectPath, - 'INVALID_REDIRECT_URI', + 'INVALID_STATE', provider ); } + const redirectUri = redirectTarget.uri; // Get provider config from cached map const providerConfig = identityProviders.providers.get(provider); diff --git a/graphql/server/src/middleware/routing.ts b/graphql/server/src/middleware/routing.ts index 752b32bdff..4fc440d093 100644 --- a/graphql/server/src/middleware/routing.ts +++ b/graphql/server/src/middleware/routing.ts @@ -1,5 +1,6 @@ import { Logger } from '@pgpmjs/logger'; import { Pool } from 'pg'; +import { getPgPool } from 'pg-cache'; import { ApiOptions, ApiStructure } from '../types'; @@ -139,3 +140,19 @@ export const routeToApiStructure = ( isPublic: config.is_public ?? (opts.api?.isPublic ?? false) }; }; + +/** + * Resolve a hostname to the minimal API surface needed by callers that must + * validate a route without loading the target API's tenant settings. + * + * `getPgPool()` returns the shared cached routing pool for `opts.pg`; this does + * not create a per-validation pool or fall back to a tenant/default database. + */ +export const resolveApiHost = async ( + opts: ApiOptions, + host: string +): Promise => { + const pool = getPgPool(opts.pg); + const route = await resolveRoute(pool, getRoutingSchema(opts), host); + return route ? routeToApiStructure(route, opts) : null; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13a05a61c9..f5ce9f02bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2157,6 +2157,9 @@ importers: '@constructive-io/graphql-query': specifier: workspace:^ version: link:../query/dist + '@constructive-io/oauth': + specifier: workspace:^ + version: link:../../packages/oauth/dist '@types/express': specifier: ^5.0.6 version: 5.0.6