Skip to content

Commit 1810ef5

Browse files
committed
feat: add unified auth handoff flow
1 parent f0e08d5 commit 1810ef5

24 files changed

Lines changed: 810 additions & 85 deletions

‎graphql/server/src/auth/oauth/__tests__/router.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,22 @@ describe('OAuth HTTP routes', () => {
7171
accessTokenExpiresAt: '2026-08-10T12:00:00.000Z',
7272
isVerified: true,
7373
totpEnabled: false,
74-
continuationUrl: null
74+
continuationUrl:
75+
'https://portal.example.com/auth/complete?handoff=handoff-code&site_state=site-state'
7576
});
7677

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

8182
const cookie = response.headers['set-cookie'][0] as string;
8283
expect(cookie).toContain('constructive_session=cnc_auth_center_token');
8384
expect(cookie).toContain('Secure');
8485
expect(cookie).toContain('HttpOnly');
8586
expect(cookie).not.toContain('Domain=');
87+
expect(response.headers.location).toBe(
88+
'https://portal.example.com/auth/complete?handoff=handoff-code&site_state=site-state'
89+
);
8690
expect(response.text).not.toContain('cnc_auth_center_token');
8791
expect(mockedComplete).toHaveBeenCalledWith(
8892
expect.anything(),

‎graphql/server/src/auth/oauth/__tests__/service.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ describe('Provider OAuth orchestration', () => {
111111
is_verified: true,
112112
totp_enabled: false,
113113
mfa_required: false,
114-
continuation_url: null
114+
callback_url: 'https://portal.example.com/auth/complete',
115+
site_state: 't'.repeat(43),
116+
handoff_expires_at: '2026-08-10T12:01:00.000Z'
115117
}
116118
]);
117119
const providerFetch = jest.fn()
@@ -137,6 +139,9 @@ describe('Provider OAuth orchestration', () => {
137139
});
138140

139141
expect(result.accessToken).toBe('cnc_auth_center_token');
142+
expect(result.continuationUrl).toMatch(
143+
/^https:\/\/portal\.example\.com\/auth\/complete\?handoff=/
144+
);
140145
expect(providerFetch).toHaveBeenCalledTimes(2);
141146
expect(query).toHaveBeenCalledTimes(2);
142147
expect(query.mock.calls[1]?.[1]).toEqual([
@@ -152,6 +157,7 @@ describe('Provider OAuth orchestration', () => {
152157
'bearer',
153158
false,
154159
null,
160+
expect.stringMatching(/^\\x[0-9a-f]{64}$/),
155161
expect.stringMatching(/^\\x[0-9a-f]{64}$/)
156162
]);
157163
});

‎graphql/server/src/auth/oauth/page.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,3 @@ const page = (title: string, body: string): string => `<!doctype html>
2626

2727
export const renderOAuthFailurePage = (error: ConstructiveError): string =>
2828
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-
);

‎graphql/server/src/auth/oauth/router.ts‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,7 @@ import {
1212
getSessionCookieConfig,
1313
setSessionCookie
1414
} from '../../middleware/cookie';
15-
import {
16-
renderOAuthFailurePage,
17-
renderOAuthSuccessPage
18-
} from './page';
15+
import { renderOAuthFailurePage } from './page';
1916
import {
2017
completeProviderAuthentication,
2118
createProviderAuthorizationUrl
@@ -125,11 +122,7 @@ export const createOAuthRouter = (options: OAuthRouterOptions): Router => {
125122
secure: true
126123
};
127124
setSessionCookie(res, result.accessToken, cookieConfig);
128-
if (result.continuationUrl) {
129-
res.redirect(303, result.continuationUrl);
130-
return;
131-
}
132-
res.status(200).type('html').send(renderOAuthSuccessPage());
125+
res.redirect(303, result.continuationUrl);
133126
} catch (cause) {
134127
sendFailure(req, res, cause);
135128
}

‎graphql/server/src/auth/oauth/service.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ProviderAdapterError
1010
} from '@constructive-io/oauth';
1111

12+
import { createHandoffMaterial } from '../sso/handoff';
1213
import { resolveConfiguredProvider } from '../sso/provider-config';
1314
import {
1415
completeProviderUnifiedLogin,
@@ -125,6 +126,7 @@ export const completeProviderAuthentication = async (
125126
requestId: request.requestId,
126127
identity,
127128
browserBinding: input.browserBinding,
128-
deviceToken: input.deviceToken
129+
deviceToken: input.deviceToken,
130+
handoff: createHandoffMaterial()
129131
});
130132
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
buildHandoffContinuationUrl,
3+
createHandoffMaterial,
4+
hashHandoffCode
5+
} from '../handoff';
6+
7+
describe('SSO handoff primitives', () => {
8+
it('creates 256-bit plaintext and keeps only its SHA-256 bytea digest', () => {
9+
const first = createHandoffMaterial();
10+
const second = createHandoffMaterial();
11+
12+
expect(first.code).toMatch(/^[A-Za-z0-9_-]{43}$/);
13+
expect(first.hash).toMatch(/^\\x[0-9a-f]{64}$/);
14+
expect(first.hash).toBe(hashHandoffCode(first.code));
15+
expect(first.code).not.toBe(second.code);
16+
});
17+
18+
it('adds only handoff and Site state to an exact HTTPS callback', () => {
19+
const result = buildHandoffContinuationUrl(
20+
'https://portal.example.com/auth/complete?locale=en',
21+
's'.repeat(43),
22+
'h'.repeat(43)
23+
);
24+
const callback = new URL(result);
25+
26+
expect(callback.origin).toBe('https://portal.example.com');
27+
expect(callback.pathname).toBe('/auth/complete');
28+
expect(callback.searchParams.get('locale')).toBe('en');
29+
expect(callback.searchParams.get('handoff')).toBe('h'.repeat(43));
30+
expect(callback.searchParams.get('site_state')).toBe('s'.repeat(43));
31+
});
32+
33+
it('fails closed for non-HTTPS or reserved callback parameters', () => {
34+
expect(() => buildHandoffContinuationUrl(
35+
'http://portal.example.com/auth/complete',
36+
's'.repeat(43),
37+
'h'.repeat(43)
38+
)).toThrow();
39+
expect(() => buildHandoffContinuationUrl(
40+
'https://portal.example.com/auth/complete?handoff=attacker',
41+
's'.repeat(43),
42+
'h'.repeat(43)
43+
)).toThrow();
44+
});
45+
46+
it('rejects malformed redemption codes before hashing', () => {
47+
expect(() => hashHandoffCode('short')).toThrow();
48+
});
49+
});

‎graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ describe('UnifiedAuthPlugin schema integration', () => {
5454
'confirmUnifiedLogin',
5555
'signInUnifiedLogin',
5656
'signUpUnifiedLogin',
57-
'startProviderAuthentication'
57+
'startProviderAuthentication',
58+
'redeemUnifiedLoginHandoff'
5859
])
5960
);
6061
});

‎graphql/server/src/auth/sso/__tests__/service.test.ts‎

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,35 @@ const makeContext = (
4040
options: {
4141
userId?: string | null;
4242
providers?: Record<string, IdentityProviderConfig>;
43+
runtime?: boolean;
4344
} = {}
4445
): { context: ConstructiveContext; query: jest.Mock } => {
4546
const query = jest.fn(async () => ({
4647
rows: databaseResult === undefined ? [] : [{ result: databaseResult }]
4748
} as unknown as QueryResult));
4849
const client = { query } as unknown as PoolClient;
4950
const context = {
51+
api: {
52+
apiId: options.runtime
53+
? '00000000-0000-0000-0000-000000000020'
54+
: undefined,
55+
siteId: options.runtime
56+
? '00000000-0000-0000-0000-000000000024'
57+
: undefined
58+
},
59+
token: options.runtime
60+
? {
61+
id: '00000000-0000-0000-0000-000000000021',
62+
user_id: '00000000-0000-0000-0000-000000000022',
63+
principal_id: '00000000-0000-0000-0000-000000000023',
64+
kind: 'api_key',
65+
access_level: 'full_access'
66+
}
67+
: null,
5068
requestOrigin: 'https://auth.example.com',
69+
siteId: options.runtime
70+
? '00000000-0000-0000-0000-000000000024'
71+
: null,
5172
userId: options.userId ?? null,
5273
useModule: jest.fn(async (name: string) => {
5374
if (name === 'ssoSurface') return surface;
@@ -135,7 +156,10 @@ describe('unified authentication GraphQL service', () => {
135156
access_token_expires_at: '2026-08-10T00:00:00.000Z',
136157
is_verified: false,
137158
totp_enabled: false,
138-
mfa_required: false
159+
mfa_required: false,
160+
callback_url: 'https://portal.example.com/auth/complete',
161+
site_state: opaque,
162+
handoff_expires_at: '2026-08-10T00:01:00.000Z'
139163
});
140164
const service = createUnifiedAuthService(false);
141165

@@ -150,7 +174,9 @@ describe('unified authentication GraphQL service', () => {
150174
);
151175

152176
expect(result.accessToken).toBe('cnc_live_bt_secret');
153-
expect(result.continuationUrl).toBeNull();
177+
expect(result.continuationUrl).toMatch(
178+
/^https:\/\/portal\.example\.com\/auth\/complete\?handoff=[A-Za-z0-9_-]{43}&site_state=/
179+
);
154180
expect(query).toHaveBeenCalledTimes(1);
155181
expect(query.mock.calls[0][0]).toContain(
156182
'"tenant_acme_sso_private"."sign_in_unified_login"'
@@ -163,10 +189,109 @@ describe('unified authentication GraphQL service', () => {
163189
'bearer',
164190
expect.stringMatching(/^\\x[0-9a-f]{64}$/),
165191
null,
166-
null
192+
null,
193+
expect.stringMatching(/^\\x[0-9a-f]{64}$/)
194+
]);
195+
});
196+
197+
it('creates the same handoff continuation for reusable authentication', async () => {
198+
const { context, query } = makeContext({
199+
user_id: '00000000-0000-0000-0000-000000000011',
200+
callback_url: 'https://portal.example.com/auth/complete',
201+
site_state: opaque,
202+
handoff_expires_at: '2026-08-10T00:01:00.000Z'
203+
}, { userId: '00000000-0000-0000-0000-000000000011' });
204+
const service = createUnifiedAuthService(false);
205+
206+
const result = await service.confirm(
207+
{ constructive: context, browserBinding: opaque },
208+
{ transactionId: opaque }
209+
);
210+
211+
expect(result.continuationUrl).toMatch(
212+
/^https:\/\/portal\.example\.com\/auth\/complete\?handoff=/
213+
);
214+
expect(query.mock.calls[0][0]).toContain(
215+
'"tenant_acme_sso_private"."confirm_unified_login"'
216+
);
217+
expect(query.mock.calls[0][1]).toEqual([
218+
expect.stringMatching(/^\\x[0-9a-f]{64}$/),
219+
expect.stringMatching(/^\\x[0-9a-f]{64}$/),
220+
expect.stringMatching(/^\\x[0-9a-f]{64}$/)
167221
]);
168222
});
169223

224+
it('creates the shared handoff through the registration wrapper', async () => {
225+
const { context, query } = makeContext({
226+
id: '00000000-0000-0000-0000-000000000010',
227+
user_id: '00000000-0000-0000-0000-000000000011',
228+
access_token: 'cnc_live_bt_registration',
229+
access_token_expires_at: '2026-08-10T00:00:00.000Z',
230+
is_verified: false,
231+
totp_enabled: false,
232+
mfa_required: false,
233+
callback_url: 'https://portal.example.com/auth/complete',
234+
site_state: opaque,
235+
handoff_expires_at: '2026-08-10T00:01:00.000Z'
236+
});
237+
const service = createUnifiedAuthService(false);
238+
239+
await expect(service.signUp(
240+
{ constructive: context, browserBinding: opaque },
241+
{
242+
transactionId: opaque,
243+
email: 'new@example.com',
244+
password: 'correct horse battery staple'
245+
}
246+
)).resolves.toMatchObject({
247+
accessToken: 'cnc_live_bt_registration',
248+
continuationUrl: expect.stringMatching(/handoff=/)
249+
});
250+
expect(query.mock.calls[0][0]).toContain(
251+
'"tenant_acme_sso_private"."sign_up_unified_login"'
252+
);
253+
});
254+
255+
it('redeems through an authenticated routed Site runtime API key', async () => {
256+
const { context, query } = makeContext({
257+
id: '00000000-0000-0000-0000-000000000030',
258+
user_id: '00000000-0000-0000-0000-000000000031',
259+
access_token: 'cnc_live_bt_site',
260+
access_token_expires_at: '2026-08-10T01:00:00.000Z',
261+
is_verified: true,
262+
totp_enabled: false,
263+
mfa_required: false,
264+
return_to: '/approvals/42'
265+
}, { runtime: true });
266+
const service = createUnifiedAuthService(false);
267+
const handoffCode = 'h'.repeat(43);
268+
269+
await expect(service.redeem(
270+
{ constructive: context },
271+
{ handoffCode }
272+
)).resolves.toMatchObject({
273+
accessToken: 'cnc_live_bt_site',
274+
returnTo: '/approvals/42'
275+
});
276+
expect(query.mock.calls[0][0]).toContain(
277+
'"tenant_acme_sso_private"."redeem_sso_handoff"'
278+
);
279+
expect(query.mock.calls[0][1]).toEqual([
280+
expect.stringMatching(/^\\x[0-9a-f]{64}$/)
281+
]);
282+
});
283+
284+
it('does not let an auth-center browser credential redeem a Site handoff', async () => {
285+
const { context, query } = makeContext();
286+
const service = createUnifiedAuthService(false);
287+
288+
await expect(service.redeem(
289+
{ constructive: context },
290+
{ handoffCode: 'h'.repeat(43) }
291+
)).rejects.toMatchObject({ code: 'UNAUTHENTICATED' });
292+
expect(query).not.toHaveBeenCalled();
293+
});
294+
170295
it('starts Provider authentication without exposing transaction or PKCE secrets', async () => {
171296
const { context, query } = makeContext({
172297
oauth_request_id: '00000000-0000-0000-0000-000000000099'

0 commit comments

Comments
 (0)