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
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
60 changes: 60 additions & 0 deletions graphql/server/src/auth/sso/__tests__/plugin.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import path from 'node:path';

import { getConnections, seed } from 'graphile-test';

import { createUnifiedAuthPlugin } from '../plugin';

jest.setTimeout(60_000);

type Connections = Awaited<ReturnType<typeof getConnections>>;

describe('UnifiedAuthPlugin schema integration', () => {
let db: Connections['db'];
let query: Connections['query'];
let teardown: () => Promise<void>;

beforeAll(async () => {
const connections = await getConnections(
{
schemas: ['app_public'],
authRole: 'anonymous',
preset: { plugins: [createUnifiedAuthPlugin(false)] }
},
[
seed.sqlfile([
path.join(__dirname, '../../../../../server-test/sql/test.sql')
])
]
);
({ db, query, teardown } = connections);
});

beforeEach(() => db.beforeEach());
afterEach(() => db.afterEach());
afterAll(() => teardown());

it('adds the stable unified-auth Query and Mutation fields', async () => {
const response = await query<{
query: { fields: Array<{ name: string }> };
mutation: { fields: Array<{ name: string }> };
}>(`
query UnifiedAuthSchema {
query: __type(name: "Query") { fields { name } }
mutation: __type(name: "Mutation") { fields { name } }
}
`);

expect(response.errors).toBeUndefined();
expect(response.data?.query.fields.map(field => field.name)).toContain(
'unifiedAuthProviders'
);
expect(response.data?.mutation.fields.map(field => field.name)).toEqual(
expect.arrayContaining([
'startUnifiedLogin',
'confirmUnifiedLogin',
'signInUnifiedLogin',
'signUpUnifiedLogin'
])
);
});
});
197 changes: 197 additions & 0 deletions graphql/server/src/auth/sso/__tests__/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import type {
ConstructiveContext,
IdentityProviderConfig,
SsoSurface
} from '@constructive-io/express-context';
import type { PoolClient, QueryResult } from 'pg';

import { createUnifiedAuthService } from '../service';

const opaque = 'a'.repeat(43);
const surface: SsoSurface = { privateSchema: 'tenant_acme_sso_private' };

const googleProvider: IdentityProviderConfig = {
id: 'provider-id',
slug: 'google-workspace',
kind: 'google',
displayName: 'Google Workspace',
enabled: true,
clientId: 'client-id',
clientSecret: 'client-secret',
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
userinfoUrl: null,
issuerUrl: 'https://accounts.google.com',
discoveryUrlOverride: null,
discoveryDoc: null,
jwks: { keys: [] },
jwksFetchedAt: null,
acceptableClientIds: [],
scopes: ['openid', 'email', 'profile'],
extraAuthorizationParams: {},
emailOptional: false,
allowLinkByEmail: false,
skipNonceCheck: false,
pkceEnabled: true
};

const makeContext = (
databaseResult?: Record<string, unknown>,
options: {
userId?: string | null;
providers?: Record<string, IdentityProviderConfig>;
} = {}
): { 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 = {
userId: options.userId ?? null,
useModule: jest.fn(async (name: string) => {
if (name === 'ssoSurface') return surface;
if (name === 'identityProviders') {
return options.providers
? { providers: options.providers, source: { schemaName: 'p', tableName: 'p' } }
: undefined;
}
return undefined;
}),
withPgClient: jest.fn(async (callback: (pg: PoolClient) => Promise<unknown>) =>
callback(client)
)
} as unknown as ConstructiveContext;
return { context, query };
};

describe('unified authentication GraphQL service', () => {
it('returns no Provider options without resolving secrets when OAuth is disabled', async () => {
const { context } = makeContext(undefined, { providers: { google: googleProvider } });
const service = createUnifiedAuthService(false);

await expect(service.providers({ constructive: context })).resolves.toEqual([]);
expect(context.useModule).not.toHaveBeenCalledWith('identityProviders');
});

it('returns only safe dynamic Provider display fields', async () => {
const { context } = makeContext(undefined, {
providers: {
google: googleProvider,
custom: { ...googleProvider, slug: 'custom', kind: 'custom' }
}
});
const service = createUnifiedAuthService(true);

await expect(service.providers({ constructive: context })).resolves.toEqual([
{ key: 'google-workspace', displayName: 'Google Workspace' }
]);
});

it('starts through the current Tenant SSO function and merges Provider options', async () => {
const { context, query } = makeContext({
site_id: '00000000-0000-0000-0000-000000000001',
site_display_name: 'Customer Portal',
site_icon_url: null,
site_theme_color: '#112233',
sign_in_mode: 'confirm',
reusable_authentication: false,
current_user_id: null
}, { providers: { google: googleProvider } });
const service = createUnifiedAuthService(true);

const result = await service.start(
{ constructive: context, browserBinding: opaque },
{
siteId: '00000000-0000-0000-0000-000000000001',
returnTo: '/approvals/42',
siteState: opaque
}
);

expect(result.providers).toEqual([
{ key: 'google-workspace', displayName: 'Google Workspace' }
]);
expect(result.transactionId).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(result.site.displayName).toBe('Customer Portal');
expect(query.mock.calls[0][0]).toContain(
'"tenant_acme_sso_private"."start_unified_login"'
);
expect(query.mock.calls[0][1]).toEqual([
expect.stringMatching(/^\\x[0-9a-f]{64}$/),
'00000000-0000-0000-0000-000000000001',
null,
'/approvals/42',
opaque,
expect.stringMatching(/^\\x[0-9a-f]{64}$/)
]);
});

it('uses the fixed local-password wrapper contract once', async () => {
const { context, query } = makeContext({
id: '00000000-0000-0000-0000-000000000010',
user_id: '00000000-0000-0000-0000-000000000011',
access_token: 'cnc_live_bt_secret',
access_token_expires_at: '2026-08-10T00:00:00.000Z',
is_verified: false,
totp_enabled: false,
mfa_required: false
});
const service = createUnifiedAuthService(false);

const result = await service.signIn(
{ constructive: context, browserBinding: opaque },
{
transactionId: opaque,
email: 'user@example.com',
password: 'correct horse battery staple',
rememberMe: true
}
);

expect(result.accessToken).toBe('cnc_live_bt_secret');
expect(result.continuationUrl).toBeNull();
expect(query).toHaveBeenCalledTimes(1);
expect(query.mock.calls[0][0]).toContain(
'"tenant_acme_sso_private"."sign_in_unified_login"'
);
expect(query.mock.calls[0][1]).toEqual([
expect.stringMatching(/^\\x[0-9a-f]{64}$/),
'user@example.com',
'correct horse battery staple',
true,
'bearer',
expect.stringMatching(/^\\x[0-9a-f]{64}$/),
null,
null
]);
});

it('rejects a cross-origin return target before database access', async () => {
const { context, query } = makeContext();
const service = createUnifiedAuthService(false);

await expect(service.start(
{ constructive: context, browserBinding: opaque },
{
siteId: '00000000-0000-0000-0000-000000000001',
returnTo: 'https://evil.example/steal',
siteState: opaque
}
)).rejects.toMatchObject({ code: 'INVALID_SSO_RETURN_TARGET' });
expect(query).not.toHaveBeenCalled();
});

it('requires the server-read first-party browser binding', async () => {
const { context, query } = makeContext();
const service = createUnifiedAuthService(false);

await expect(service.start(
{ constructive: context },
{
siteId: '00000000-0000-0000-0000-000000000001',
siteState: opaque
}
)).rejects.toMatchObject({ code: 'INVALID_SSO_SITE_STATE' });
expect(query).not.toHaveBeenCalled();
});
});
Loading