feat(auth): let a single-sign-on account confirm it is you at its provider

An account created through OIDC holds no password, passkey or second
factor, so the recent-proof gate in front of token minting, MCP
connections, exports and the admin reset had nothing to ask it for and
the web could only say "sign in again" (#1296).

GET /api/auth/oidc/reproof sends the browser back to the configured
provider with prompt=login and max_age=0, its own state, nonce and PKCE
verifier, and the signed-in account and session sealed into the state
cookie. The shared callback branches on that intent: it refuses unless
the browser still carries the bound session (checked before the code is
redeemed), the ID token names the account's linked issuer and subject,
and auth_time lies inside the gate's five-minute window. It then stamps
Session.reproofAt exactly as a password re-proof does, creates no
session, and returns to the page it left with ?reproof=done|failed.

recentProofMethods offers "oidc" only to an account without a second
factor whose identity is linked while a provider is configured, and the
fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the
list as proofMethods, so the passkey card offers what the account can
actually give, and register-options takes { method: "oidc" } on a fresh
provider re-proof, so such an account can add a passkey as a second way
to confirm. Both re-proof dialogs offer the provider and a toast reports
the outcome on return.

The callback's missing-state branch and the passkey card's enrollment
flow move into their own functions to keep both handlers under the size
ratchet.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 08:56:51 +01:00
1 parent 35a0ceb12c
commit bbef4eea99
35 files changed
+1654 -187

No files matched your search

+36 -5
View File
@@ -2481,10 +2481,13 @@ paths:
The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`,
`webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second
factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422
`auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also
refreshes the session's second-factor stamp, exactly as signing in again would. Assertions begin at POST
/api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.
factor; on one without, `password` or `passkey`. An account without a second factor whose identity is linked to
the configured identity provider is also offered `oidc` in `meta.methods`; that proof is not posted here but
given by the browser round trip GET /api/auth/oidc/reproof, which stamps the session exactly as a password proof
does. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`,
before any guess is spent. A second-factor or passkey proof also refreshes the session's second-factor stamp,
exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method:
"passkey" | "webauthn" }`.
Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that
@@ -4077,7 +4080,9 @@ paths:
**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a
password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options
issued. On an account that already has a second factor a password is refused before it is checked, with 422
issued. An account confirmed at its identity provider (GET /api/auth/oidc/reproof, within five minutes) sends `{
method: "oidc" }` instead, which takes that re-proof as the proof and, like a password, clears the second-factor
stamp. On an account that already has a second factor a password is refused before it is checked, with 422
`auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone
would sign in without the second factor from then on.
@@ -4103,6 +4108,7 @@ paths:
schema:
anyOf:
- $ref: "#/components/schemas/PasskeyReauthRequest"
- $ref: "#/components/schemas/PasskeyProviderProofRequest"
- $ref: "#/components/schemas/StepUpMintRequest"
responses:
"200":
@@ -28611,6 +28617,17 @@ components:
description: The re-proof arm of register-options. Recognised ONLY when `method` is the body's single key — adding any
other property routes the request to the enrollment arm, where it will be read as a factor proof and almost
certainly refused.
PasskeyProviderProofRequest:
type: object
properties:
method:
type: string
const: oidc
required:
- method
description: Take the session's recent identity-provider re-proof (GET /api/auth/oidc/reproof) as the proof. Accepted
only for an account whose `proofMethods` list `oidc` and only within five minutes of that re-proof; otherwise
401 `auth.reproof.required` with `meta.methods`. Web only.
PasskeyRegisterVerifyRequest:
type: object
properties:
@@ -47232,11 +47249,25 @@ components:
$ref: "#/components/schemas/WebauthnMfaCredentialInfo"
passkeyNudgeDismissed:
type: boolean
proofMethods:
type: array
items:
type: string
enum:
- password
- oidc
- totp
- webauthn
- passkey
description: The ways this account can confirm it is you, the same list a recent-proof refusal names in `meta.methods`.
`password` and `oidc` only on an account without a second factor; `oidc` is a browser round trip to the
identity provider (`GET /api/auth/oidc/reproof`) and is never offered to a token.
required:
- totp
- recoveryCodesRemaining
- webauthn
- passkeyNudgeDismissed
- proofMethods
additionalProperties: false
WebauthnMfaCredentialInfo:
type: object