Skip to content

Commit f0e08d5

Browse files
committed
feat: add OAuth provider HTTP flow
1 parent b4e9292 commit f0e08d5

23 files changed

Lines changed: 1178 additions & 81 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { errors } from '@constructive-io/errors';
2+
import type { ConstructiveContext } from '@constructive-io/express-context';
3+
import express from 'express';
4+
import supertest from 'supertest';
5+
6+
import { createOAuthRouter } from '../router';
7+
import {
8+
completeProviderAuthentication,
9+
createProviderAuthorizationUrl
10+
} from '../service';
11+
12+
jest.mock('../service', () => ({
13+
completeProviderAuthentication: jest.fn(),
14+
createProviderAuthorizationUrl: jest.fn()
15+
}));
16+
17+
const mockedAuthorize = jest.mocked(createProviderAuthorizationUrl);
18+
const mockedComplete = jest.mocked(completeProviderAuthentication);
19+
const opaqueState = 's'.repeat(43);
20+
21+
const makeApp = () => {
22+
const app = express();
23+
const context = {
24+
useModule: jest.fn(async () => ({ privateSchema: 'tenant_sso_private' }))
25+
} as unknown as ConstructiveContext;
26+
app.use((req, _res, next) => {
27+
req.constructive = context;
28+
req.cookies = { csrf_token: 'b'.repeat(64) };
29+
req.deviceToken = 'device-token';
30+
req.api = {
31+
dbname: 'tenant',
32+
anonRole: 'anonymous',
33+
roleName: 'anonymous',
34+
schema: [],
35+
authSettings: {
36+
cookieDomain: '.example.com',
37+
cookieSecure: false,
38+
cookieHttponly: false
39+
}
40+
};
41+
next();
42+
});
43+
app.use('/auth/oauth', createOAuthRouter({ requestTimeoutMs: 1000 }));
44+
return app;
45+
};
46+
47+
describe('OAuth HTTP routes', () => {
48+
beforeEach(() => jest.clearAllMocks());
49+
50+
it('redirects authorize using only the server-restored adapter URL', async () => {
51+
mockedAuthorize.mockResolvedValue(
52+
'https://github.com/login/oauth/authorize?state=provider-state'
53+
);
54+
55+
const response = await supertest(makeApp())
56+
.get(`/auth/oauth/authorize?state=${opaqueState}`)
57+
.expect(303);
58+
59+
expect(response.headers.location).toBe(
60+
'https://github.com/login/oauth/authorize?state=provider-state'
61+
);
62+
expect(response.headers['cache-control']).toBe('no-store');
63+
expect(response.headers['referrer-policy']).toBe('no-referrer');
64+
});
65+
66+
it('sets a Secure HttpOnly host-only auth-center cookie after callback', async () => {
67+
mockedComplete.mockResolvedValue({
68+
credentialId: '00000000-0000-0000-0000-000000000001',
69+
userId: '00000000-0000-0000-0000-000000000002',
70+
accessToken: 'cnc_auth_center_token',
71+
accessTokenExpiresAt: '2026-08-10T12:00:00.000Z',
72+
isVerified: true,
73+
totpEnabled: false,
74+
continuationUrl: null
75+
});
76+
77+
const response = await supertest(makeApp())
78+
.get(`/auth/oauth/callback?state=${opaqueState}&code=provider-code`)
79+
.expect(200);
80+
81+
const cookie = response.headers['set-cookie'][0] as string;
82+
expect(cookie).toContain('constructive_session=cnc_auth_center_token');
83+
expect(cookie).toContain('Secure');
84+
expect(cookie).toContain('HttpOnly');
85+
expect(cookie).not.toContain('Domain=');
86+
expect(response.text).not.toContain('cnc_auth_center_token');
87+
expect(mockedComplete).toHaveBeenCalledWith(
88+
expect.anything(),
89+
expect.anything(),
90+
expect.objectContaining({ deviceToken: 'device-token' })
91+
);
92+
});
93+
94+
it('returns only a stable safe cancellation classification', async () => {
95+
mockedComplete.mockRejectedValue(errors.OAUTH_AUTHORIZATION_CANCELLED());
96+
97+
const response = await supertest(makeApp())
98+
.get(
99+
`/auth/oauth/callback?state=${opaqueState}` +
100+
'&error=access_denied&error_description=provider-secret-detail'
101+
)
102+
.expect(400);
103+
104+
expect(response.text).toContain('OAUTH_AUTHORIZATION_CANCELLED');
105+
expect(response.text).not.toContain('provider-secret-detail');
106+
expect(mockedComplete).toHaveBeenCalledWith(
107+
expect.anything(),
108+
expect.anything(),
109+
expect.objectContaining({ providerReturnedError: true })
110+
);
111+
});
112+
});
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import type {
2+
ConstructiveContext,
3+
IdentityProviderConfig,
4+
SsoSurface
5+
} from '@constructive-io/express-context';
6+
import type { PoolClient, QueryResult } from 'pg';
7+
8+
import {
9+
completeProviderAuthentication,
10+
createProviderAuthorizationUrl
11+
} from '../service';
12+
13+
const opaqueState = 's'.repeat(43);
14+
const browserBinding = 'b'.repeat(64);
15+
const verifier = 'v'.repeat(43);
16+
const surface: SsoSurface = { privateSchema: 'tenant_acme_sso_private' };
17+
18+
const githubProvider: IdentityProviderConfig = {
19+
id: 'provider-id',
20+
slug: 'github-enterprise',
21+
kind: 'github',
22+
displayName: 'GitHub',
23+
enabled: true,
24+
clientId: 'client-id',
25+
clientSecret: 'client-secret',
26+
authorizationUrl: 'https://github.com/login/oauth/authorize',
27+
tokenUrl: 'https://github.com/login/oauth/access_token',
28+
userinfoUrl: 'https://api.github.com/user',
29+
issuerUrl: null,
30+
discoveryUrlOverride: null,
31+
discoveryDoc: null,
32+
jwks: null,
33+
jwksFetchedAt: null,
34+
acceptableClientIds: [],
35+
scopes: ['read:user', 'user:email'],
36+
extraAuthorizationParams: {},
37+
emailOptional: false,
38+
allowLinkByEmail: false,
39+
skipNonceCheck: false,
40+
pkceEnabled: true
41+
};
42+
43+
const createContext = (results: Record<string, unknown>[]) => {
44+
const query = jest.fn(async (..._args: unknown[]) => ({
45+
rows: [{ result: results.shift() }]
46+
} as unknown as QueryResult));
47+
const client = { query } as unknown as PoolClient;
48+
const context = {
49+
useModule: jest.fn(async (name: string) => name === 'identityProviders'
50+
? {
51+
providers: { [githubProvider.slug]: githubProvider },
52+
source: { schemaName: 'private', tableName: 'identity_providers' }
53+
}
54+
: undefined),
55+
withPgClient: jest.fn(async (callback: (pg: PoolClient) => Promise<unknown>) =>
56+
callback(client)
57+
)
58+
} as unknown as ConstructiveContext;
59+
return { context, query };
60+
};
61+
62+
describe('Provider OAuth orchestration', () => {
63+
it('rejects malformed state before database access', async () => {
64+
const { context, query } = createContext([]);
65+
await expect(createProviderAuthorizationUrl(
66+
context,
67+
surface,
68+
'not-a-state',
69+
browserBinding
70+
)).rejects.toMatchObject({ code: 'INVALID_OAUTH_STATE' });
71+
expect(query).not.toHaveBeenCalled();
72+
});
73+
74+
it('builds authorization through the configured adapter without exposing verifier', async () => {
75+
const { context } = createContext([{
76+
oauth_request_id: '00000000-0000-0000-0000-000000000001',
77+
provider_key: githubProvider.slug,
78+
code_verifier: verifier,
79+
nonce: null,
80+
redirect_uri: 'https://auth.example.com/auth/oauth/callback'
81+
}]);
82+
83+
const url = await createProviderAuthorizationUrl(
84+
context,
85+
surface,
86+
opaqueState,
87+
browserBinding
88+
);
89+
const parsed = new URL(url);
90+
expect(parsed.origin).toBe('https://github.com');
91+
expect(parsed.searchParams.get('state')).toBe(opaqueState);
92+
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
93+
expect(parsed.searchParams.get('code_challenge')).not.toBe(verifier);
94+
expect(url).not.toContain(verifier);
95+
});
96+
97+
it('consumes state, mocks only Provider HTTP, and applies normalized identity', async () => {
98+
const { context, query } = createContext([
99+
{
100+
oauth_request_id: '00000000-0000-0000-0000-000000000001',
101+
provider_key: githubProvider.slug,
102+
code_verifier: verifier,
103+
nonce: null,
104+
redirect_uri: 'https://auth.example.com/auth/oauth/callback'
105+
},
106+
{
107+
id: '00000000-0000-0000-0000-000000000002',
108+
user_id: '00000000-0000-0000-0000-000000000003',
109+
access_token: 'cnc_auth_center_token',
110+
access_token_expires_at: '2026-08-10T12:00:00.000Z',
111+
is_verified: true,
112+
totp_enabled: false,
113+
mfa_required: false,
114+
continuation_url: null
115+
}
116+
]);
117+
const providerFetch = jest.fn()
118+
.mockResolvedValueOnce(new Response(JSON.stringify({
119+
access_token: 'github-server-token'
120+
}), { status: 200, headers: { 'content-type': 'application/json' } }))
121+
.mockResolvedValueOnce(new Response(JSON.stringify({
122+
id: 12345,
123+
login: 'octocat',
124+
name: 'Octo Cat',
125+
email: 'octo@example.com',
126+
avatar_url: 'https://avatars.githubusercontent.com/u/12345'
127+
}), { status: 200, headers: { 'content-type': 'application/json' } }));
128+
129+
const result = await completeProviderAuthentication(context, surface, {
130+
state: opaqueState,
131+
code: 'provider-authorization-code',
132+
providerReturnedError: false,
133+
browserBinding,
134+
deviceToken: null,
135+
requestTimeoutMs: 1000,
136+
fetch: providerFetch as typeof fetch
137+
});
138+
139+
expect(result.accessToken).toBe('cnc_auth_center_token');
140+
expect(providerFetch).toHaveBeenCalledTimes(2);
141+
expect(query).toHaveBeenCalledTimes(2);
142+
expect(query.mock.calls[1]?.[1]).toEqual([
143+
'00000000-0000-0000-0000-000000000001',
144+
githubProvider.slug,
145+
'12345',
146+
'octo@example.com',
147+
JSON.stringify({
148+
name: 'Octo Cat',
149+
username: 'octocat',
150+
avatarUrl: 'https://avatars.githubusercontent.com/u/12345'
151+
}),
152+
'bearer',
153+
false,
154+
null,
155+
expect.stringMatching(/^\\x[0-9a-f]{64}$/)
156+
]);
157+
});
158+
159+
it('consumes a cancelled Provider callback before returning a safe error', async () => {
160+
const { context, query } = createContext([{
161+
oauth_request_id: '00000000-0000-0000-0000-000000000001',
162+
provider_key: githubProvider.slug,
163+
code_verifier: verifier,
164+
nonce: null,
165+
redirect_uri: 'https://auth.example.com/auth/oauth/callback'
166+
}]);
167+
168+
await expect(completeProviderAuthentication(context, surface, {
169+
state: opaqueState,
170+
providerReturnedError: true,
171+
browserBinding,
172+
deviceToken: null,
173+
requestTimeoutMs: 1000
174+
})).rejects.toMatchObject({ code: 'OAUTH_AUTHORIZATION_CANCELLED' });
175+
expect(query).toHaveBeenCalledTimes(1);
176+
expect(context.useModule).not.toHaveBeenCalledWith('identityProviders');
177+
});
178+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { createOAuthRouter, type OAuthRouterOptions } from './router';
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { ConstructiveError } from '@constructive-io/errors';
2+
3+
const escapeHtml = (value: string): string =>
4+
value.replace(/[&<>'"]/g, character => ({
5+
'&': '&amp;',
6+
'<': '&lt;',
7+
'>': '&gt;',
8+
"'": '&#39;',
9+
'"': '&quot;'
10+
})[character] ?? character);
11+
12+
const page = (title: string, body: string): string => `<!doctype html>
13+
<html lang="en">
14+
<head>
15+
<meta charset="utf-8">
16+
<meta name="viewport" content="width=device-width, initial-scale=1">
17+
<title>${escapeHtml(title)}</title>
18+
</head>
19+
<body>
20+
<main>
21+
<h1>${escapeHtml(title)}</h1>
22+
<p>${escapeHtml(body)}</p>
23+
</main>
24+
</body>
25+
</html>`;
26+
27+
export const renderOAuthFailurePage = (error: ConstructiveError): string =>
28+
page('External sign in failed', `${error.message} (${error.code})`);
29+
30+
export const renderOAuthSuccessPage = (): string =>
31+
page(
32+
'External sign in completed',
33+
'Authentication succeeded. You may close this page.'
34+
);

0 commit comments

Comments
 (0)