Skip to content
Open
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
9 changes: 8 additions & 1 deletion src/workos/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,8 +582,15 @@ export function formatConnection(conn: WorkOSConnection): Record<string, unknown
return formatEntity(conn);
}

/**
* 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). The emulator stores only the two parts, so the full name is derived from them
* here, at the one place every profile response is built, and is null when neither is known.
*/
export function formatSSOProfile(p: WorkOSSSOProfile): Record<string, unknown> {
return formatEntity(p);
const name = [p.first_name, p.last_name].filter((part) => part).join(' ') || null;
return { ...formatEntity(p), name };
}

export function formatPipeConnection(pc: WorkOSPipeConnection): Record<string, unknown> {
Expand Down
94 changes: 93 additions & 1 deletion 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 { 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,96 @@ 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).
expect(body.token_type).toBe('Bearer');
expect(body.expires_in).toBe(600);
// 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);
});
});

it('derives the profile name from first_name and last_name', () => {
const base = {
id: 'prof_1',
object: 'profile',
connection_id: 'conn_1',
connection_type: 'GenericSAML',
organization_id: 'org_1',
idp_id: 'idp_1',
email: 'ada@acme.test',
groups: [],
raw_attributes: {},
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
} as const;
const name = (first_name: string | null, last_name: string | null) =>
formatSSOProfile({ ...base, first_name, last_name } as unknown as WorkOSSSOProfile).name;

expect(name('Ada', 'Lovelace')).toBe('Ada Lovelace');
expect(name('Ada', null)).toBe('Ada');
expect(name(null, 'Lovelace')).toBe('Lovelace');
expect(name(null, null)).toBeNull();
});

it('returns 404 when no active connection found', async () => {
Expand Down
44 changes: 31 additions & 13 deletions src/workos/routes/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ const OAUTH_CONNECTION_TYPES = new Set<WorkOSConnectionType>([
'MicrosoftOAuth',
]);

/**
* Lifetime of the access token `/sso/token` issues, in seconds. Production reports it as
* `expires_in` (the API reference's example is 600), and the SDKs require that field — the
* Python SDK's `SSOTokenResponse` fails to parse a response without it — so the emulator signs
* the token with this lifetime and reports the same number.
*/
const SSO_TOKEN_TTL_SECONDS = 600;

interface SSOAuthorizeParams {
redirectUri: string;
state: string | null;
Expand Down Expand Up @@ -283,11 +291,14 @@ export function ssoRoutes(ctx: RouteContext): void {

ws.ssoAuthorizations.delete(auth.id);

const accessToken = jwt.sign({
sub: profile.id,
aud: (body.client_id as string) ?? 'workos-emulate',
org_id: auth.organization_id,
});
const accessToken = jwt.sign(
{
sub: profile.id,
aud: (body.client_id as string) ?? 'workos-emulate',
org_id: auth.organization_id,
},
{ expiresIn: SSO_TOKEN_TTL_SECONDS },
Comment thread
burkestar marked this conversation as resolved.
);

store.setData(`${STORE_KEY_PREFIXES.ssoToken}${accessToken}`, profile.id);

Expand Down Expand Up @@ -320,8 +331,10 @@ export function ssoRoutes(ctx: RouteContext): void {
});

return c.json({
profile: formatSSOProfile(profile),
token_type: 'Bearer',
access_token: accessToken,
expires_in: SSO_TOKEN_TTL_SECONDS,
profile: formatSSOProfile(profile),
});
});

Expand All @@ -332,15 +345,20 @@ export function ssoRoutes(ctx: RouteContext): void {
}
const token = authHeader.replace(/^Bearer\s+/i, '').trim();

// The token is checked before anything is looked up: its signature and its `exp`, which is
// the `expires_in` /sso/token reported. Resolving a token through the store first and only
// verifying the ones it did not know would leave an issued token usable after it expired.
let payload: ReturnType<typeof jwt.verify>;
try {
payload = jwt.verify(token);
} catch {
throw new WorkOSApiError(401, 'Invalid access token', 'unauthorized');
}

const profileId = store.getData<string>(`${STORE_KEY_PREFIXES.ssoToken}${token}`);
if (!profileId) {
try {
const payload = jwt.verify(token);
const profile = ws.ssoProfiles.get(payload.sub);
if (profile) return c.json(formatSSOProfile(profile));
} catch {
// fall through
}
const profile = ws.ssoProfiles.get(payload.sub);
if (profile) return c.json(formatSSOProfile(profile));
throw new WorkOSApiError(401, 'Invalid access token', 'unauthorized');
}

Expand Down
Loading