Skip to content
Closed
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
3 changes: 2 additions & 1 deletion graphql/server-test/src/get-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ export const getConnections = async (
exposedSchemas: input.schemas,
...(input.authRole && { anonRole: input.authRole, roleName: input.authRole })
},
graphile: input.graphile
graphile: input.graphile,
oauth: input.server?.oauth
});

// Start the HTTP server. Suites default to the production scoped-routing
Expand Down
8 changes: 7 additions & 1 deletion graphql/server-test/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { ApiOptions,GraphileOptions } from '@constructive-io/graphql-types';
import type {
ApiOptions,
GraphileOptions,
OAuthServerOptions
} from '@constructive-io/graphql-types';
import type { DocumentNode, GraphQLError } from 'graphql';
import type { Server } from 'http';
import type { PgTestClient } from 'pgsql-test/test-client';
Expand Down Expand Up @@ -39,6 +43,8 @@ export interface ServerOptions {
* ```
*/
api?: Partial<ApiOptions>;
/** GraphQL-server OAuth options forwarded through the normal typed config path. */
oauth?: OAuthServerOptions;
}

/**
Expand Down
1 change: 1 addition & 0 deletions graphql/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@constructive-io/graphql-env": "workspace:^",
"@constructive-io/graphql-types": "workspace:^",
"@constructive-io/llm-env": "workspace:^",
"@constructive-io/oauth": "workspace:^",
"@constructive-io/query-builder": "workspace:^",
"@constructive-io/s3-utils": "workspace:^",
"@constructive-io/url-domains": "workspace:^",
Expand Down
112 changes: 112 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,112 @@
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.deviceToken = 'device-token';
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');
expect(mockedComplete).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ deviceToken: 'device-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 })
);
});
});
178 changes: 178 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,178 @@
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,
deviceToken: null,
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,
null,
expect.stringMatching(/^\\x[0-9a-f]{64}$/)
]);
});

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,
deviceToken: null,
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