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 000000000..e8644b3ca --- /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 711e36df5..8388627d6 100644 --- a/graphql/server-test/__tests__/oauth-scoped-routing.integration.test.ts +++ b/graphql/server-test/__tests__/oauth-scoped-routing.integration.test.ts @@ -1,9 +1,10 @@ /** * OAuth route integration on the production scoped-routing server. * - * The shared fixture deliberately does not provision OAuth modules. These - * assertions prove the explicitly enabled route receives a database/API scope - * from routing_public and can start a flow with validated OAuth options. + * The shared fixture proves the explicitly enabled route receives a + * database/API scope from routing_public. The SSO-specific assertion stays at + * this HTTP seam: a second registered API in the same database is accepted as + * a redirect target. Browser cookie/session behavior belongs in CNC Hub. */ import path from 'path'; @@ -33,6 +34,7 @@ const metaSchemas = [ 'metaschema_modules_public' ]; const API_HOST = 'app.test.constructive.io'; +const SAME_DATABASE_TARGET_HOST = 'private.test.constructive.io'; const seedAdapters = [ seed.pgpm(pgpmWorkspace), @@ -98,6 +100,19 @@ describe('OAuth routes over scoped routing', () => { expect(setCookies.join('\n')).toContain('oauth_pkce='); }); + it('accepts a registered cross-origin API in the same database', async () => { + const target = `http://${SAME_DATABASE_TARGET_HOST}/after-login`; + const response = await request + .get('/auth/github') + .query({ redirect_uri: target }) + .set('Host', API_HOST); + + expect(response.status).toBe(302); + const redirect = new URL(response.headers.location); + expect(redirect.origin).toBe('https://github.example.test'); + expect(redirect.searchParams.get('state')).toBeTruthy(); + }); + it('does not fall back to a default database for an unknown host', async () => { const response = await request .get('/auth/providers') 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 000000000..674a3c2a2 --- /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 7c709b39d..5fe9bab28 100644 --- a/graphql/server/src/middleware/__tests__/oauth.test.ts +++ b/graphql/server/src/middleware/__tests__/oauth.test.ts @@ -3,18 +3,24 @@ 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 { errorHandler } from '../error-handler'; 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') @@ -28,6 +34,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; @@ -40,6 +53,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 { @@ -68,9 +84,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 @@ -99,7 +123,8 @@ function createConstructiveContext() { return { cookieHttponly: true, cookieSecure: false, - cookieSamesite: 'lax' + cookieSamesite: 'lax', + ...authSettingsOverrides }; } if (name === 'connectedAccountsModule') { @@ -194,10 +219,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('passes provider errors through without treating them as registered errors', async () => { await withOAuthServer(async (baseUrl) => { @@ -248,7 +322,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, { @@ -281,6 +358,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( + 'OAUTH_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( + 'OAUTH_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( + 'OAUTH_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( @@ -340,6 +563,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('OAUTH_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(); @@ -460,9 +709,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'); @@ -537,16 +791,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('OAUTH_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 bcd013a88..59a2efa72 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 { resolveApiHost, ResolvedRoute, 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/auth.ts b/graphql/server/src/middleware/auth.ts index ef6da3f3a..07d54f669 100644 --- a/graphql/server/src/middleware/auth.ts +++ b/graphql/server/src/middleware/auth.ts @@ -38,6 +38,14 @@ export const createAuthenticateMiddleware = ( res: Response, next: NextFunction ): Promise => { + // OAuth / SSO routes manage their own auth (signed state + session cookie + // set by the callback). Skipping them here prevents a stale/invalid + // constructive_session cookie from 401ing /auth/* before the OAuth router + // ever runs — the router is mounted AFTER this middleware. + if (req.path.startsWith('/auth/')) { + return next(); + } + const api = req.api; log.info(`[auth] middleware called, api=${api ? 'present' : 'missing'}`); if (!api) { diff --git a/graphql/server/src/middleware/oauth.ts b/graphql/server/src/middleware/oauth.ts index 65a91aee0..668f14402 100644 --- a/graphql/server/src/middleware/oauth.ts +++ b/graphql/server/src/middleware/oauth.ts @@ -46,6 +46,7 @@ import { setDeviceTokenCookie, setSessionCookie } from './cookie'; +import { resolveApiHost } from './routing'; const log = new Logger('oauth'); @@ -61,6 +62,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 { @@ -168,24 +172,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. */ @@ -306,9 +372,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, @@ -346,11 +418,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), @@ -513,17 +588,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, - errors.OAUTH_INVALID_REDIRECT_URI(), + errors.OAUTH_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 752b32bdf..4fc440d09 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; +};