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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,36 @@ A JWT template reads the same fact as `user.identities` — a provider→`idp_id
provider the user has not linked, so `{{ user.identities.GoogleOAuth }}` renders the id and an
unlinked provider renders a claim the emulator drops.

### Pipes connected accounts

`GET|POST|PUT|DELETE /user_management/users/{id}/connected_accounts/{slug}` serve a user's
[connected accounts](https://workos.com/docs/reference/pipes/connected-account). Seed them
with `connectedAccounts`, referencing a user by email (the same join key memberships use)
and, for an org-scoped connection, an organization by name:

```yaml
users:
- email: alice@acme.com
organizations:
- name: Acme Corp
connectedAccounts:
- email: alice@acme.com
provider: github # the slug requests address
scopes: [repo, user:email]
- email: alice@acme.com
provider: slack
organization: Acme Corp # resolvable only with ?organization_id=<its id>
state: needs_reauthorization # defaults to connected
```

Accounts are keyed by (user, provider, organization scope), exactly as the API addresses
them. `POST` imports an account from OAuth tokens — an omitted `state` is derived from the
token combination (an expired access token with no refresh token is `needs_reauthorization`) —
and answers `409` for a duplicate. `DELETE` disconnects by removing the account and its stored
tokens, so a later import is a fresh `201`. State changes emit the spec's
`pipes.connected_account.connected` / `reauthorization_needed` / `disconnected` events,
including for seeded accounts.

### Machine-to-Machine (M2M) Applications

Seed M2M Connect Applications so a service has a known `client_id` / client secret pair on
Expand Down
4 changes: 2 additions & 2 deletions SUPPORTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Supported Features

The emulator implements **140 of 212** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.59.0`) (**66.0%**).
The emulator implements **143 of 212** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.59.0`) (**67.5%**).

Endpoint coverage says whether a route exists, not whether a
feature is usable; for example, Directory Sync implements every endpoint the spec defines for it and is
Expand Down Expand Up @@ -33,7 +33,7 @@ answers "can I actually emulate this?".
| Vault | ❌ 0/5 | ❌ 0/6 | ❌ none | Not implemented. |
| Feature Flags | ✅ 4/4 | ⚠️ 1/4 | ⚠️ API only | Enable/disable and targeting exist, but under different verbs than the spec (`POST /feature-flags/:slug/enable` where the spec says `PUT`), so they do not count toward coverage. |
| API Keys | ⚠️ 1/2 | ⚠️ 2/5 | ✅ seed `apiKeys` | Seeded keys authenticate real requests. User-scoped API key endpoints are not implemented. |
| Pipes / Connected Apps | ⚠️ 2/5 | ⚠️ 1/12 | ⚠️ API only | Connection CRUD and access-token minting are emulator-specific routes under `/pipes/connections`. |
| Pipes / Connected Apps | ⚠️ 2/5 | ⚠️ 4/12 | ✅ seed `connectedAccounts` | Connection CRUD and access-token minting are emulator-specific routes under `/pipes/connections`. |
| Applications | ⚠️ 4/5 | ⚠️ 4/8 | ✅ seed `connectApplications` | |
| JWT Templates | ✅ 1/1 | ✅ 1/1 | ✅ seed `jwtTemplate` | Claims render into every access token. Filters, conditionals, and loops are not supported. |
| Webhooks | ✅ 1/1 | ⚠️ 2/3 | ✅ seed `webhookEndpoints` | Delivery is fire-and-forget with a 5s timeout and no retries. Endpoints registered in a seed file do not receive events from that same seed file. |
Expand Down
22 changes: 22 additions & 0 deletions scripts/gen-supported-lib.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,28 @@ describe('parseEmulatorRoutes', () => {
});
});

it('resolves bare identifier paths declared as const strings', () => {
const source = [
`const ACCOUNT_PATH = '/user_management/users/:user_id/connected_accounts/:slug';`,
`app.get(ACCOUNT_PATH, (c) => {});`,
`app.post(ACCOUNT_PATH, async (c) => {});`,
`app.put(ACCOUNT_PATH, async (c) => {});`,
`app.delete(ACCOUNT_PATH, (c) => {});`,
].join('\n');
const routes = parseEmulatorRoutes([source]);
expect(routes.map((r) => `${r.method} ${r.path}`)).toEqual([
'GET /user_management/users/:user_id/connected_accounts/:slug',
'POST /user_management/users/:user_id/connected_accounts/:slug',
'PUT /user_management/users/:user_id/connected_accounts/:slug',
'DELETE /user_management/users/:user_id/connected_accounts/:slug',
]);
});

it('ignores bare identifiers with no matching const declaration', () => {
const routes = parseEmulatorRoutes([`app.get(unknownPath, (c) => {});`]);
expect(routes).toHaveLength(0);
});

it('skips template literals with unresolved interpolations', () => {
const source = `app.get(\`${'${unknown}'}/path\`, (c) => {});`;
const routes = parseEmulatorRoutes([source]);
Expand Down
10 changes: 9 additions & 1 deletion scripts/gen-supported-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export const FEATURES: FeatureDef[] = [
name: 'Pipes / Connected Apps',
tags: ['pipes', 'pipes.provider', 'user-management.data-providers'],
emulatorCreateRoutes: ['/pipes'],
seedKeys: ['connectedAccounts'],
notes: 'Connection CRUD and access-token minting are emulator-specific routes under `/pipes/connections`.',
},
{
Expand Down Expand Up @@ -324,6 +325,7 @@ export function parseEmulatorRoutes(sources: string[]): EmulatorRoute[] {
const routes: EmulatorRoute[] = [];
const literalPattern = /app\.(get|post|put|patch|delete)\('([^']+)'/g;
const templatePattern = /app\.(get|post|put|patch|delete)\(`([^`]+)`/g;
const identifierPattern = /app\.(get|post|put|patch|delete)\((\w+)\s*,/g;
const helperPattern = /pathPrefix:\s*([^,}\n]+)/g;

for (const source of sources) {
Expand All @@ -346,7 +348,13 @@ export function parseEmulatorRoutes(sources: string[]): EmulatorRoute[] {
routes.push({ method: match[1].toUpperCase(), path: raw });
}

// 3. registerRoleRoutes helper — expand the known routes from pathPrefix
// 3. Bare identifier routes — a shared `const PATH = '...'` registered on several verbs
for (const match of source.matchAll(identifierPattern)) {
const path = vars.get(match[2]);
if (path) routes.push({ method: match[1].toUpperCase(), path });
}

// 4. registerRoleRoutes helper — expand the known routes from pathPrefix
for (const match of source.matchAll(helperPattern)) {
let prefix = match[1].trim();
if (prefix.startsWith("'") && prefix.endsWith("'")) {
Expand Down
5 changes: 4 additions & 1 deletion src/core/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ export const ID_PREFIXES = {
redirect_uri: 'redir',
cors_origin: 'cors',
authorized_application: 'auth_app',
connected_account: 'conn_acct',
// Production connected-account ids are data installations (`data_installation_01…`), and
// every account of one provider installs the same environment-level data integration.
connected_account: 'data_installation',
data_integration: 'data_integration',
role: 'role',
permission: 'perm',
role_permission: 'rp',
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface EmulatorSeedConfig {
organizations?: WorkOSSeedConfig['organizations'];
users?: WorkOSSeedConfig['users'];
connections?: WorkOSSeedConfig['connections'];
connectedAccounts?: WorkOSSeedConfig['connectedAccounts'];
invitations?: WorkOSSeedConfig['invitations'];
roles?: WorkOSSeedConfig['roles'];
permissions?: WorkOSSeedConfig['permissions'];
Expand Down
100 changes: 100 additions & 0 deletions src/workos/config-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,106 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe
}
}

// Validate connected accounts
if (config.connectedAccounts) {
if (!Array.isArray(config.connectedAccounts)) {
errors.push({
path: 'connectedAccounts',
message: 'connectedAccounts must be an array',
value: config.connectedAccounts,
});
} else {
// Organization name is the join key, the same one connections use.
const orgNames = new Set(
Array.isArray(config.organizations)
? config.organizations.map((o) => o.name).filter((n): n is string => typeof n === 'string')
: [],
);
// (user, provider, organization) is the key requests address; a duplicate would seed
// the pair POST answers 409 for, and every lookup would only ever resolve the first.
const seenAccounts = new Set<string>();
config.connectedAccounts.forEach((account, index) => {
// A non-object entry (e.g. `connectedAccounts: [null]` from a YAML typo) would throw
// on the property reads below; record a structured error instead of crashing startup.
if (account === null || typeof account !== 'object') {
errors.push({
path: `connectedAccounts[${index}]`,
message: 'each connected account must be an object',
value: account,
});
return;
}
const email = seedEmail(account.email);
if (!email.ok) {
errors.push({
path: `connectedAccounts[${index}].email`,
message:
email.problem === 'malformed'
? 'email must be a valid email address'
: 'email is required and must be the email of a user defined in users',
value: account.email,
});
} else if (!userEmails.has(email.email.toLowerCase())) {
errors.push({
path: `connectedAccounts[${index}].email`,
message: 'email must match a user defined in users',
value: account.email,
});
}
if (!account.provider || typeof account.provider !== 'string') {
errors.push({
path: `connectedAccounts[${index}].provider`,
message: 'provider is required and must be a non-empty string (the slug requests address, e.g. "github")',
value: account.provider,
});
}
if (
account.organization !== undefined &&
(typeof account.organization !== 'string' || !orgNames.has(account.organization))
) {
errors.push({
path: `connectedAccounts[${index}].organization`,
message: 'organization must name an organization defined in organizations',
value: account.organization,
});
}
if (
account.scopes !== undefined &&
(!Array.isArray(account.scopes) || account.scopes.some((s) => typeof s !== 'string'))
) {
errors.push({
path: `connectedAccounts[${index}].scopes`,
message: 'scopes must be an array of strings if provided',
value: account.scopes,
});
}
if (account.state && !['connected', 'needs_reauthorization'].includes(account.state)) {
errors.push({
path: `connectedAccounts[${index}].state`,
message:
'state must be "connected" or "needs_reauthorization" if provided — a disconnected account is a deleted one, so it cannot be seeded',
value: account.state,
});
}
if (email.ok && typeof account.provider === 'string' && account.provider) {
const key = [
email.email.toLowerCase(),
account.provider,
typeof account.organization === 'string' ? account.organization : '',
].join('\u0000');
if (seenAccounts.has(key)) {
errors.push({
path: `connectedAccounts[${index}]`,
message: 'duplicate connected account for this user, provider, and organization',
value: { email: account.email, provider: account.provider, organization: account.organization },
});
}
seenAccounts.add(key);
}
});
}
}

// Validate roles
if (config.roles) {
if (!Array.isArray(config.roles)) {
Expand Down
22 changes: 21 additions & 1 deletion src/workos/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,31 @@ export interface WorkOSAuthorizedApplication extends Entity {
redirect_uri: string;
}

/**
* States a stored connected account can hold. The spec's enum also has `disconnected`, but
* disconnecting an account is deleting it — that value is observable only in the
* `pipes.connected_account.disconnected` event a deletion emits, never on a stored row,
* so a fresh import after a disconnect can't be answered 409 by a row that no longer works.
*/
export type ConnectedAccountState = 'connected' | 'needs_reauthorization';

export interface WorkOSConnectedAccount extends Entity {
object: 'connected_account';
user_id: string;
organization_id: string | null;
/** Provider slug the account is addressed by (`github`, `slack`, …). Not part of the spec's REST shape — requests carry it in the path, events as `provider_slug`. */
provider: string;
provider_id: string;
/** The environment's integration for this provider; every account of one slug shares it. */
data_integration_id: string;
scopes: string[];
/** The import DTO and seed only describe OAuth connections, so this is the one value the emulator can be told. */
auth_method: 'oauth';
api_key_last_4: null;
state: ConnectedAccountState;
/** Tokens the import/update endpoints were given. Kept because deleting the account is specified to remove them; never serialized. */
access_token: string | null;
refresh_token: string | null;
token_expires_at: string | null;
}

export type PipeProvider = 'github' | 'slack' | 'google' | 'salesforce';
Expand Down
42 changes: 41 additions & 1 deletion src/workos/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
WorkOSApiError,
validationError,
generateId,
ID_PREFIXES,
type CursorPaginatedResult,
type Entity,
type Store,
Expand Down Expand Up @@ -32,6 +33,7 @@ import type {
WorkOSCorsOrigin,
WorkOSAuthorizedApplication,
WorkOSConnectedAccount,
ConnectedAccountState,
WorkOSAuthenticationChallenge,
WorkOSDeviceAuthorization,
WorkOSRole,
Expand Down Expand Up @@ -587,8 +589,46 @@ export function formatAuthorizedApplication(a: WorkOSAuthorizedApplication): Rec
return formatEntity(a);
}

// The spec's ConnectedAccount names the provider only in the request path; the slug, the
// integration id, and any imported tokens are the emulator's own bookkeeping.
const CONNECTED_ACCOUNT_INTERNAL_FIELDS = new Set<string>([
'provider',
'data_integration_id',
'access_token',
'refresh_token',
'token_expires_at',
]);

export function formatConnectedAccount(a: WorkOSConnectedAccount): Record<string, unknown> {
return formatEntity(a);
return formatEntity(a, { exclude: CONNECTED_ACCOUNT_INTERNAL_FIELDS });
}

/**
* `pipes.connected_account.*` event payloads carry two fields the REST shape does not — the
* provider slug and the data integration id. `state` is overridable because `disconnected`
* exists only in the event a deletion emits; a stored row never holds it.
*/
export function formatConnectedAccountEvent(
a: WorkOSConnectedAccount,
state: ConnectedAccountState | 'disconnected' = a.state,
): Record<string, unknown> {
return {
...formatConnectedAccount(a),
state,
provider_slug: a.provider,
data_integration_id: a.data_integration_id,
};
}

/**
* One data integration per provider slug: an account is an installation of the environment's
* integration for that provider, so every account of one slug shares the id. Reused from any
* live account before minting, keeping the id stable for as long as any account of the slug
* exists rather than inventing a fresh integration per install.
*/
export function dataIntegrationIdFor(ws: WorkOSStore, slug: string): string {
const existing = ws.connectedAccounts.findBy('provider', slug)[0];
return existing?.data_integration_id ?? generateId(ID_PREFIXES.data_integration);
}

/** Redirect URI hosts the emulator's authorize endpoints accept with no configuration. */
Expand Down
Loading
Loading