Skip to content
Draft
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
106 changes: 106 additions & 0 deletions graphql/server/src/auth/oauth/__tests__/router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { errors } from '@constructive-io/errors';
import type { ConstructiveContext } from '@constructive-io/express-context';
import express from 'express';
import supertest from 'supertest';

import { createOAuthRouter } from '../router';
import {
completeProviderAuthentication,
createProviderAuthorizationUrl
} from '../service';

jest.mock('../service', () => ({
completeProviderAuthentication: jest.fn(),
createProviderAuthorizationUrl: jest.fn()
}));

const mockedAuthorize = jest.mocked(createProviderAuthorizationUrl);
const mockedComplete = jest.mocked(completeProviderAuthentication);
const opaqueState = 's'.repeat(43);

const makeApp = () => {
const app = express();
const context = {
useModule: jest.fn(async () => ({ privateSchema: 'tenant_sso_private' }))
} as unknown as ConstructiveContext;
app.use((req, _res, next) => {
req.constructive = context;
req.cookies = { csrf_token: 'b'.repeat(64) };
req.api = {
dbname: 'tenant',
anonRole: 'anonymous',
roleName: 'anonymous',
schema: [],
authSettings: {
cookieDomain: '.example.com',
cookieSecure: false,
cookieHttponly: false
}
};
next();
});
app.use('/auth/oauth', createOAuthRouter({ requestTimeoutMs: 1000 }));
return app;
};

describe('OAuth HTTP routes', () => {
beforeEach(() => jest.clearAllMocks());

it('redirects authorize using only the server-restored adapter URL', async () => {
mockedAuthorize.mockResolvedValue(
'https://github.com/login/oauth/authorize?state=provider-state'
);

const response = await supertest(makeApp())
.get(`/auth/oauth/authorize?state=${opaqueState}`)
.expect(303);

expect(response.headers.location).toBe(
'https://github.com/login/oauth/authorize?state=provider-state'
);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.headers['referrer-policy']).toBe('no-referrer');
});

it('sets a Secure HttpOnly host-only auth-center cookie after callback', async () => {
mockedComplete.mockResolvedValue({
credentialId: '00000000-0000-0000-0000-000000000001',
userId: '00000000-0000-0000-0000-000000000002',
accessToken: 'cnc_auth_center_token',
accessTokenExpiresAt: '2026-08-10T12:00:00.000Z',
isVerified: true,
totpEnabled: false,
continuationUrl: null
});

const response = await supertest(makeApp())
.get(`/auth/oauth/callback?state=${opaqueState}&code=provider-code`)
.expect(200);

const cookie = response.headers['set-cookie'][0] as string;
expect(cookie).toContain('constructive_session=cnc_auth_center_token');
expect(cookie).toContain('Secure');
expect(cookie).toContain('HttpOnly');
expect(cookie).not.toContain('Domain=');
expect(response.text).not.toContain('cnc_auth_center_token');
});

it('returns only a stable safe cancellation classification', async () => {
mockedComplete.mockRejectedValue(errors.OAUTH_AUTHORIZATION_CANCELLED());

const response = await supertest(makeApp())
.get(
`/auth/oauth/callback?state=${opaqueState}` +
'&error=access_denied&error_description=provider-secret-detail'
)
.expect(400);

expect(response.text).toContain('OAUTH_AUTHORIZATION_CANCELLED');
expect(response.text).not.toContain('provider-secret-detail');
expect(mockedComplete).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ providerReturnedError: true })
);
});
});
175 changes: 175 additions & 0 deletions graphql/server/src/auth/oauth/__tests__/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import type {
ConstructiveContext,
IdentityProviderConfig,
SsoSurface
} from '@constructive-io/express-context';
import type { PoolClient, QueryResult } from 'pg';

import {
completeProviderAuthentication,
createProviderAuthorizationUrl
} from '../service';

const opaqueState = 's'.repeat(43);
const browserBinding = 'b'.repeat(64);
const verifier = 'v'.repeat(43);
const surface: SsoSurface = { privateSchema: 'tenant_acme_sso_private' };

const githubProvider: IdentityProviderConfig = {
id: 'provider-id',
slug: 'github-enterprise',
kind: 'github',
displayName: 'GitHub',
enabled: true,
clientId: 'client-id',
clientSecret: 'client-secret',
authorizationUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
userinfoUrl: 'https://api.github.com/user',
issuerUrl: null,
discoveryUrlOverride: null,
discoveryDoc: null,
jwks: null,
jwksFetchedAt: null,
acceptableClientIds: [],
scopes: ['read:user', 'user:email'],
extraAuthorizationParams: {},
emailOptional: false,
allowLinkByEmail: false,
skipNonceCheck: false,
pkceEnabled: true
};

const createContext = (results: Record<string, unknown>[]) => {
const query = jest.fn(async (..._args: unknown[]) => ({
rows: [{ result: results.shift() }]
} as unknown as QueryResult));
const client = { query } as unknown as PoolClient;
const context = {
useModule: jest.fn(async (name: string) => name === 'identityProviders'
? {
providers: { [githubProvider.slug]: githubProvider },
source: { schemaName: 'private', tableName: 'identity_providers' }
}
: undefined),
withPgClient: jest.fn(async (callback: (pg: PoolClient) => Promise<unknown>) =>
callback(client)
)
} as unknown as ConstructiveContext;
return { context, query };
};

describe('Provider OAuth orchestration', () => {
it('rejects malformed state before database access', async () => {
const { context, query } = createContext([]);
await expect(createProviderAuthorizationUrl(
context,
surface,
'not-a-state',
browserBinding
)).rejects.toMatchObject({ code: 'INVALID_OAUTH_STATE' });
expect(query).not.toHaveBeenCalled();
});

it('builds authorization through the configured adapter without exposing verifier', async () => {
const { context } = createContext([{
oauth_request_id: '00000000-0000-0000-0000-000000000001',
provider_key: githubProvider.slug,
code_verifier: verifier,
nonce: null,
redirect_uri: 'https://auth.example.com/auth/oauth/callback'
}]);

const url = await createProviderAuthorizationUrl(
context,
surface,
opaqueState,
browserBinding
);
const parsed = new URL(url);
expect(parsed.origin).toBe('https://github.com');
expect(parsed.searchParams.get('state')).toBe(opaqueState);
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
expect(parsed.searchParams.get('code_challenge')).not.toBe(verifier);
expect(url).not.toContain(verifier);
});

it('consumes state, mocks only Provider HTTP, and applies normalized identity', async () => {
const { context, query } = createContext([
{
oauth_request_id: '00000000-0000-0000-0000-000000000001',
provider_key: githubProvider.slug,
code_verifier: verifier,
nonce: null,
redirect_uri: 'https://auth.example.com/auth/oauth/callback'
},
{
id: '00000000-0000-0000-0000-000000000002',
user_id: '00000000-0000-0000-0000-000000000003',
access_token: 'cnc_auth_center_token',
access_token_expires_at: '2026-08-10T12:00:00.000Z',
is_verified: true,
totp_enabled: false,
mfa_required: false,
continuation_url: null
}
]);
const providerFetch = jest.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
access_token: 'github-server-token'
}), { status: 200, headers: { 'content-type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 12345,
login: 'octocat',
name: 'Octo Cat',
email: 'octo@example.com',
avatar_url: 'https://avatars.githubusercontent.com/u/12345'
}), { status: 200, headers: { 'content-type': 'application/json' } }));

const result = await completeProviderAuthentication(context, surface, {
state: opaqueState,
code: 'provider-authorization-code',
providerReturnedError: false,
browserBinding,
requestTimeoutMs: 1000,
fetch: providerFetch as typeof fetch
});

expect(result.accessToken).toBe('cnc_auth_center_token');
expect(providerFetch).toHaveBeenCalledTimes(2);
expect(query).toHaveBeenCalledTimes(2);
expect(query.mock.calls[1]?.[1]).toEqual([
'00000000-0000-0000-0000-000000000001',
githubProvider.slug,
'12345',
'octo@example.com',
JSON.stringify({
name: 'Octo Cat',
username: 'octocat',
avatarUrl: 'https://avatars.githubusercontent.com/u/12345'
}),
'bearer',
false,
browserBinding
]);
});

it('consumes a cancelled Provider callback before returning a safe error', async () => {
const { context, query } = createContext([{
oauth_request_id: '00000000-0000-0000-0000-000000000001',
provider_key: githubProvider.slug,
code_verifier: verifier,
nonce: null,
redirect_uri: 'https://auth.example.com/auth/oauth/callback'
}]);

await expect(completeProviderAuthentication(context, surface, {
state: opaqueState,
providerReturnedError: true,
browserBinding,
requestTimeoutMs: 1000
})).rejects.toMatchObject({ code: 'OAUTH_AUTHORIZATION_CANCELLED' });
expect(query).toHaveBeenCalledTimes(1);
expect(context.useModule).not.toHaveBeenCalledWith('identityProviders');
});
});
1 change: 1 addition & 0 deletions graphql/server/src/auth/oauth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createOAuthRouter, type OAuthRouterOptions } from './router';
34 changes: 34 additions & 0 deletions graphql/server/src/auth/oauth/page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { ConstructiveError } from '@constructive-io/errors';

const escapeHtml = (value: string): string =>
value.replace(/[&<>'"]/g, character => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;'
})[character] ?? character);

const page = (title: string, body: string): string => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
</head>
<body>
<main>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(body)}</p>
</main>
</body>
</html>`;

export const renderOAuthFailurePage = (error: ConstructiveError): string =>
page('External sign in failed', `${error.message} (${error.code})`);

export const renderOAuthSuccessPage = (): string =>
page(
'External sign in completed',
'Authentication succeeded. You may close this page.'
);
Loading