Skip to content

fix(mfa): return the spec's auth-factor enrollment envelope - #114

Merged
gjtorikian merged 5 commits into
mainfrom
fix/mfa-auth-factor-envelope
Sep 15, 2026
Merged

gjtorikian merged 5 commits into
mainfrom
fix/mfa-auth-factor-envelope

Conversation

@gjtorikian

@gjtorikian gjtorikian commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #110. Verified against production (userland-user-authentication-factors.controller.ts), the published OpenAPI spec, and all 11 backend SDKs.

  • Enrollment envelope. POST /user_management/users/:id/auth_factors returned a bare factor where the spec's UserlandUserAuthenticationFactorEnrollResponse wraps it as { authentication_factor, authentication_challenge }. The Node SDK threw a TypeError; Python, Kotlin, Android, Rust, Swift and PHP refused to deserialize; Go, Ruby, .NET and Elixir silently returned nil for both halves. No SDK could drive TOTP enrollment through the emulator.
  • TOTP secrets. The wrapper alone was not enough: the spec's enrolled factor requires totp.secret, totp.qr_code and totp.uri, and the same SDKs fail one level down without them. Enrollment now mints a 32-character Base32 secret (honoring a caller-supplied totp_secret, validated as production does, 422 invalid_totp_secret otherwise) and is the only response that shows it — GET and LIST return the spec's secretless AuthenticationFactor, as production does. Also fixes totp_user being ignored.
  • Challenge field name. Every authentication_challenge on the wire (challenge routes, the password grant's mfa_challenge step, legacy MFA) carried the store's factor_id/user_id where the spec has authentication_factor_id and no user. Seven SDKs hard-fail on this. Changed at the single formatter every route already goes through; the store keeps its columns.
  • text/plain on 204. Not the handler's doing — @hono/node-server 1.x stamped a text/plain default on every response without one, empty body included, so all 27 no-content routes were affected. 2.x skips the default for a null body; bumping fixes them all with no adapter workaround. (Only serve is used; the only 2.x breaking change is dropping Node 18, and engines already requires ≥22.)

Notes

  • qr_code is a valid 1×1 PNG rather than a scannable code: the field is required and typed as a string, the emulator never verifies a real TOTP code, and uri already carries everything the code would. Marked with a ponytail: comment naming the upgrade path.
  • Why the spec conformance loops didn't catch this. response-envelopes.spec.ts already hits each cataloged route live and diffs the body against the OpenAPI spec, but no MFA operation or resource was in the curated catalogs — a coverage gap, not a design one. The last commit adds the six MFA operations with a JSON body and the two resources to the catalogs, regenerates them, and adds the live cases. Run against main without the fix, those cases fail six times, each naming one of the drifts above. The loops diff top-level keys only, so the nested totp.secret/qr_code/uri requirement is pinned by the new auth-factors.spec.ts instead.

Every no-content reply — deleting a user, a factor, an organization —
went out as `Content-Type: text/plain; charset=UTF-8`, which
production never sends. The header was not ours: @hono/node-server 1.x
stamped a text/plain default onto any response lacking one, empty
body included. 2.x skips the default when the body is null, so the
bump fixes all 27 routes at once with no adapter-level workaround.

Refs #110
Every authentication_challenge on the wire — from the challenge
routes, the password grant's mfa_challenge step and the legacy MFA
API — carried the store's join columns, `factor_id` and `user_id`,
where the spec's AuthenticationChallenge has `authentication_factor_id`
and no user. Every generated SDK reads the spec's name: Kotlin, Rust,
Swift, Python and PHP refuse to deserialize without it, and Go, Ruby,
.NET and Elixir hand back an empty factor id. The store keeps its
columns; only the formatter, where every route already meets, changes.

Refs #110
`POST /user_management/users/:id/auth_factors` answered with a bare
factor, where the spec's UserlandUserAuthenticationFactorEnrollResponse
wraps it as `{ authentication_factor, authentication_challenge }`.
Every backend SDK reads that envelope: Node throws a TypeError before
returning, Python, Kotlin, Rust, Swift and PHP refuse to deserialize,
and Go, Ruby, .NET and Elixir hand back nil for both halves — so no
SDK could drive TOTP enrollment through the emulator.

The wrapper alone would not have been enough. The spec's enrolled
factor requires `totp.secret`, `totp.qr_code` and `totp.uri`, and the
same SDKs fail one level down without them; the emulator stored only a
hex secret buried in the URI. Enrollment now mints a Base32 secret
(honoring a caller-supplied `totp_secret`, validated as production
does) and is the only response that shows it — GET and LIST return
the spec's secretless AuthenticationFactor, as production does.

The `qr_code` is a valid 1x1 PNG rather than a scannable code: the
field is required and typed as a string, the emulator never verifies
a real TOTP code, and `uri` already carries everything the code would.

Fixes #110
@greptile-apps

greptile-apps Bot commented Sep 15, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the previously reported secret-type contract issue is fixed and no new actionable failures remain.

Summary

This PR aligns MFA enrollment and challenge responses with the published API contract.

  • Returns the authentication-factor enrollment envelope with an enrolled factor and challenge.
  • Generates and validates Base32 TOTP secrets while limiting secret disclosure to enrollment responses.
  • Maps stored challenge fields to their public schema names.
  • Expands MFA response-shape and route coverage.
  • Upgrades @hono/node-server to correct headers on empty responses.

Diagram

sequenceDiagram
    participant SDK
    participant Emulator
    participant Store

    SDK->>Emulator: "POST /users/{id}/auth_factors"
    Emulator->>Emulator: Validate or generate Base32 secret
    Emulator->>Store: Insert authentication factor
    Emulator->>Store: Insert enrollment challenge
    Emulator-->>SDK: "201 {authentication_factor, authentication_challenge}"

    SDK->>Emulator: GET/LIST authentication factors
    Emulator->>Store: Read stored factor
    Emulator-->>SDK: Secretless AuthenticationFactor
Loading

Reviews (3) · Last reviewed commit: "fix(mfa): reject a non-string totp_secre..."

Comment thread src/workos/routes/auth-factors.ts Outdated
Issue #110 shipped in several releases because neither conformance
loop looked at MFA: the enrollment envelope, the factor and the
challenge were all outside the curated catalogs, so the bare factor
and the challenge's `factor_id` never met the spec. Both loops already
do exactly the check that was needed — the gap was coverage, not
design. The six spec operations with a JSON body and the two resources
join the catalogs; run against main without the fix, the new cases
fail six times, each naming one of the drifts.

The loops diff top-level keys, so they cannot see the enrolled
factor's `totp.secret`, `qr_code` and `uri`; the route tests added
with the fix pin those.

Refs #110
`RegExp.test` coerces its argument, and the digits 2–7 are Base32, so
a JSON number like 234567 passed validation and was stored and
returned as a number — a `totp.secret` the strictly typed SDKs would
refuse to deserialize.

Refs #110
@gjtorikian
gjtorikian merged commit 9b438ee into main Sep 15, 2026
10 checks passed
@gjtorikian
gjtorikian deleted the fix/mfa-auth-factor-envelope branch September 15, 2026 15:11
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.

MFA auth-factors: createUserAuthFactor omits the documented envelope (SDK throws); deleteFactor's 204 has the wrong Content-Type

1 participant