Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1633,6 +1633,10 @@ connections:
- email: admin@acme.com
first_name: Admin
last_name: User
# The profile's full name is its own attribute, as in production, where it is set only
# when the connection maps one. Omit it and the profile reports `name: null` — it is
# never composed from first_name and last_name.
name: Admin User
groups: [admins, it_staff]
- email: employee@acme.com
first_name: Regular
Expand Down
9 changes: 9 additions & 0 deletions scripts/gen-shapes-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export const OBJECT_SCHEMA_MAP: readonly ShapeMapEntry[] = [
{ objectType: 'api_key', schemaName: 'ApiKey' },
{ objectType: 'password_reset', schemaName: 'PasswordReset' },
{ objectType: 'feature_flag', schemaName: 'Flag' },
// The SSO profile is pure data once minted: `/sso/token` and `/sso/profile` both serve it
// through one formatter, which is where `name` was composed from `first_name` and
// `last_name` for a release — a field production stores on its own and never derives.
{ objectType: 'profile', schemaName: 'Profile' },
// The secretless factor GET and LIST return. `AuthenticationFactorEnrolled` shares its
// discriminator and top-level fields; the two differ only inside `totp`, below this loop's
// depth — enrollment's secrets are pinned by the route tests instead.
Expand Down Expand Up @@ -129,6 +133,11 @@ export const ENVELOPE_SCHEMA_MAP: readonly EnvelopeMapEntry[] = [
schemaName: 'AuthorizationCheck',
},
{ method: 'GET', path: '/sso/jwks/{clientId}', status: '200', schemaName: 'JwksResponse' },
// Unlike the two token endpoints excluded above, the spec documents this body
// (`SsoTokenResponse`), and it is the envelope that went out as a bare `{ access_token,
// profile }` for several releases (issue #129): every SDK requires `token_type` and
// `expires_in`, and the Python SDK refused to parse the response without them.
{ method: 'POST', path: '/sso/token', status: '200', schemaName: 'SsoTokenResponse' },
{ method: 'POST', path: '/client/token', status: '201', schemaName: 'ClientApiTokenResponse' },
// The created secret is the one place the plaintext `secret` is ever returned, so the
// envelope and the resource are the same body — listed here because no named spec schema
Expand Down
6 changes: 6 additions & 0 deletions src/workos/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,12 @@ export interface WorkOSSSOProfile extends Entity {
email: string;
first_name: string | null;
last_name: string | null;
/**
* The full name, stored as its own attribute the way production stores it: the reconciler
* sets it only when the connection's attribute map has a `name` key, and never composes it
* from the two parts. Null for every profile the emulator mints itself.
*/
name: string | null;
groups: string[];
raw_attributes: Record<string, unknown>;
}
Expand Down
38 changes: 38 additions & 0 deletions src/workos/generated/response-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,39 @@ export const RESPONSE_SHAPE_REQUIREMENTS: Record<string, ResponseShapeRequiremen
'updated_at',
],
},
profile: {
schema: 'Profile',
properties: [
'connection_id',
'connection_type',
'custom_attributes',
'email',
'first_name',
'groups',
'id',
'idp_id',
'last_name',
'name',
'object',
'organization_id',
'raw_attributes',
'role',
'roles',
],
required: [
'connection_id',
'connection_type',
'email',
'first_name',
'id',
'idp_id',
'last_name',
'name',
'object',
'organization_id',
'raw_attributes',
],
},
role: {
schema: 'Role',
properties: [
Expand Down Expand Up @@ -448,6 +481,11 @@ export const RESPONSE_ENVELOPE_REQUIREMENTS: Record<string, ResponseShapeRequire
properties: ['link'],
required: ['link'],
},
'POST /sso/token': {
schema: 'SsoTokenResponse',
properties: ['access_token', 'expires_in', 'oauth_tokens', 'profile', 'token_type'],
required: ['access_token', 'expires_in', 'profile', 'token_type'],
},
'POST /user_management/password_reset': {
schema: 'PasswordReset',
properties: [
Expand Down
17 changes: 16 additions & 1 deletion src/workos/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,8 +597,23 @@ export function formatConnection(conn: WorkOSConnection): Record<string, unknown
return formatEntity(conn);
}

/**
* The spec's Profile has no timestamps, and production's serializer
* (`api/src/sso/utils/serialize-profile.ts`) emits none — `created_at` and `updated_at` are
* the store's bookkeeping, not part of the wire shape.
*/
const SSO_PROFILE_EXCLUDE = new Set([...INTERNAL_FIELDS, 'created_at', 'updated_at']);

/**
* The spec's Profile carries `name` (the user's full name, nullable) beside `first_name` and
* `last_name`, and the SDKs read it as a required key — workos-python's `Profile.from_dict` fails
* without it (#129). It is emitted as stored, never composed from the two parts: production's
* reconciler (`api/src/profiles/profile-reconciler.ts`) sets `name` only when the connection's
* attribute map has a `name` key, and its serializer returns that stored value or null. A
* derived "Ada Lovelace" would pass against the emulator and come back null from WorkOS.
*/
export function formatSSOProfile(p: WorkOSSSOProfile): Record<string, unknown> {
return formatEntity(p);
return formatEntity(p, { exclude: SSO_PROFILE_EXCLUDE });
}

export function formatPipeConnection(pc: WorkOSPipeConnection): Record<string, unknown> {
Expand Down
6 changes: 6 additions & 0 deletions src/workos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ export interface WorkOSSeedConnection {
email: string;
first_name?: string;
last_name?: string;
/**
* The full name as its own attribute, the way production stores it. Null when omitted —
* never composed from `first_name` and `last_name`.
*/
name?: string;
idp_id?: string;
groups?: string[];
}>;
Expand Down Expand Up @@ -654,6 +659,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee
email: p.email,
first_name: p.first_name ?? null,
last_name: p.last_name ?? null,
name: p.name ?? null,
groups: p.groups ?? [],
raw_attributes: { email: p.email },
});
Expand Down
16 changes: 16 additions & 0 deletions src/workos/response-envelopes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface Fixtures {
userId: string;
membershipId: string;
clientId: string;
connectionId: string;
connectApplicationId: string;
passwordResetToken: string;
passwordResetId: string;
Expand Down Expand Up @@ -112,6 +113,18 @@ const CASES: readonly EnvelopeCase[] = [
operation: 'GET /sso/jwks/{clientId}',
request: (app, f) => get(`/sso/jwks/${f.clientId}`)(app),
},
{
operation: 'POST /sso/token',
// The code is minted by the authorize redirect and spent by the exchange, so each run earns
// its own rather than sharing one with another case.
request: async (app, f) => {
const authorize = await app.request(
`/sso/authorize?connection=${f.connectionId}&redirect_uri=http://localhost:3000/callback`,
);
const code = new URL(authorize.headers.get('location')!).searchParams.get('code');
return post('/sso/token', { grant_type: 'authorization_code', code })(app);
},
},
{
operation: 'POST /user_management/users/{userlandUserId}/auth_factors',
request: (app, f) => post(`/user_management/users/${f.userId}/auth_factors`, { type: 'totp' })(app),
Expand Down Expand Up @@ -203,6 +216,8 @@ describe('response envelope conformance (route bodies vs OpenAPI spec)', () => {
users: [{ email: 'alice@acme.com', password: 'secret123', totp: true }],
permissions: [{ slug: 'posts:read', name: 'Read Posts' }],
roles: [{ slug: 'member', name: 'Member', permissions: ['posts:read'] }],
// An active connection for the SSO token exchange to authorize against.
connections: [{ name: 'Acme SSO', organization: 'Acme Corp', domains: ['acme.com'] }],
// Subscribed to an event this test never triggers, not the catch-all `[]`. Webhook
// endpoints are seeded before api keys and connect applications, so a catch-all would
// fire deliveries at a port nothing is listening on — and those in-flight fetches
Expand Down Expand Up @@ -270,6 +285,7 @@ describe('response envelope conformance (route bodies vs OpenAPI spec)', () => {
userId,
membershipId,
clientId: 'client_billing',
connectionId: ws.connections.findOneBy('name', 'Acme SSO')!.id,
connectApplicationId: ws.connectApplications.findOneBy('client_id', 'client_billing')!.id,
passwordResetToken,
passwordResetId,
Expand Down
22 changes: 22 additions & 0 deletions src/workos/response-shapes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
formatAuthChallenge,
formatClientSecret,
formatItContact,
formatSSOProfile,
} from './helpers.js';
import { RESPONSE_SHAPE_REQUIREMENTS } from './generated/response-shapes.js';
import type {
Expand All @@ -52,6 +53,7 @@ import type {
WorkOSAuthenticationChallenge,
WorkOSClientSecret,
WorkOSItContact,
WorkOSSSOProfile,
} from './entities.js';

const TS = '2026-01-01T00:00:00.000Z';
Expand Down Expand Up @@ -265,6 +267,25 @@ const clientSecret: WorkOSClientSecret = {
updated_at: TS,
};

// Timestamps populated on purpose: the spec's Profile has none, so the reverse check is what
// proves the formatter strips the store's bookkeeping rather than passing it through.
const ssoProfile: WorkOSSSOProfile = {
id: 'prof_01',
object: 'profile',
connection_id: 'conn_01',
connection_type: 'GenericSAML',
organization_id: 'org_01',
idp_id: 'idp_usr_1',
email: 'alice@acme.com',
first_name: 'Alice',
last_name: 'Smith',
name: null,
groups: ['Engineering'],
raw_attributes: { email: 'alice@acme.com' },
created_at: TS,
updated_at: TS,
};

const CASES: ReadonlyArray<{ objectType: string; output: Record<string, unknown> }> = [
{ objectType: 'user', output: formatUser(user) },
{ objectType: 'organization', output: formatOrganization(organization, ws, { domains: [] }) },
Expand All @@ -281,6 +302,7 @@ const CASES: ReadonlyArray<{ objectType: string; output: Record<string, unknown>
{ objectType: 'authentication_challenge', output: formatAuthChallenge(authChallenge) },
{ objectType: 'connect_application_secret', output: formatClientSecret(clientSecret) },
{ objectType: 'it_contact', output: formatItContact(itContact) },
{ objectType: 'profile', output: formatSSOProfile(ssoProfile) },
];

/**
Expand Down
123 changes: 121 additions & 2 deletions src/workos/routes/sso.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, it, expect, beforeEach } from 'bun:test';
import { describe, it, expect, beforeEach, afterEach, setSystemTime } from 'bun:test';
import { createServer, type ApiKeyMap } from '../../core/index.js';
import { workosPlugin } from '../index.js';
import { workosPlugin, seedFromConfig } from '../index.js';
import { getWorkOSStore } from '../store.js';
import { STORE_KEYS } from '../constants.js';
import type { Store } from '../../core/index.js';
import { formatSSOProfile } from '../helpers.js';
import type { WorkOSSSOProfile } from '../entities.js';

const apiKeys: ApiKeyMap = { sk_test_sso: { environment: 'test' } };
const headers = { Authorization: 'Bearer sk_test_sso', 'Content-Type': 'application/json' };
Expand Down Expand Up @@ -325,6 +327,123 @@ describe('SSO routes', () => {
expect(body.profile).toBeDefined();
expect(body.profile.object).toBe('profile');
expect(body.access_token).toBeDefined();
// The SDKs parse this as SSOTokenResponse, which requires both of these (#129). The lifetime
// is production's: its authorization-code exchanger mints the token with addMinutes(5).
expect(body.token_type).toBe('Bearer');
expect(body.expires_in).toBe(300);
// A profile the emulator mints has no stored name, and none is composed from the parts.
expect(body.profile.first_name).toBe('user');
expect(body.profile.name).toBeNull();
// The token's own lifetime matches what the response reports.
const [, payload] = (body.access_token as string).split('.');
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString());
expect(claims.exp - claims.iat).toBe(body.expires_in);
// Every field the spec's SsoTokenResponse and Profile require is present.
for (const key of ['token_type', 'access_token', 'expires_in', 'profile']) {
expect(body).toHaveProperty(key);
}
for (const key of [
'object',
'id',
'organization_id',
'connection_id',
'connection_type',
'idp_id',
'email',
'first_name',
'last_name',
'name',
'raw_attributes',
]) {
expect(body.profile).toHaveProperty(key);
}
});

describe('/sso/profile token lifetime', () => {
afterEach(() => setSystemTime());

async function issueToken() {
const { conn } = await createOrgWithConnection();
const authRes = await app.request(
`/sso/authorize?connection=${conn.id}&redirect_uri=http://localhost:3000/callback`,
);
const code = new URL(authRes.headers.get('location')!).searchParams.get('code')!;
const tokenRes = await app.request('/sso/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'authorization_code', code }),
});
return (await json(tokenRes)) as { access_token: string; expires_in: number };
}

const profile = (token: string) => app.request('/sso/profile', { headers: { Authorization: `Bearer ${token}` } });

// The lifetime /sso/token reports is the one /sso/profile enforces: the token works for
// expires_in seconds and is refused once that has passed.
it('accepts an issued token until expires_in has passed, then refuses it', async () => {
const start = new Date('2026-01-01T00:00:00Z');
setSystemTime(start);
const { access_token, expires_in } = await issueToken();

expect((await profile(access_token)).status).toBe(200);

setSystemTime(new Date(start.getTime() + (expires_in - 1) * 1000));
expect((await profile(access_token)).status).toBe(200);

setSystemTime(new Date(start.getTime() + (expires_in + 1) * 1000));
const expired = await profile(access_token);
expect(expired.status).toBe(401);
});

it('refuses a token that is not one the emulator signed', async () => {
expect((await profile('not-a-token')).status).toBe(401);
});
});

// Production stores `name` as its own attribute — its reconciler sets it only when the
// connection's attribute map has a `name` key — and its serializer emits the stored value or
// null. It is never composed from the two parts: a derived "Ada Lovelace" would pass against
// the emulator and come back null from WorkOS.
it('reports the stored name, never one composed from first_name and last_name', () => {
const base: WorkOSSSOProfile = {
id: 'prof_1',
object: 'profile',
connection_id: 'conn_1',
connection_type: 'GenericSAML',
organization_id: 'org_1',
idp_id: 'idp_1',
email: 'ada@acme.test',
first_name: 'Ada',
last_name: 'Lovelace',
name: null,
groups: [],
raw_attributes: {},
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
};

expect(formatSSOProfile(base).name).toBeNull();
expect(formatSSOProfile({ ...base, name: 'Ada Lovelace' }).name).toBe('Ada Lovelace');
expect(formatSSOProfile({ ...base, name: 'Countess of Lovelace' }).name).toBe('Countess of Lovelace');
});

it('seeds a profile name only when the seed supplies one', () => {
seedFromConfig(store, 'http://localhost:0', {
organizations: [{ name: 'Seeded Org' }],
connections: [
{
name: 'Seeded SSO',
organization: 'Seeded Org',
profiles: [
{ email: 'named@seeded.test', first_name: 'Ada', last_name: 'Lovelace', name: 'Ada Lovelace' },
{ email: 'unnamed@seeded.test', first_name: 'Ada', last_name: 'Lovelace' },
],
},
],
});
const profiles = getWorkOSStore(store).ssoProfiles.all();
expect(profiles.find((p) => p.email === 'named@seeded.test')?.name).toBe('Ada Lovelace');
expect(profiles.find((p) => p.email === 'unnamed@seeded.test')?.name).toBeNull();
});

it('returns 404 when no active connection found', async () => {
Expand Down
Loading
Loading