Skip to content

feat(auth): add opt-in password step to interactive login - #99

Merged
gjtorikian merged 7 commits into
mainfrom
feat/interactive-password-step
Sep 3, 2026
Merged

feat(auth): add opt-in password step to interactive login#99
gjtorikian merged 7 commits into
mainfrom
feat/interactive-password-step

Conversation

@gjtorikian

Copy link
Copy Markdown
Collaborator

Summary

  • --interactive-password / interactiveAuth: { password: true } adds the step hosted AuthKit puts after the email: a user who has a password gets a password page before any organization selection. The interactive page was email-only, so a Playwright suite written against hosted AuthKit had no password field to fill and no wrong-password path to assert. Opt-in, because the one-step email page is what every existing interactive suite was written against; plain --interactive is unchanged.
  • A wrong password re-renders the page with an inline error (HTTP 401) and emits authentication.password_failed with invalid_credentials, the same event the password grant emits. Nothing reaches the callback until the password is right.
  • The auth code records the method, so the exchange emits authentication.password_succeeded and returns "authentication_method": "Password" instead of claiming OAuth for a login it verified with a password.
  • The organization page carries a short-lived token, not the password. A verified login is remembered server-side for ten minutes and spent when it mints a code. Posting an organization_id without that token lands back on the password page, so the check cannot be skipped.
  • sealed_session is now always null from /user_management/authenticate. Production never seals on the server; the SDKs seal client-side with a cookie password the API never sees. The emulator's own sealing pushed authkit-nextjs session cookies past the 4096-byte browser cap. Fixes authenticate returns sealed_session on every SDK call, pushing authkit-nextjs session cookies past the 4096-byte browser cap #93.

Also ignores the local Dolt database and credential key that bd init writes.

Usage

workos-emulate --interactive-password --seed workos-emulate.config.yaml
await page.fill('input[name="email"]', 'alice@example.com');
await page.click('button[type="submit"]');
await page.fill('input[name="password"]', 'correct-horse');
await page.click('button[type="submit"]');

A user without a password skips the page. MFA challenges and the email-verification gate are not part of it. The README gains a "Requiring a password" section under Interactive Auth.

`bd init` writes a local Dolt database and a credential key next
to the repo. Neither belongs in version control; the tracker syncs
through refs/dolt/data on the remote instead.
Production does not seal on the server: the SDKs seal client-side
with a caller-supplied cookie password the API never sees, so the
field is always null in a real response. The emulator's own sealing
pushed authkit-nextjs session cookies past the 4096-byte browser
cap (issue #93), and the @workos-inc/node refresh call, which sends
the API key as client_secret, opted into it on every refresh.
The interactive login page asked for an email and nothing else, so
a Playwright suite written against hosted AuthKit had no password
field to fill and no wrong-password path to assert. The gap
surfaced when a customer's CI kept reusing one real AuthKit test
user, whose sessions piled up into the millions.

Opt-in via --interactive-password rather than the default, because
the one-step email page is what every existing interactive suite
was written against. A verified password is remembered under a
short-lived token across the organization page so the secret never
sits in a hidden field, and the auth code records the method so the
exchange emits authentication.password_* instead of claiming OAuth.
@greptile-apps

greptile-apps Bot commented Sep 3, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds an opt-in password, email-verification, and MFA sequence to interactive AuthKit login while preserving the existing email-only mode. It also records the authentication method on authorization codes, returns sealed_session: null, and adds bounded cleanup for short-lived interactive-login state.

  • Adds CLI and programmatic configuration for interactive password authentication.
  • Carries verified browser logins through gates and organization selection using short-lived server-side tokens.
  • Deletes spent tokens and sweeps expired tokens together with their associated verification or challenge records.
  • Expands route and interactive-auth coverage for success, failure, replay, expiry, reset, and cleanup behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/workos/routes/auth.ts Implements the staged interactive password flow, authorization-code method tracking, token consumption, and bounded cleanup of expired continuation state.
src/core/store.ts Adds direct keyed deletion and predicate-based prefix deletion used to remove spent and expired interactive-login state.
src/workos/login-page.ts Adds escaped, server-rendered password and one-time-code pages for the new interactive steps.
src/index.ts Extends interactive-auth configuration and restores both interactive flags after emulator reset.
src/workos/interactive-auth.spec.ts Covers password, verification, MFA, organization selection, replay prevention, expiry cleanup, and reset behavior.
src/workos/routes/auth.spec.ts Updates the authenticate response contract to require sealed_session: null, including refresh-token requests.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Interactive email page] --> B{Password configured?}
    B -- No --> G{Organization selection needed?}
    B -- Yes --> C[Password page]
    C --> D{Email verified?}
    D -- No --> E[Email verification page]
    D -- Yes --> F{MFA enrolled?}
    E --> F
    F -- Yes --> H[MFA code page]
    F -- No --> G
    H --> G
    G -- Yes --> I[Organization page]
    G -- No --> J[Mint authorization code]
    I --> J
    J --> K[Delete interactive login token]
    L[Next token mint] --> M[Sweep expired login tokens]
    M --> N[Delete referenced verification or challenge]
Loading

Reviews (5): Last reviewed commit: "fix(auth): release gate records when an ..." | Re-trigger Greptile

Comment thread src/workos/routes/auth.ts Outdated
Store.setData is a plain Map.set, so clearing the one-time token
with setData(key, undefined) kept the key allocated: a long-lived
emulator grew by one entry per multi-organization password login.
Expired tokens that are presented again are dropped the same way.
Comment thread src/workos/routes/auth.ts
A token minted for an organization page that is never submitted was
only checked for expiry when presented again, which an abandoned page
never does. Each abandoned login therefore left one entry behind for
the emulator's lifetime. Sweeping expired entries whenever a new one
is minted bounds the store to the tokens from the last ten minutes.
A correct password on the interactive page minted a code for any
account, so a browser suite could sign in a user the `password`
grant would have stopped with email_verification_required or
mfa_challenge, and the exchange then reported a Password login that
production would never have completed. The gates the grant enforces
now follow the password page in the order hosted AuthKit shows them:
the emailed code for an unverified mailbox, then the authenticator
code for an enrolled factor, each a page carrying the same login
token so a POST cannot skip past an open gate.

The exchange reports what the API's final grant would: the event of
the gate cleared last, with the session and authentication_method
still recording Password. Mirroring the grants keeps a webhook
consumer's view of a login the same whether it came through the
browser or the API, at the cost of one succeeded event per login
rather than one per factor.

Also covers two token checks the tests left unexercised, a token
minted for another user and an expired one presented again, and
asserts on the specific token key instead of a prefix delete that
mutated the store and depended on test order.
Comment thread src/workos/routes/auth.ts Outdated
The sweep of expired interactive login tokens, and the drop of an
expired token presented again, removed only the token. The
email_verification or authentication_challenge the login's open gate
was waiting on stayed in its collection, so every abandoned gated
login left one record behind for the emulator's lifetime.

Both paths now delete the referenced record along with the token.
Nothing else could have redeemed it: the API grants reach their
records through a pending_authentication_token of their own, which
never points at an interactive login's.
@gjtorikian
gjtorikian merged commit 993f7f1 into main Sep 3, 2026
28 of 31 checks passed
@gjtorikian
gjtorikian deleted the feat/interactive-password-step branch September 3, 2026 19:09
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.

authenticate returns sealed_session on every SDK call, pushing authkit-nextjs session cookies past the 4096-byte browser cap

1 participant