diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 97f6585..21bd337 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -582,8 +582,15 @@ export function formatConnection(conn: WorkOSConnection): Record { - 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 { diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index cd1e084..58f558a 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -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' }; @@ -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 () => { diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index d611b35..9a1337b 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -28,6 +28,14 @@ const OAUTH_CONNECTION_TYPES = new Set([ '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; @@ -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 }, + ); store.setData(`${STORE_KEY_PREFIXES.ssoToken}${accessToken}`, profile.id); @@ -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), }); }); @@ -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; + try { + payload = jwt.verify(token); + } catch { + throw new WorkOSApiError(401, 'Invalid access token', 'unauthorized'); + } + const profileId = store.getData(`${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'); }