Skip to content

feat: provide an end-to-end login flow story with spec-accurate webhooks#2

Open
gjtorikian wants to merge 8 commits into
mainfrom
end-to-end
Open

feat: provide an end-to-end login flow story with spec-accurate webhooks#2
gjtorikian wants to merge 8 commits into
mainfrom
end-to-end

Conversation

@gjtorikian

Copy link
Copy Markdown
Collaborator

Summary

  • Makes the full workos.com/docs login story testable against the emulator: every resource creation and authentication outcome now fires a signed webhook with event names and payload shapes matching the WorkOS OpenAPI spec.
  • The event catalog is now generated from the @workos/openapi-spec devDependency (npm run gen:events) instead of hand-maintained constants, which had drifted (authentication.magicauth_succeeded vs the real authentication.magic_auth_succeeded). Emit sites reference generated constants, so an invalid event name no longer compiles.
  • Fills emission gaps: SSO, magic auth, email verification, and password reset previously emitted nothing; failed logins now emit authentication.*_failed with the spec-required error: {code, message}. Codes WorkOS would email arrive in webhook payloads (magic_auth.created, password_reset.created, email_verification.created), so tests can drive the whole flow from webhooks alone.
  • Renames to match the real catalog: connection.created/updatedconnection.activated/deactivated (on state transitions), directory*.*dsync.*, and the auth payload's method field → spec type/status fields. Sessions gained spec-required auth_method/status/expires_at/ended_at.
  • Adds src/e2e.spec.ts, which boots the real server plus a local webhook receiver and walks the documented flows, verifying HMAC signatures and spec-required payload fields; documents the story in the README. Also migrates formatting from prettier to oxfmt.

Test plan

  • npm test — 376 tests across 40 files, including the new e2e story spec
  • npm run typecheck and npm run build
  • npm run gen:events produces no diff (generated catalog is current and idempotent)
  • npm run fmt:check
  • Manual smoke: node dist/cli.js, register a webhook endpoint, walk authorize → authenticate via curl, observe signed user.created, session.created, authentication.oauth_succeeded webhooks

🤖 Generated with Claude Code

Hand-written event constants had drifted from the real API
(authentication.magicauth_succeeded vs the spec's
authentication.magic_auth_succeeded), and the public docs pages
are themselves incomplete — authentication.mfa_failed exists
only in the spec. Generating the catalog from the published
spec package makes drift impossible to reintroduce: regenerating
is `npm run gen:events`, and picking up a newer spec is an
ordinary dependency bump.

The generated file is committed so package consumers never need
the spec itself.
The emulator exists so apps can test their documented login flow
locally, but whole flows (SSO, magic auth, email verification,
password reset) emitted no events at all, failed logins emitted
nothing, and some emitted names (connection.created,
directory_user.*) don't exist in the real catalog — webhook
handlers verified against the emulator could pass on names
production never sends.

Every emit site now references the spec-generated catalog, so an
invalid event name no longer compiles. Sessions gained the
spec-required auth_method/status/expires_at/ended_at fields, and
update hooks now receive the previous value so
connection.activated/deactivated fire only on real state
transitions.

Renames for consumers asserting old names:
authentication.magicauth_succeeded →
authentication.magic_auth_succeeded (same for
email_verification), connection.created/updated →
connection.activated/deactivated, directory*.* → dsync.*, and
the payload's `method` field → spec `type`/`status` fields.
Route specs run in-process and webhook delivery was only covered
with a mocked fetch, so nothing verified the actual story: a
registered endpoint receiving signed, spec-shaped payloads as a
user walks the documented flows. The e2e spec boots the real
server plus a local receiver and asserts payloads against the
spec-generated required-field metadata, so future catalog drift
fails CI here instead of surfacing in user apps.
The webhook system existed but the README never mentioned it, so
users had no way to discover the emulator's core story: driving
an entire documented login flow locally, with codes WorkOS would
email delivered in webhook payloads instead.
@gjtorikian gjtorikian changed the title Provide an end-to-end login flow story with spec-accurate webhooks feat: provide an end-to-end login flow story with spec-accurate webhooks Jun 10, 2026
@greptile-apps

greptile-apps Bot commented Jun 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR delivers end-to-end spec-faithful webhook coverage for the WorkOS emulator: event names and payload shapes are now generated from @workos/openapi-spec rather than hand-maintained, authentication outcome events (succeeded/failed) are emitted for every grant type, and sessions now carry the spec-required auth_method, status, expires_at, and ended_at fields.

  • Generated event catalog replaces hand-maintained constants, fixing name drift (magic_auth_succeeded vs the old magicauth_succeeded), and the EVENTS object now covers SSO, dsync, API keys, feature flags, and organization roles that were previously missing.
  • Authentication failure events (authentication.*_failed with required error: {code, message}) are emitted on every credential check across all grant types; token refresh correctly skips both session.created and authentication.*_succeeded.
  • MFA challenge flow now records the primary factor on the pending-auth token so the eventual session's auth_method reflects the first factor (e.g. 'password'), not the second; connection state-transition hooks use the updated (item, previous) signature to guard against spurious connection.activated/connection.deactivated events.

Confidence Score: 5/5

Safe to merge — no functional regressions identified across the authentication, session, and webhook emission paths.

Every grant type now has matching success and failure events, the token-refresh path correctly avoids spurious events, the session lifecycle fields are correctly populated before the delete hook fires, and the MFA primary-factor recording is tested end-to-end. The generated catalog eliminates the class of hand-maintained name drift that motivated the PR. Test coverage spans unit, integration, and a new real-HTTP e2e story.

No files require special attention.

Important Files Changed

Filename Overview
src/workos/routes/auth.ts Core authenticate handler restructured: failAuth helper (typed never) covers all failure paths, issueMfaChallenge correctly short-circuits before session creation, and isFreshLogin flag prevents spurious session/auth events on token refresh.
src/workos/helpers.ts Adds AUTH_METHOD_EVENT_TYPES, AUTH_METHOD_SESSION_VALUES, AUTH_EVENTS mappings and buildAuthenticationEventData builder; MFA and EmailVerification explicitly map to 'unknown' with documenting comment.
src/workos/index.ts Hooks wired for email verifications, magic auth, password resets, API keys, feature flags, and org-scoped roles; connection and dsync hooks now respect state transitions correctly using the updated (item, prev) signature.
src/workos/generated/events.ts Generated from @workos/openapi-spec; covers 100+ events with correct names, SUBSCRIBABLE_EVENTS list, AuthenticationEventData interface, and EVENT_DATA_REQUIREMENTS for test assertions.
src/workos/routes/sessions.ts Sessions now updated to status:'revoked' + ended_at before deletion so the onDelete hook fires session.revoked with spec-required fields; both revoke and logout endpoints follow the same pattern.
src/workos/routes/sso.ts Adds emitSsoEvent helper to emit authentication.sso_succeeded/failed with the spec sso sub-object; session_id is correctly null since the SSO profile exchange does not create a user-management session.
src/workos/routes/password-reset.ts password_reset.succeeded event manually emitted after confirmation because spec fires it on the confirm action, not on a collection insert; correct and non-lossy since the local pr variable is still in scope after delete.
src/e2e.spec.ts New end-to-end spec covering the full docs login story: boots a real server, routes webhooks to a local receiver, verifies HMAC signatures, and checks spec-required payload fields via EVENT_DATA_REQUIREMENTS.
src/core/store.ts onUpdate hook signature updated to pass (updated, previous) enabling state-transition guards in connection and dsync hooks.
scripts/gen-events-lib.ts Codegen library extracts subscribable event names and payload schemas from the OpenAPI spec, derives AuthenticationEventData as a union of all auth outcome schemas, and generates the events.ts file.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Emulator
    participant EventBus
    participant WebhookReceiver

    Note over Client,WebhookReceiver: Password + MFA Flow
    Client->>Emulator: "POST /authenticate (grant_type=password)"
    Emulator-->>Client: 403 mfa_challenge (pending_token + challenge)
    Note over Emulator: No session.created, no auth event yet

    Client->>Emulator: "POST /authenticate (grant_type=mfa-totp)"
    Emulator->>EventBus: "session.created (auth_method=password)"
    Emulator->>EventBus: "authentication.mfa_succeeded (type=mfa)"
    EventBus->>WebhookReceiver: signed webhook x2
    Emulator-->>Client: 200 access_token + refresh_token

    Note over Client,WebhookReceiver: Token Refresh (no new events)
    Client->>Emulator: "POST /authenticate (grant_type=refresh_token)"
    Note over Emulator: isFreshLogin=false, reuses session, skips events
    Emulator-->>Client: 200 new access_token + refresh_token

    Note over Client,WebhookReceiver: Session Revocation
    Client->>Emulator: POST /sessions/revoke
    Emulator->>Emulator: "update(status=revoked, ended_at=now)"
    Emulator->>Emulator: delete, onDelete hook fires
    Emulator->>EventBus: "session.revoked (status=revoked, ended_at set)"
    EventBus->>WebhookReceiver: signed webhook

    Note over Client,WebhookReceiver: Failed Authentication
    Client->>Emulator: POST /authenticate (bad password)
    Emulator->>EventBus: authentication.password_failed (error.code, error.message)
    EventBus->>WebhookReceiver: signed webhook
    Emulator-->>Client: 401 invalid_credentials
Loading

Reviews (3): Last reviewed commit: "fix(auth): rotate refresh within session..." | Re-trigger Greptile

Comment thread src/workos/helpers.ts
Comment thread src/e2e.spec.ts

const WEBHOOK_SECRET = 'whsec_e2e_test_secret';

interface ReceivedWebhook {

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 Hardcoded webhook secret in source code

WEBHOOK_SECRET is committed as a literal string in a test file. The engineering rule for this repository prohibits hardcoded secrets in source code. While this value is clearly a test fixture (not a production credential), keeping it as a constant in source could trip secret-scanning tools and creates a pattern others might follow for real values. Consider moving it to a test-scoped env variable or generating it via crypto.randomBytes in beforeAll.

Rule Used: No hardcoded secrets or API keys in source code (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

The repo had the oxfmt formatter but no linter and no CI gate
enforcing either linting or formatting. oxlint (oxc's linter,
pairing with the oxfmt already in use) now runs alongside the
format check on every push and PR, so correctness regressions
like unused code can't merge unnoticed.

The dead-code removals in the spec and source files are the
findings oxlint surfaced on its first run; they ship in this
commit so the new workflow lands green instead of red.
Comment thread src/workos/routes/auth.ts Outdated
if (eventBus) {
const authEventType = `authentication.${authMethod.toLowerCase()}_succeeded`;
const succeededEvent = AUTH_EVENTS[authMethod]?.succeeded;
if (eventBus && succeededEvent) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed — and it's slightly broader than the auth event alone.

The refresh_token grant sets authMethod = 'OAuth', so this success path emits authentication.oauth_succeeded on every rotation. A refresh isn't a fresh login, so agreed — it's a false positive for anything counting or alerting on login events.

One thing to add: the handler also runs ws.sessions.insert(...) unconditionally for every grant, and sessions.onInsert emits session.created. So a refresh currently fires a spurious session.created and mints a brand-new session + refresh token, instead of rotating within the existing session (refreshToken.session_id). The misleading auth event is really the most visible symptom of "refresh is being treated as a fresh login."

Planned fix: gate the authentication-event emission behind an explicit "fresh login" check so the refresh_token grant is excluded — your first suggestion (skip emission), rather than mapping to a value with no AUTH_EVENTS entry, since that would also wipe out the legitimate session auth_method. The deeper "don't create a new session on refresh" behavior is a larger change we'll address separately.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Landed in f6c8309.

The refresh_token grant now clears an isFreshLogin flag that gates the success-event emission, so a token refresh no longer fires authentication.oauth_succeeded. Added a regression test asserting a refresh emits no authentication.* event (it fails without the gate, passes with it).

The deeper issue — a refresh still runs ws.sessions.insert(...) and so emits a spurious session.created while minting a brand-new session instead of rotating within the existing one — is left as a separate follow-up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Follow-up landed in 55f1d48: refresh no longer creates a new session.

A refresh_token rotation now reuses the session referenced by the old token (issuing fresh access + refresh tokens for it), so it emits no session.created and no authentication.* event. A revoked session can no longer be refreshed either — it returns invalid_grant rather than resurrecting itself. Regression test asserts the session.created count and total session count both stay at 1 across a rotation.

gjtorikian and others added 2 commits June 10, 2026 18:56
The refresh_token grant set authMethod = 'OAuth' and fell through to
the shared success path, so every silent token rotation emitted an
authentication.oauth_succeeded webhook. A refresh is not a login, so
this was a false positive for any consumer counting authentication
events. Gate the emission behind a fresh-login flag that the refresh
grant clears.

Also pin MFA and email-verification sessions to auth_method 'unknown'
explicitly. The spec's session auth_method enum has no value for either
(MFA is a second factor, not a primary method), so the prior silent
fallthrough was correct but undocumented — and the obvious "fix" of
adding 'mfa'/'email_verification' would emit out-of-enum values. The
comment and explicit entries guard against that regression.

Both address Greptile review feedback on #2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refresh reused the shared session-creation path, so every token
rotation inserted a new session (firing session.created) and minted a
fresh session/refresh token instead of rotating within the existing
one. Refresh now reuses the session referenced by the old token and
emits no session.created; a revoked session can no longer be refreshed
(it returns invalid_grant rather than resurrecting itself).

For MFA, the session auth_method now records the primary factor the
pending token was issued for (e.g. password) rather than the second
factor, while the event stays authentication.mfa_succeeded. To make the
flow reachable, a password login for a user with enrolled factors now
returns a pending token + challenge (mfa_challenge) instead of a
session, so completing mfa-totp yields a session keyed to the primary
factor.

The spec documents the mfa_challenge code but not the response body that
carries pending_authentication_token; that shape mirrors WorkOS.
Follow-up to Greptile review on #2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant