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
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,64 @@ requesting a scope the application does not have returns `400 invalid_scope`, so
authorization can be exercised locally. Unknown credentials return `401 invalid_client`, and an
`oauth`-type application returns `400 unauthorized_client`.

### Standalone Connect

Bridge your application's own login to Connect with an OAuth application and an emulator-only
`connectApplications[].login_url` (the stand-in for the login page configured in the WorkOS dashboard):

```yaml
connectApplications:
- name: Standalone App
type: oauth
client_id: client_local_standalone
client_secret: secret_local_standalone
login_url: http://localhost:3000/login
redirect_uris: [http://localhost:3000/callback]
scopes: [profile, email]
```

1. Send the browser to
`http://localhost:4100/oauth2/authorize?client_id=client_local_standalone&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&state=my-state`.
The emulator redirects to `login_url` with a fresh `external_auth_id` valid for ten minutes.
2. Authenticate the user in your application, then call from your backend:

```bash
curl -s http://localhost:4100/authkit/oauth2/complete \
-H "Authorization: Bearer sk_test_default" \
-H "Content-Type: application/json" \
-d '{"external_auth_id":"ext_auth_FROM_LOGIN_URL","user":{"id":"user_12345","email":"marcelina.davis@example.com"}}'
```

This creates or updates the AuthKit user by `external_id = user.id`, marks their email verified,
emits `user.created` or `user.updated`, and returns `{"redirect_uri":"..."}`. Optional `name`,
`first_name`, `last_name`, and `metadata` update when supplied; omitted fields are preserved.

3. Redirect the browser to that returned URL (`GET /oauth2/authorize/complete`). It is single-use
and redirects to the original client callback with `code` and the original `state`.
4. Exchange the code at `POST /oauth2/token` with `grant_type=authorization_code`, `code`, the exact
original `redirect_uri`, `client_id`, and `client_secret` (form-encoded or JSON; Basic credentials
also work). Codes expire after ten minutes and are consumed on exchange. The response contains
an access token, `token_type`, `expires_in`, and `scope`. Its JWT `sub` is the AuthKit user ID;
`aud` is the application's `audience`, falling back to its `client_id`.

Reusing a completed ID returns `400 external_auth_session_already_completed`; unknown or expired IDs
return `404 not_found`. Missing required fields return `422`, malformed email returns `400 invalid_email`,
and an email owned by another user returns `400 email_not_available` (including on updates). A failed
validation does not consume the session. Browser redemption of an incomplete or already redeemed ID
returns `404`.

`login_url` can also be set on `POST /connect/applications`, but is not included in API application
responses. Both browser destinations must pass the emulator's redirect-host policy (localhost by
default; configure `--redirect-hosts` for other hosts). When `redirect_uris` is non-empty, the callback
must also match an entry exactly.

**Deliberate limitations:** no PKCE, refresh tokens, ID tokens, or consent UI. `user_consent_options`
is ignored; the `email_change_not_allowed` policy is not modeled. The authorize request's `scope`
is not tracked: tokens default to the application's configured scopes, optionally narrowed by `scope`
at token exchange. The emulator's completion URL uses `/oauth2/authorize/complete?external_auth_id=...`,
not production's AuthKit-domain `/oauth/authorize/complete?state=...`; always follow the returned URL
rather than constructing it. This is a local testing flow, not a replacement authentication service.

### API Keys

Seed organization- or user-owned API keys. Each seeded key is created as an `api_key` resource
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 **179 of 250** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.80.0`) (**71.6%**).
The emulator implements **180 of 250** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.80.0`) (**72.0%**).

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 @@ -34,7 +34,7 @@ answers "can I actually emulate this?".
| Feature Flags | ✅ 4/4 | ✅ 4/4 | ✅ seed `featureFlags` | Every spec endpoint is implemented at its documented verb; the emulator additionally accepts `POST` on enable/disable and `PUT` on target creation as aliases, which production rejects. Flags resolve into the `feature_flags` access-token claim, the per-user and per-organization list endpoints, and `GET /sdk/feature-flags` — the Node SDK runtime client's polling endpoint, which the spec does not define. Production has no create-flag endpoint, so flags come from the `featureFlags` seed key. |
| API Keys | ✅ 2/2 | ✅ 5/5 | ✅ seed `apiKeys` | Created and seeded keys authenticate real requests. |
| 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` | |
| Applications | ⚠️ 4/5 | ⚠️ 5/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. |
| Events | ✅ 1/1 | — | ✅ automatic | Emitted as a side effect of every other operation. All are queryable at `GET /events`, including those with no registered webhook endpoint. |
Expand Down
1 change: 1 addition & 0 deletions src/core/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const ID_PREFIXES = {
authentication_factor: 'auth_factor',
authentication_challenge: 'auth_challenge',
authorization_code: 'auth_code',
external_auth_session: 'ext_auth',
identity: 'identity',
sso_authorization: 'sso_auth',
refresh_token: 'ref',
Expand Down
12 changes: 12 additions & 0 deletions src/workos/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ export interface WorkOSAuthenticationFactor extends Entity {
};
}

export interface WorkOSExternalAuthSession extends Entity {
client_id: string;
redirect_uri: string;
state: string | null;
expires_at: string;
completed_at: string | null;
redeemed_at: string | null;
user_id: string | null;
}

export interface WorkOSAuthorizationCode extends Entity {
user_id: string;
organization_id: string | null;
Expand Down Expand Up @@ -481,6 +491,8 @@ export interface WorkOSConnectApplication extends Entity {
/** The `aud` claim minted into m2m tokens. Falls back to client_id when null. */
audience: string | null;
redirect_uris: string[];
/** Emulator-only Standalone Connect login page; never serialized on the API application. */
login_url: string | null;
client_id: string;
logo_url: string | null;
}
Expand Down
5 changes: 5 additions & 0 deletions src/workos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { vaultRoutes } from './routes/vault.js';
import { radarRoutes } from './routes/radar.js';
import { connectRoutes } from './routes/connect.js';
import { oauthRoutes } from './routes/oauth.js';
import { standaloneConnectRoutes } from './routes/standalone-connect.js';
import { directoryRoutes } from './routes/directories.js';
import { auditLogRoutes } from './routes/audit-logs.js';
import { featureFlagRoutes } from './routes/feature-flags.js';
Expand Down Expand Up @@ -284,6 +285,8 @@ export interface WorkOSSeedConnectApplication {
client_secret?: string;
/** OAuth redirect URIs. Ignored for `m2m` applications. */
redirect_uris?: string[];
/** Emulator-only Standalone Connect login page, receiving an external_auth_id. */
login_url?: string | null;
}

export interface WorkOSSeedApiKey {
Expand Down Expand Up @@ -749,6 +752,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee
scopes: appConfig.scopes ?? [],
audience: appConfig.audience ?? null,
redirect_uris: appConfig.redirect_uris ?? [],
login_url: appConfig.login_url ?? null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Seeded login URLs go unchecked

The new seeded login_url is stored without validation, so malformed values are accepted during startup and fail only when /oauth2/authorize returns 400 invalid_redirect_uri. Validating this field with the other connectApplications settings would report configuration errors when the seed is loaded.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos/index.ts
Line: 755

Comment:
**Seeded login URLs go unchecked**

The new seeded `login_url` is stored without validation, so malformed values are accepted during startup and fail only when `/oauth2/authorize` returns `400 invalid_redirect_uri`. Validating this field with the other `connectApplications` settings would report configuration errors when the seed is loaded.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

client_id: appConfig.client_id ?? generateClientId(),
logo_url: null,
});
Expand Down Expand Up @@ -945,6 +949,7 @@ export const workosPlugin: ServicePlugin = {
radarRoutes(ctx);
connectRoutes(ctx);
oauthRoutes(ctx);
standaloneConnectRoutes(ctx);
directoryRoutes(ctx);
auditLogRoutes(ctx);
featureFlagRoutes(ctx);
Expand Down
4 changes: 3 additions & 1 deletion src/workos/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,7 +835,9 @@ export function authRoutes(ctx: RouteContext): void {
new OauthApiError(400, 'invalid_grant', `The code '${code}' has expired or is invalid.`),
);
}
if (isExpired(authCode.expires_at)) {
// Standalone Connect codes belong to /oauth2/token, which enforces the Connect
// client's secret and redirect_uri. Reject them here without consuming the code.
if (authCode.auth_method === 'external_auth' || isExpired(authCode.expires_at)) {
failAuth(
'OAuth',
{ userId: authCode.user_id, email: ws.users.get(authCode.user_id)?.email },
Expand Down
20 changes: 20 additions & 0 deletions src/workos/routes/connect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,26 @@ describe('Connect routes', () => {
expect(app.id).toMatch(/^connect_app_/);
});

it('stores the emulator-only login_url without adding it to the API response', async () => {
const res = await req('/connect/applications', {
method: 'POST',
body: JSON.stringify({ name: 'Standalone', login_url: 'http://localhost:3000/login' }),
});
expect(res.status).toBe(201);
const created = await json(res);
expect(created.login_url).toBeUndefined();
expect(getWorkOSStore(store).connectApplications.get(created.id)?.login_url).toBe('http://localhost:3000/login');
expect((await json(await req(`/connect/applications/${created.id}`))).login_url).toBeUndefined();
});

it('rejects a non-string login_url', async () => {
const res = await req('/connect/applications', {
method: 'POST',
body: JSON.stringify({ name: 'Standalone', login_url: 123 }),
});
expect(res.status).toBe(422);
});

it('rejects empty name', async () => {
const res = await req('/connect/applications', {
method: 'POST',
Expand Down
5 changes: 5 additions & 0 deletions src/workos/routes/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export function connectRoutes(ctx: RouteContext): void {
throw validationError('scopes must be an array of strings', [{ field: 'scopes', code: 'invalid' }]);
}

if (body.login_url !== undefined && body.login_url !== null && typeof body.login_url !== 'string') {
throw validationError('login_url must be a string or null', [{ field: 'login_url', code: 'invalid' }]);
}

const applicationType = body.application_type === 'm2m' ? 'm2m' : 'oauth';
const organizationId = (body.organization_id as string) ?? null;
// m2m applications are owned by an organization; reject a null or dangling owner so
Expand Down Expand Up @@ -68,6 +72,7 @@ export function connectRoutes(ctx: RouteContext): void {
scopes: (body.scopes as string[]) ?? [],
audience: (body.audience as string) ?? null,
redirect_uris: (body.redirect_uris as string[]) ?? [],
login_url: (body.login_url as string) ?? null,
client_id: generateClientId(),
logo_url: (body.logo_url as string) ?? null,
});
Expand Down
15 changes: 15 additions & 0 deletions src/workos/routes/oauth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,18 @@ describe('OAuth M2M token routes', () => {
expect((await json(res)).error).toBe('unauthorized_client');
});

it('rejects authorization_code for an m2m application', async () => {
const res = await form({
grant_type: 'authorization_code',
client_id: 'client_billing',
client_secret: 'secret_billing_value',
code: 'any_code',
redirect_uri: 'http://localhost:3000/cb',
});
expect(res.status).toBe(400);
expect((await json(res)).error).toBe('unauthorized_client');
});

it('requires no API key (token endpoint is public)', async () => {
// No Authorization header at all — must not be rejected by the auth middleware.
const res = await form({
Expand Down Expand Up @@ -234,6 +246,7 @@ describe('OAuth M2M token routes', () => {
redirect_uris: [],
client_id: 'client_aud',
logo_url: null,
login_url: null,
});
ws.clientSecrets.insert({
object: 'client_secret',
Expand Down Expand Up @@ -261,6 +274,7 @@ describe('OAuth M2M token routes', () => {
redirect_uris: [],
client_id: 'client_percent',
logo_url: null,
login_url: null,
});
ws.clientSecrets.insert({
object: 'client_secret',
Expand Down Expand Up @@ -295,6 +309,7 @@ describe('OAuth M2M token routes', () => {
redirect_uris: [],
client_id: 'client_malformed',
logo_url: null,
login_url: null,
});
ws.clientSecrets.insert({
object: 'client_secret',
Expand Down
Loading
Loading