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
8 changes: 6 additions & 2 deletions graphql/server/src/auth/oauth/__tests__/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,22 @@ describe('OAuth HTTP routes', () => {
accessTokenExpiresAt: '2026-08-10T12:00:00.000Z',
isVerified: true,
totpEnabled: false,
continuationUrl: null
continuationUrl:
'https://portal.example.com/auth/complete?handoff=handoff-code&site_state=site-state'
});

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

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.headers.location).toBe(
'https://portal.example.com/auth/complete?handoff=handoff-code&site_state=site-state'
);
expect(response.text).not.toContain('cnc_auth_center_token');
});

Expand Down
10 changes: 8 additions & 2 deletions graphql/server/src/auth/oauth/__tests__/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ describe('Provider OAuth orchestration', () => {
is_verified: true,
totp_enabled: false,
mfa_required: false,
continuation_url: null
callback_url: 'https://portal.example.com/auth/complete',
site_state: 't'.repeat(43),
handoff_expires_at: '2026-08-10T12:01:00.000Z'
}
]);
const providerFetch = jest.fn()
Expand All @@ -136,6 +138,9 @@ describe('Provider OAuth orchestration', () => {
});

expect(result.accessToken).toBe('cnc_auth_center_token');
expect(result.continuationUrl).toMatch(
/^https:\/\/portal\.example\.com\/auth\/complete\?handoff=/
);
expect(providerFetch).toHaveBeenCalledTimes(2);
expect(query).toHaveBeenCalledTimes(2);
expect(query.mock.calls[1]?.[1]).toEqual([
Expand All @@ -150,7 +155,8 @@ describe('Provider OAuth orchestration', () => {
}),
'bearer',
false,
browserBinding
browserBinding,
expect.stringMatching(/^\\x[0-9a-f]{64}$/)
]);
});

Expand Down
6 changes: 0 additions & 6 deletions graphql/server/src/auth/oauth/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,3 @@ const page = (title: string, body: string): string => `<!doctype 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.'
);
11 changes: 2 additions & 9 deletions graphql/server/src/auth/oauth/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ import {
getSessionCookieConfig,
setSessionCookie
} from '../../middleware/cookie';
import {
renderOAuthFailurePage,
renderOAuthSuccessPage
} from './page';
import { renderOAuthFailurePage } from './page';
import {
completeProviderAuthentication,
createProviderAuthorizationUrl
Expand Down Expand Up @@ -124,11 +121,7 @@ export const createOAuthRouter = (options: OAuthRouterOptions): Router => {
secure: true
};
setSessionCookie(res, result.accessToken, cookieConfig);
if (result.continuationUrl) {
res.redirect(303, result.continuationUrl);
return;
}
res.status(200).type('html').send(renderOAuthSuccessPage());
res.redirect(303, result.continuationUrl);
} catch (cause) {
sendFailure(req, res, cause);
}
Expand Down
4 changes: 3 additions & 1 deletion graphql/server/src/auth/oauth/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ProviderAdapterError
} from '@constructive-io/oauth';

import { createHandoffMaterial } from '../sso/handoff';
import { resolveConfiguredProvider } from '../sso/provider-config';
import {
completeProviderUnifiedLogin,
Expand Down Expand Up @@ -123,6 +124,7 @@ export const completeProviderAuthentication = async (
return completeProviderUnifiedLogin(context, surface, {
requestId: request.requestId,
identity,
browserBinding: input.browserBinding
browserBinding: input.browserBinding,
handoff: createHandoffMaterial()
});
};
49 changes: 49 additions & 0 deletions graphql/server/src/auth/sso/__tests__/handoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
buildHandoffContinuationUrl,
createHandoffMaterial,
hashHandoffCode
} from '../handoff';

describe('SSO handoff primitives', () => {
it('creates 256-bit plaintext and keeps only its SHA-256 bytea digest', () => {
const first = createHandoffMaterial();
const second = createHandoffMaterial();

expect(first.code).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(first.hash).toMatch(/^\\x[0-9a-f]{64}$/);
expect(first.hash).toBe(hashHandoffCode(first.code));
expect(first.code).not.toBe(second.code);
});

it('adds only handoff and Site state to an exact HTTPS callback', () => {
const result = buildHandoffContinuationUrl(
'https://portal.example.com/auth/complete?locale=en',
's'.repeat(43),
'h'.repeat(43)
);
const callback = new URL(result);

expect(callback.origin).toBe('https://portal.example.com');
expect(callback.pathname).toBe('/auth/complete');
expect(callback.searchParams.get('locale')).toBe('en');
expect(callback.searchParams.get('handoff')).toBe('h'.repeat(43));
expect(callback.searchParams.get('site_state')).toBe('s'.repeat(43));
});

it('fails closed for non-HTTPS or reserved callback parameters', () => {
expect(() => buildHandoffContinuationUrl(
'http://portal.example.com/auth/complete',
's'.repeat(43),
'h'.repeat(43)
)).toThrow();
expect(() => buildHandoffContinuationUrl(
'https://portal.example.com/auth/complete?handoff=attacker',
's'.repeat(43),
'h'.repeat(43)
)).toThrow();
});

it('rejects malformed redemption codes before hashing', () => {
expect(() => hashHandoffCode('short')).toThrow();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ describe('UnifiedAuthPlugin schema integration', () => {
'confirmUnifiedLogin',
'signInUnifiedLogin',
'signUpUnifiedLogin',
'startProviderAuthentication'
'startProviderAuthentication',
'redeemUnifiedLoginHandoff'
])
);
});
Expand Down
125 changes: 122 additions & 3 deletions graphql/server/src/auth/sso/__tests__/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,28 @@ const makeContext = (
options: {
userId?: string | null;
providers?: Record<string, IdentityProviderConfig>;
runtime?: boolean;
} = {}
): { context: ConstructiveContext; query: jest.Mock } => {
const query = jest.fn(async () => ({
rows: databaseResult === undefined ? [] : [{ result: databaseResult }]
} as unknown as QueryResult));
const client = { query } as unknown as PoolClient;
const context = {
api: {
apiId: options.runtime
? '00000000-0000-0000-0000-000000000020'
: undefined
},
token: options.runtime
? {
id: '00000000-0000-0000-0000-000000000021',
user_id: '00000000-0000-0000-0000-000000000022',
principal_id: '00000000-0000-0000-0000-000000000023',
kind: 'api_key',
access_level: 'full_access'
}
: null,
requestOrigin: 'https://auth.example.com',
userId: options.userId ?? null,
useModule: jest.fn(async (name: string) => {
Expand Down Expand Up @@ -134,7 +149,10 @@ describe('unified authentication GraphQL service', () => {
access_token_expires_at: '2026-08-10T00:00:00.000Z',
is_verified: false,
totp_enabled: false,
mfa_required: false
mfa_required: false,
callback_url: 'https://portal.example.com/auth/complete',
site_state: opaque,
handoff_expires_at: '2026-08-10T00:01:00.000Z'
});
const service = createUnifiedAuthService(false);

Expand All @@ -149,7 +167,9 @@ describe('unified authentication GraphQL service', () => {
);

expect(result.accessToken).toBe('cnc_live_bt_secret');
expect(result.continuationUrl).toBeNull();
expect(result.continuationUrl).toMatch(
/^https:\/\/portal\.example\.com\/auth\/complete\?handoff=[A-Za-z0-9_-]{43}&site_state=/
);
expect(query).toHaveBeenCalledTimes(1);
expect(query.mock.calls[0][0]).toContain(
'"tenant_acme_sso_private"."sign_in_unified_login"'
Expand All @@ -161,10 +181,109 @@ describe('unified authentication GraphQL service', () => {
true,
'bearer',
opaque,
null
null,
expect.stringMatching(/^\\x[0-9a-f]{64}$/)
]);
});

it('creates the same handoff continuation for reusable authentication', async () => {
const { context, query } = makeContext({
user_id: '00000000-0000-0000-0000-000000000011',
callback_url: 'https://portal.example.com/auth/complete',
site_state: opaque,
handoff_expires_at: '2026-08-10T00:01:00.000Z'
}, { userId: '00000000-0000-0000-0000-000000000011' });
const service = createUnifiedAuthService(false);

const result = await service.confirm(
{ constructive: context, browserBinding: opaque },
{ transactionId: opaque }
);

expect(result.continuationUrl).toMatch(
/^https:\/\/portal\.example\.com\/auth\/complete\?handoff=/
);
expect(query.mock.calls[0][0]).toContain(
'"tenant_acme_sso_private"."confirm_unified_login"'
);
expect(query.mock.calls[0][1]).toEqual([
opaque,
opaque,
expect.stringMatching(/^\\x[0-9a-f]{64}$/)
]);
});

it('creates the shared handoff through the registration wrapper', async () => {
const { context, query } = makeContext({
id: '00000000-0000-0000-0000-000000000010',
user_id: '00000000-0000-0000-0000-000000000011',
access_token: 'cnc_live_bt_registration',
access_token_expires_at: '2026-08-10T00:00:00.000Z',
is_verified: false,
totp_enabled: false,
mfa_required: false,
callback_url: 'https://portal.example.com/auth/complete',
site_state: opaque,
handoff_expires_at: '2026-08-10T00:01:00.000Z'
});
const service = createUnifiedAuthService(false);

await expect(service.signUp(
{ constructive: context, browserBinding: opaque },
{
transactionId: opaque,
email: 'new@example.com',
password: 'correct horse battery staple'
}
)).resolves.toMatchObject({
accessToken: 'cnc_live_bt_registration',
continuationUrl: expect.stringMatching(/handoff=/)
});
expect(query.mock.calls[0][0]).toContain(
'"tenant_acme_sso_private"."sign_up_unified_login"'
);
});

it('redeems through an authenticated routed Site runtime API key', async () => {
const { context, query } = makeContext({
id: '00000000-0000-0000-0000-000000000030',
user_id: '00000000-0000-0000-0000-000000000031',
access_token: 'cnc_live_bt_site',
access_token_expires_at: '2026-08-10T01:00:00.000Z',
is_verified: true,
totp_enabled: false,
mfa_required: false,
return_to: '/approvals/42'
}, { runtime: true });
const service = createUnifiedAuthService(false);
const handoffCode = 'h'.repeat(43);

await expect(service.redeem(
{ constructive: context },
{ handoffCode }
)).resolves.toMatchObject({
accessToken: 'cnc_live_bt_site',
returnTo: '/approvals/42'
});
expect(query.mock.calls[0][0]).toContain(
'"tenant_acme_sso_private"."redeem_sso_handoff"'
);
expect(query.mock.calls[0][1]).toEqual([
expect.stringMatching(/^\\x[0-9a-f]{64}$/)
]);
});

it('does not let an auth-center browser credential redeem a Site handoff', async () => {
const { context, query } = makeContext();
const service = createUnifiedAuthService(false);

await expect(service.redeem(
{ constructive: context },
{ handoffCode: 'h'.repeat(43) }
)).rejects.toMatchObject({ code: 'UNAUTHENTICATED' });
expect(query).not.toHaveBeenCalled();
});

it('starts Provider authentication without exposing transaction or PKCE secrets', async () => {
const { context, query } = makeContext({
oauth_request_id: '00000000-0000-0000-0000-000000000099'
Expand Down
Loading