Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions docs/plan/oauth/tenant-shared-session-sso.md
Original file line number Diff line number Diff line change
@@ -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.
162 changes: 141 additions & 21 deletions graphql/server-test/__tests__/oauth-scoped-routing.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -33,15 +32,34 @@ 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),
seed.sqlfile([
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')
])
];

Expand All @@ -53,8 +71,12 @@ beforeAll(async () => {
{
schemas,
authRole: 'anonymous',
oauth: {
stateSecret: OAUTH_STATE_SECRET
},
server: {
useRouting: true,
strictAuth: false,
api: {
isPublic: true,
metaSchemas
Expand All @@ -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<OAuthStatePayload>(
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' })
])
);
});
});
1 change: 1 addition & 0 deletions graphql/server-test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading