feat(api): 添加或删除 passkey、修改邮箱前须 5 分钟内用已有因子重新验证,变更后邮件通知账户,面板加确认对话框与修改邮箱入口

This commit is contained in:
Lemon-miaow committed 2026-09-25 14:47:54 +08:00
1 parent 9e7f23ca13
commit d3769b5c31
36 files changed
+2735 -288

No files matched your search

+237 -7
View File
@@ -157,6 +157,27 @@ components:
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
Reauthed:
description: This session is reauthed until the returned time.
content:
application/json:
schema:
type: object
required: [ok, until]
properties:
ok: { type: boolean, const: true }
until: { type: string, format: date-time }
ReauthRequired:
description: >
reauth_required: this change adds, removes or moves a way into the account,
and the account has a passkey or a verified email, so the session must have
proven one of them within the last 5 minutes. Signing in by passkey, email
code, op-login or the setup token counts; a bind-code sign-in does not.
GET /api/v1/account/reauth lists the factors that can give the proof, then
retry the change.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
MailUndeliverable:
description: >
The configured SMTP relay refused the message (code mail_undeliverable), so no
@@ -3977,7 +3998,8 @@ paths:
Generates a one-time code bound to the authenticated principal and the
supplied address, persists only its hash, and delivers it out of band. The
code is never returned in the response. A re-request supersedes the prior
unconsumed code.
unconsumed code. Once the account has a passkey or a verified email, the
session must have reauthed within 5 minutes (403 reauth_required).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
@@ -4008,6 +4030,8 @@ paths:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/ReauthRequired'
'429':
description: >-
Resend requested before the cooldown elapsed (otp_resend_cooldown); or the
@@ -4030,7 +4054,9 @@ paths:
success the user's email is written and email_verified is set true. When the
new address replaces a different verified one, every other session of the
caller is signed out: sign-in codes now go to the new address, so a session
opened through the old one ends. Too many
opened through the old one ends; the old address is mailed a notice with the
new one masked. A verified code also counts as a reauth for this session.
Too many
incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h,
counted across every code, lock the account's email-code door until the
window ends (429 otp_account_locked with Retry-After). An unknown, expired,
@@ -4082,7 +4108,9 @@ paths:
Writes the supplied address to the authenticated principal's user row and
clears email_verified (already false for a fresh Owner). The setup bootstrap
has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP
flow proves control of the address via /account/email/verify.
flow proves control of the address via /account/email/verify. Clearing a
verified address strips a factor, so once the account has one the session
must have reauthed within 5 minutes (403 reauth_required).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
@@ -4112,6 +4140,8 @@ paths:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/ReauthRequired'
/api/v1/account/passkey/register/begin:
post:
@@ -4122,8 +4152,10 @@ paths:
Mints a credential-creation challenge bound to the authenticated principal,
stashes the server-side ceremony state under a short TTL, and returns the
WebAuthn publicKey creation options for navigator.credentials.create(). The
challenge is never echoed by the client. Enrollment only — passkey login is a
deferred slice. 503 when the WebAuthn verifier is not configured on this instance.
challenge is never echoed by the client. Once the account has a passkey or a
verified email, the session must have reauthed within 5 minutes (403
reauth_required). 503 when the WebAuthn verifier is not configured on this
instance.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
@@ -4137,6 +4169,8 @@ paths:
description: Opaque WebAuthn PublicKeyCredentialCreationOptions, passed verbatim to the browser.
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/ReauthRequired'
'503':
description: Passkey subsystem is not configured.
content:
@@ -4153,7 +4187,9 @@ paths:
authenticator's attestation against the server-stashed ceremony state, and
persists the public credential. A missing or expired ceremony is a 400; an
attestation that fails verification is a 400; a credential already bound to any
account is a 409. 503 when the WebAuthn verifier is not configured.
account is a 409. The verified email is mailed a notice, and the ceremony
counts as a reauth for this session. 503 when the WebAuthn verifier is not
configured.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
@@ -4231,7 +4267,9 @@ paths:
silently no-ops as success. The account's only passkey cannot be removed while
its email is unverified (409 last_passkey): it is then the account's only
durable way in. Removing a passkey signs out every other session of the
caller, so a session opened with that passkey ends with it.
caller, so a session opened with that passkey ends with it, and mails the
verified email a notice. The session must have reauthed within 5 minutes
(403 reauth_required).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
@@ -4246,6 +4284,8 @@ paths:
description: Passkey unbound.
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/ReauthRequired'
'404':
description: No such passkey for this caller.
content:
@@ -4257,6 +4297,196 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/reauth:
get:
tags: [account]
operationId: reauthStatus
summary: Say whether a passkey or email change needs a reauth first, and how to give one.
description: >
needed is true when the account has a passkey or a verified email and this
session has not proven one within the last 5 minutes. until is when the
current proof stops counting. factors lists the ways this caller can
reauth, best first: passkey (an enrolled passkey), email (a player's
verified address), sign_in (an operator signs out and back in through
op-login or a passkey).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
responses:
'200':
description: Where the caller stands.
content:
application/json:
schema:
type: object
required: [needed, factors]
properties:
needed: { type: boolean }
until: { type: string, format: date-time }
factors:
type: array
items: { type: string, enum: [passkey, email, sign_in] }
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/account/reauth/passkey/begin:
post:
tags: [account]
operationId: reauthPasskeyBegin
summary: Begin a passkey assertion that reauths this session.
description: >
Returns WebAuthn assertion request options over the caller's own passkeys,
bound to a fresh reauth-purpose challenge.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
responses:
'200':
description: WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get.
content:
application/json:
schema: { type: object, description: Opaque WebAuthn PublicKeyCredentialRequestOptions. }
'400':
description: The caller has no enrolled passkey (no_passkey), or no browser session to mark (no_session).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/account/reauth/passkey/finish:
post:
tags: [account]
operationId: reauthPasskeyFinish
summary: Finish the passkey assertion and mark this session reauthed for 5 minutes.
description: >
Verifies the assertion against the reauth challenge with the login door's
clone check (a cloned authenticator is 400 passkey_login_invalid).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [assertion]
properties:
assertion:
type: object
description: The navigator.credentials.get() PublicKeyCredential assertion.
responses:
'200':
$ref: '#/components/responses/Reauthed'
'400':
description: Assertion invalid, challenge stale, or a cloned authenticator (passkey_login_invalid); no browser session (no_session).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/account/reauth/email/start:
post:
tags: [account]
operationId: reauthEmailStart
summary: Mail a reauth code to the caller's verified address.
description: >
For players with a verified email. Operators reauth with a passkey or by
signing in again (403 staff_reauth).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
responses:
'202':
description: Code minted and dispatched.
content:
application/json:
schema:
type: object
required: [sent, expires_at]
properties:
sent: { type: boolean, const: true }
expires_at: { type: string, format: date-time }
'400':
description: No browser session to mark (no_session).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Operators cannot reauth by email (staff_reauth).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: The account has no verified email (no_step_up_factor).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: >-
Resend requested before the cooldown elapsed (otp_resend_cooldown); or the
account's daily wrong-code budget is spent (otp_account_locked, with
Retry-After); or the install-wide mail budget is spent
(mail_rate_limited, with Retry-After).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'502':
$ref: '#/components/responses/MailUndeliverable'
/api/v1/account/reauth/email/verify:
post:
tags: [account]
operationId: reauthEmailVerify
summary: Redeem the reauth code and mark this session reauthed for 5 minutes.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code: { type: string }
responses:
'200':
$ref: '#/components/responses/Reauthed'
'400':
description: Invalid or expired code (invalid_code), or no browser session (no_session).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Operators cannot reauth by email (staff_reauth).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: The account has no verified email (no_step_up_factor).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: >-
Too many incorrect attempts on this code (otp_locked), or the account's
daily wrong-code budget is spent (otp_account_locked, with Retry-After).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/sessions:
get:
tags: [account]
+125
View File
@@ -0,0 +1,125 @@
package api
import (
"fmt"
"log"
"net/http"
"strings"
"time"
"felis.lolicon.best/internal/metrics"
)
// Account change notices tell the owner of an account, at the verified address,
// that a way into it was just added, removed or moved: a passkey registered or
// removed, the email replaced (that notice goes to the OLD address, which is the
// one the owner still reads if someone else made the change). They carry the time
// and the source address and say what to do if the change was not theirs.
// Best effort, like the lock notice: the change already happened.
// notifyAccountChange mails one notice to the given address.
func (a *API) notifyAccountChange(r *http.Request, to, subject, body string) {
if to == "" {
return
}
sender, ok := a.Mailer.(noticeSender)
if !ok {
log.Printf("auth: no notice mailer; account change notice %q was not sent (request_id=%s)",
subject, requestIDFromContext(r.Context()))
return
}
if ok, _ := a.mailGate().take(mailGateKey); !ok {
metrics.MailTotal.WithLabelValues("notice", "throttled").Inc()
log.Printf("auth: mail budget spent; account change notice %q was not sent (request_id=%s)",
subject, requestIDFromContext(r.Context()))
return
}
if err := sender.SendNotice(r.Context(), to, subject, body); err != nil {
metrics.MailTotal.WithLabelValues("notice", "failed").Inc()
log.Printf("auth: account change notice failed (request_id=%s): %v", requestIDFromContext(r.Context()), err)
return
}
metrics.MailTotal.WithLabelValues("notice", "sent").Inc()
}
// verifiedEmail is where a notice about p's account goes: the address it proved,
// or nothing.
func verifiedEmail(p *Principal) string {
if !p.EmailVerified {
return ""
}
return p.Email
}
func (a *API) notifyPasskeyAdded(r *http.Request, p *Principal) {
subject, body := accountChangeNotice(
"已添加 Passkey", "passkey added",
"你的 Felis 账户刚刚添加了一个 Passkey。", "A passkey was just added to your Felis account.",
"删除这个 Passkey", "remove that passkey",
a.now(), a.noticeIP(r))
a.notifyAccountChange(r, verifiedEmail(p), subject, body)
}
func (a *API) notifyPasskeyRemoved(r *http.Request, p *Principal) {
subject, body := accountChangeNotice(
"已删除 Passkey", "passkey removed",
"你的 Felis 账户刚刚删除了一个 Passkey,其它设备上的登录已全部退出。",
"A passkey was just removed from your Felis account, and every other device was signed out.",
"检查剩下的 Passkey", "check the passkeys that remain",
a.now(), a.noticeIP(r))
a.notifyAccountChange(r, verifiedEmail(p), subject, body)
}
// notifyEmailChanged tells the previous verified address where the account's
// mail now goes, masked so the notice does not hand the new address to whoever
// reads the old mailbox.
func (a *API) notifyEmailChanged(r *http.Request, oldEmail, newEmail string) {
masked := maskEmail(newEmail)
subject, body := accountChangeNotice(
"邮箱已更换", "email changed",
"你的 Felis 账户的邮箱刚刚更换为 "+masked+",这个地址以后不会再收到登录验证码。",
"The email on your Felis account was just changed to "+masked+". This address will no longer receive sign-in codes.",
"把邮箱改回来", "change the email back",
a.now(), a.noticeIP(r))
a.notifyAccountChange(r, oldEmail, subject, body)
}
func (a *API) noticeIP(r *http.Request) string {
if ip := a.clientIP(r); ip.IsValid() {
return ip.String()
}
return ""
}
// accountChangeNotice renders a bilingual notice. zhUndo/enUndo name the step
// that reverses the change, for the "if this wasn't you" line.
func accountChangeNotice(zhTitle, enTitle, zhWhat, enWhat, zhUndo, enUndo string, at time.Time, ip string) (subject, body string) {
when := at.UTC().Format("2006-01-02 15:04 MST")
zhIP, enIP := ip, ip
if ip == "" {
zhIP, enIP = "未知", "unknown"
}
subject = "Felis " + zhTitle + " · " + enTitle
body = fmt.Sprintf(`%s
时间:%s
来源 IP:%s
如果不是你本人操作,请立即登录 Felis,在账户页%s并退出其它设备,然后联系服务器管理员。
%s
Time: %s
From IP: %s
If this wasn't you, sign in to Felis now, %s and sign out other devices on the Account page, then contact the server operator.
`, zhWhat, when, zhIP, zhUndo, enWhat, when, enIP, enUndo)
return subject, body
}
// maskEmail keeps the first character of the local part and the domain:
// [email protected] → a***@example.com.
func maskEmail(email string) string {
at := strings.LastIndexByte(email, '@')
if at <= 0 {
return "***"
}
first := []rune(email[:at])[0]
return string(first) + "***" + email[at:]
}
+10
View File
@@ -560,6 +560,16 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish},
{Method: "GET", Pattern: "/api/v1/account/passkey/credentials", SetupAllowed: true, h: a.handlePasskeyList},
{Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", SetupAllowed: true, h: a.handlePasskeyDelete},
// Reauth (reauth.go): the fresh proof that passkey enrollment and removal and an
// email change require once the account has a factor. Status says whether one
// is needed and how to give it; the pairs below take a passkey assertion or an
// email code and mark the caller's session. SetupAllowed like the routes they
// unlock.
{Method: "GET", Pattern: "/api/v1/account/reauth", SetupAllowed: true, h: a.handleReauthStatus},
{Method: "POST", Pattern: "/api/v1/account/reauth/passkey/begin", SetupAllowed: true, h: a.handleReauthPasskeyBegin},
{Method: "POST", Pattern: "/api/v1/account/reauth/passkey/finish", SetupAllowed: true, h: a.handleReauthPasskeyFinish},
{Method: "POST", Pattern: "/api/v1/account/reauth/email/start", SetupAllowed: true, h: a.handleReauthEmailStart},
{Method: "POST", Pattern: "/api/v1/account/reauth/email/verify", SetupAllowed: true, h: a.handleReauthEmailVerify},
// The caller's own sessions (handlers_account_sessions.go): list every signed-in
// device and sign out one or all the others. App-tier and scoped to the caller
// inside the handler, like the passkey routes above.
+17 -4
View File
@@ -75,10 +75,12 @@ type fakeRepo struct {
// failSessionUser / failGetSetting force those reads to fail with a generic
// (non-ErrNotFound) error, simulating a store outage for the 503 auth path.
failSessionUser error
// failTouchSession / failRevokeOthers force those session writes to fail.
// failTouchSession / failRevokeOthers / failMarkReauth force those session
// writes to fail.
failTouchSession error
failRevokeOthers error
failGetSetting error
failMarkReauth error
failGetSetting error
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
// newest live (user, purpose) just as the PG query does.
otps map[string]*fakeEmailOTP
@@ -220,6 +222,8 @@ type fakeSession struct {
userAgent string
clientIP string
touches int
// reauthAt is reauth_at: when the session last proved a factor; zero = never.
reauthAt time.Time
}
// fakeBackup mirrors a world_backups row: the client-facing view plus the
@@ -904,7 +908,16 @@ func (f *fakeRepo) CreateSession(_ context.Context, ns NewSession) error {
now := ns.ExpiresAt.Add(-sessionTTL)
f.sessions[ns.TokenHash] = &fakeSession{
userID: ns.UserID, expiresAt: ns.ExpiresAt, createdAt: now, lastSeen: now,
userAgent: ns.UserAgent, clientIP: ns.ClientIP,
userAgent: ns.UserAgent, clientIP: ns.ClientIP, reauthAt: ns.ReauthAt,
}
return nil
}
func (f *fakeRepo) MarkSessionReauth(_ context.Context, tokenHash string, at time.Time) error {
if f.failMarkReauth != nil {
return f.failMarkReauth
}
if s, ok := f.sessions[tokenHash]; ok && !s.revoked {
s.reauthAt = at
}
return nil
}
@@ -951,7 +964,7 @@ func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Tim
}
return &SessionedUser{
ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role,
EmailVerified: u.EmailVerified, LastSeenAt: s.lastSeenAt(now),
EmailVerified: u.EmailVerified, LastSeenAt: s.lastSeenAt(now), ReauthAt: s.reauthAt,
}, nil
}
func (f *fakeRepo) TouchSession(_ context.Context, tokenHash string, now time.Time) error {
+1 -1
View File
@@ -43,7 +43,7 @@ func TestAuditCannotBeSignedWithAnotherPersonsEmail(t *testing.T) {
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["owner"] = &StaffUser{ID: "u1", Username: "owner", Email: "[email protected]", Role: "owner", EmailVerified: true}
repo.staff["mallory"] = &StaffUser{ID: "u2", Username: "mallory", Email: "[email protected]", Role: "user", EmailVerified: true}
repo.sessions[hashCookie("tok")] = &fakeSession{userID: "u2", expiresAt: time.Unix(1_700_000_000, 0).Add(time.Hour)}
repo.sessions[hashCookie("tok")] = &fakeSession{userID: "u2", expiresAt: frozenNow.Add(time.Hour), reauthAt: frozenNow}
api := newTestAPI(repo, newFakeCluster())
api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
api.ClientIPHeader = "CF-Connecting-IP"
+4
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
@@ -41,6 +42,9 @@ type Principal struct {
// passed Zero Trust at the edge, so the local-email-verification gate is not
// the right boundary for them.
ViaSession bool
// ReauthAt is when the holder of the session last proved a factor of the
// account; zero for a session that never did and for a JWT caller.
ReauthAt time.Time
}
// staffRole reports whether a stored user role carries staff standing: admin,
+10 -152
View File
@@ -1,11 +1,9 @@
package api
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
@@ -25,7 +23,9 @@ import (
// email-OTP — advancing to 'confirmed'. Mere
// session possession is never enough; a stolen
// session cannot read the mailbox nor present the
// authenticator.
// authenticator, and cannot enroll one of its own
// without a recent proof of an existing factor
// (reauth.go).
// 3. web issue code + name target → handleMigrateIssueCode: the source names the
// target account by id and mints a one-time code
// ('code_issued').
@@ -205,54 +205,7 @@ func (a *API) handleMigrateConfirmOTPStart(w http.ResponseWriter, r *http.Reques
}
// Per-recipient cooldown, namespaced apart from the other OTP doors so they never
// perturb each other's throttle.
if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, otpPurposeMigrate, a.now()); err != nil {
writeError(w, r, err)
return
} else if !until.IsZero() {
writeOTPAccountLocked(w, r, until, a.now())
return
}
emailKey := "migrate:confirm:" + strings.ToLower(p.Email)
lim := a.otpLimiter()
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
"a code was sent recently; wait a moment before requesting another"))
return
}
committed := false
defer func() {
if !committed {
lim.release(emailKey, emailAt)
}
}()
code, err := newEmailOTP()
if err != nil {
writeError(w, r, err)
return
}
id, err := newOTPID()
if err != nil {
writeError(w, r, err)
return
}
expiresAt := a.now().Add(otpTTL)
if err := a.Repo.CreateEmailOTP(r.Context(), id, p.UserID, p.Email, otpCodeHash(code), otpPurposeMigrate, expiresAt); err != nil {
writeError(w, r, err)
return
}
if err := a.deliverOTP(r.Context(), p.Email, code); err != nil {
writeError(w, r, err)
return
}
committed = true
a.audit(r, "account.migrate.confirm_otp_sent", "")
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()})
}
// migrateConfirmOTPVerifyRequest is the OTP step-up verify body: the code from the email.
type migrateConfirmOTPVerifyRequest struct {
Code string `json:"code"`
a.startStepUpOTP(w, r, p, otpPurposeMigrate, "migrate:confirm:", "account.migrate.confirm_otp_sent")
}
// handleMigrateConfirmOTPVerify redeems the migration step-up code and, on a match,
@@ -260,7 +213,7 @@ type migrateConfirmOTPVerifyRequest struct {
// is the login-door one (no identity side-effect): the address is already proven.
func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
var req migrateConfirmOTPVerifyRequest
var req stepUpOTPVerifyRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
@@ -273,25 +226,7 @@ func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Reque
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
return
}
var lock *OTPAccountLockedError
err := a.Repo.ConsumeLoginEmailOTP(r.Context(), p.UserID, otpPurposeMigrate, otpCodeHash(code), a.now())
if isOTPRefusal(err) {
a.authFailure(r, "migrate_confirm", otpFailureReason(err), nil)
}
switch {
case errors.As(err, &lock):
a.noteOTPLock(r, err, p.UserID, otpPurposeMigrate)
writeOTPAccountLocked(w, r, lock.Until, a.now())
return
case errors.Is(err, ErrOTPLocked):
writeError(w, r, newError(http.StatusTooManyRequests, "otp_locked",
"too many incorrect attempts; request a new code"))
return
case errors.Is(err, ErrOTPInvalid):
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired"))
return
case err != nil:
writeError(w, r, err)
if !a.verifyStepUpOTP(w, r, p, otpPurposeMigrate, "migrate_confirm", code) {
return
}
if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "email_otp", a.now()); err != nil {
@@ -307,17 +242,6 @@ func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, map[string]any{"confirmed": true})
}
// migratePasskeyUser builds the PasskeyUser the assertion ceremony needs for the
// already-logged-in source (contrast the login door, which resolves it from a typed
// email). The credential set must be identical between begin and finish.
func migratePasskeyUser(p *Principal, creds []PasskeyCredential) PasskeyUser {
name := p.Email
if name == "" {
name = p.UserID
}
return PasskeyUser{ID: p.UserID, Name: name, DisplayName: name, Credentials: creds}
}
// handleMigrateConfirmPasskeyBegin starts a fresh passkey assertion bound to the
// migration step-up (spec §B3, external app face). Unlike the login door it needs no
// email — the caller is already authenticated — so it scopes the challenge to the
@@ -331,39 +255,8 @@ func (a *API) handleMigrateConfirmPasskeyBegin(w http.ResponseWriter, r *http.Re
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
return
}
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
if err != nil {
writeError(w, r, err)
return
}
if len(creds) == 0 {
writeError(w, r, newError(http.StatusBadRequest, "no_passkey",
"no passkey enrolled; confirm the migration with an email code"))
return
}
options, sessionData, err := a.Passkey.BeginLogin(migratePasskeyUser(p, creds))
if err != nil {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed",
"could not start passkey confirmation"))
return
}
id, err := newPasskeyID()
if err != nil {
writeError(w, r, err)
return
}
expiresAt := a.now().Add(passkeyChallengeTTL)
if err := a.Repo.CreatePasskeyChallenge(r.Context(), id, p.UserID, passkeyPurposeMigrate, sessionData, expiresAt); err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, options)
}
// migrateConfirmPasskeyFinishRequest is the assertion the browser produced, captured
// as raw bytes so the exact response reaches the verifier without re-encoding.
type migrateConfirmPasskeyFinishRequest struct {
Assertion json.RawMessage `json:"assertion"`
a.beginStepUpPasskey(w, r, p, passkeyPurposeMigrate,
"no passkey enrolled; confirm the migration with an email code")
}
// handleMigrateConfirmPasskeyFinish verifies the migration step-up assertion and, on
@@ -375,7 +268,7 @@ func (a *API) handleMigrateConfirmPasskeyFinish(w http.ResponseWriter, r *http.R
writeError(w, r, errPasskeyUnavailable)
return
}
var req migrateConfirmPasskeyFinishRequest
var req stepUpPasskeyFinishRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
@@ -387,42 +280,7 @@ func (a *API) handleMigrateConfirmPasskeyFinish(w http.ResponseWriter, r *http.R
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
return
}
sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), p.UserID, passkeyPurposeMigrate, a.now())
if err != nil {
if errors.Is(err, ErrPasskeyChallengeInvalid) {
a.authFailure(r, "migrate_passkey", "challenge_invalid", nil)
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey confirmation could not be completed; begin again"))
return
}
writeError(w, r, err)
return
}
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
if err != nil {
writeError(w, r, err)
return
}
va, err := a.Passkey.FinishLogin(migratePasskeyUser(p, creds), sessionData, bytes.NewReader(req.Assertion))
if err != nil {
a.authFailure(r, "migrate_passkey", "bad_assertion", nil)
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey confirmation could not be completed; begin again"))
return
}
// Same clone policy as the login door (applyAssertionCounter): a rolled-back counter
// fails closed with the opaque envelope and advances nothing, so the migrate step-up is
// never a weaker sibling that would accept an authenticator login refuses. A clean
// assertion advances the stored sign-count, keeping the clone signal meaningful for the
// next login.
if err := a.applyAssertionCounter(r.Context(), va); err != nil {
if errors.Is(err, errPasskeyClonedAuthenticator) {
a.passkeyCloneRejected(r, "migrate_passkey", nil, va.CredentialID)
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey confirmation could not be completed; begin again"))
return
}
writeError(w, r, err)
if !a.finishStepUpPasskey(w, r, p, passkeyPurposeMigrate, "migrate_passkey", req.Assertion) {
return
}
if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "passkey", a.now()); err != nil {
@@ -35,7 +35,9 @@ func newSessionsFixture(t *testing.T) *sessionsFixture {
api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
now := api.now()
for tok, s := range map[string]*fakeSession{
laptopTok: {userID: "u1", lastSeen: now.Add(-10 * time.Minute), userAgent: "Firefox on Linux", clientIP: "203.0.113.5"},
// The laptop signed in by a proving door a minute ago, so the guarded
// changes below run without a reauth (reauth_test.go covers the gate).
laptopTok: {userID: "u1", lastSeen: now.Add(-10 * time.Minute), userAgent: "Firefox on Linux", clientIP: "203.0.113.5", reauthAt: now.Add(-time.Minute)},
phoneTok: {userID: "u1", lastSeen: now.Add(-2 * time.Hour), userAgent: "Safari on iPhone", clientIP: "198.51.100.7"},
alexTok: {userID: "u2", lastSeen: now.Add(-time.Minute)},
} {
+1 -1
View File
@@ -271,7 +271,7 @@ func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.startSession(w, r, u.ID); err != nil {
if err := a.startSession(w, r, u.ID, provenSignIn); err != nil {
writeError(w, r, err)
return
}
+14 -1
View File
@@ -131,6 +131,10 @@ func (a *API) handleEmailOTPStart(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
// Gate the start: the verify only redeems a code minted here.
if !a.requireReauth(w, r, p) {
return
}
// Atomically reserve the cooldown on both the caller and the recipient BEFORE
// minting, so a burst of truly concurrent starts yields exactly one winner. Here
// the throttle is the sole defense and each admitted send is a real, non-idempotent
@@ -249,10 +253,14 @@ func (a *API) handleEmailOTPVerify(w http.ResponseWriter, r *http.Request) {
return
}
a.audit(r, "account.email.verified", "")
// Proving the address is an email reauth.
a.markReauthQuietly(r)
// Replacing a verified address moves where sign-in codes go, so a session
// opened through the old one ends. A first verification retires nothing.
// opened through the old one ends, and the old mailbox hears about it. A
// first verification retires nothing.
if p.EmailVerified && !strings.EqualFold(p.Email, email) {
a.revokeOtherSessionsAfter(r, "email change")
a.notifyEmailChanged(r, p.Email, email)
}
writeJSON(w, http.StatusOK, map[string]any{"verified": true, "email": email})
}
@@ -320,6 +328,11 @@ func (a *API) handleSetEmail(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
// Recording an address unverifies the current one, which would strip the
// account's email factor and with it the reauth that guards adding a passkey.
if !a.requireReauth(w, r, p) {
return
}
if err := a.Repo.SetUserEmail(r.Context(), p.UserID, email); err != nil {
writeError(w, r, err)
return
+1 -1
View File
@@ -125,7 +125,7 @@ func (a *API) handleBindRedeem(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.startSession(w, r, userID); err != nil {
if err := a.startSession(w, r, userID, bindCodeSignIn); err != nil {
writeError(w, r, err)
return
}
+1 -1
View File
@@ -326,7 +326,7 @@ func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusForbidden, "staff_account", "that account is not an operator"))
return
}
if err := a.startSession(w, r, u.ID); err != nil {
if err := a.startSession(w, r, u.ID, provenSignIn); err != nil {
writeError(w, r, err)
return
}
+14 -1
View File
@@ -253,6 +253,10 @@ func (a *API) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request)
return
}
p := principalFromContext(r.Context())
// Gate the begin: the finish only consumes the challenge minted here.
if !a.requireReauth(w, r, p) {
return
}
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
if err != nil {
writeError(w, r, err)
@@ -356,6 +360,11 @@ func (a *API) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request
return
}
a.audit(r, "account.passkey.registered", cred.ID)
// The session just showed an authenticator now bound to the account, the
// same strength as a passkey reauth, so the next guarded step of a first-time
// setup (verifying an email) runs without asking again.
a.markReauthQuietly(r)
a.notifyPasskeyAdded(r, p)
writeJSON(w, http.StatusCreated, passkeyView(cred))
}
@@ -409,6 +418,9 @@ func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "credential id is required"))
return
}
if !a.requireReauth(w, r, p) {
return
}
if err := a.Repo.DeletePasskeyCredential(r.Context(), p.UserID, id); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusNotFound, "not_found", "no such passkey"))
@@ -424,6 +436,7 @@ func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) {
}
a.audit(r, "account.passkey.removed", id)
a.revokeOtherSessionsAfter(r, "passkey removal")
a.notifyPasskeyRemoved(r, p)
w.WriteHeader(http.StatusNoContent)
}
@@ -654,7 +667,7 @@ func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.startSession(w, r, u.ID); err != nil {
if err := a.startSession(w, r, u.ID, provenSignIn); err != nil {
writeError(w, r, err)
return
}
@@ -197,7 +197,7 @@ func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *htt
return
}
if err := a.startSession(w, r, resolved.ID); err != nil {
if err := a.startSession(w, r, resolved.ID, provenSignIn); err != nil {
writeError(w, r, err)
return
}
+1 -1
View File
@@ -81,7 +81,7 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
// Mint the session — a regular felis_session; the lockdown is a product-level
// restriction the frontend enforces until email is verified / a passkey is bound.
if err := a.startSession(w, r, u.ID); err != nil {
if err := a.startSession(w, r, u.ID, provenSignIn); err != nil {
writeError(w, r, err)
return
}
+1 -1
View File
@@ -83,7 +83,7 @@ func TestSetEmailClearsVerified(t *testing.T) {
repo.staff["u"] = &StaffUser{ID: "u1", Username: "u", Role: "admin", Email: "[email protected]", EmailVerified: true}
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: &Principal{UserID: "u1", Role: "admin", ViaSession: true, EmailVerified: true}}
api.External = staticExternal{p: &Principal{UserID: "u1", Role: "admin", ViaSession: true, EmailVerified: true, ReauthAt: frozenNow}}
h := api.ExternalHandler()
w := do(h, "POST", "/api/v1/account/email", `{"email":"[email protected]"}`, jsonHeader)
+18 -5
View File
@@ -1114,10 +1114,11 @@ func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email string)
// CreateSession records a minted session by the sha-256 of its cookie value
// (spec §B). Only the hash is stored, mirroring tokens.
func (p *PGRepo) CreateSession(ctx context.Context, s NewSession) error {
reauth := sql.NullTime{Time: s.ReauthAt, Valid: !s.ReauthAt.IsZero()}
_, err := p.db.ExecContext(ctx,
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip)
VALUES ($1, $2, $3, $4, $5)`,
s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP)
`INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip, reauth_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP, reauth)
return err
}
@@ -1136,17 +1137,21 @@ const sessionLive = `s.revoked_at IS NULL AND s.expires_at > $2
// SessionUser resolves a live session hash to its user, or ErrNotFound.
func (p *PGRepo) SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error) {
const q = `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text, COALESCE(u.email_verified, false),
s.last_seen_at
s.last_seen_at, s.reauth_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND ` + sessionLive
var u SessionedUser
var reauth sql.NullTime
switch err := p.db.QueryRowContext(ctx, q, tokenHash, now, now.Add(-staffSessionIdle)).Scan(
&u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified, &u.LastSeenAt); {
&u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified, &u.LastSeenAt, &reauth); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
case err != nil:
return nil, err
}
if reauth.Valid {
u.ReauthAt = reauth.Time
}
return &u, nil
}
@@ -1158,6 +1163,14 @@ func (p *PGRepo) TouchSession(ctx context.Context, tokenHash string, now time.Ti
return err
}
// MarkSessionReauth records a proven factor on a live session.
func (p *PGRepo) MarkSessionReauth(ctx context.Context, tokenHash string, at time.Time) error {
_, err := p.db.ExecContext(ctx,
`UPDATE sessions SET reauth_at = $2 WHERE token_hash = $1 AND revoked_at IS NULL`,
tokenHash, at)
return err
}
// RevokeSession marks a session revoked (logout). Idempotent: a missing or
// already-revoked session is not an error.
func (p *PGRepo) RevokeSession(ctx context.Context, tokenHash string) error {
+416
View File
@@ -0,0 +1,416 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"log"
"net/http"
"strings"
"time"
)
// Reauth (step-up) guards the changes that plant or remove a lasting way into an
// account: adding or removing a passkey and changing the email. Holding the
// session is not enough for them once the account has a factor of its own; the
// holder must have proven one within reauthWindow. Otherwise a stolen cookie
// (XSS, a shared machine) could register the thief's passkey and keep the
// account long after the session ends, and for staff that passkey would skip
// op-login's in-game approval for good.
//
// What proves a factor, and so marks the session (sessions.reauth_at):
//
// - signing in by passkey, by email code, through op-login or with the setup
// token (startSession with provenSignIn);
// - a passkey assertion or an email code on the reauth endpoints below;
// - verifying an email address by code (the address is proven that moment,
// and reaching that step already passed this gate when the account had a
// factor to protect).
//
// A bind-code sign-in proves only the in-game identity and marks nothing: whoever
// controls the Minecraft account must still show the account's passkey or mailbox
// before touching them.
//
// Staff reauth with a passkey or by signing in again through op-login. An email
// code alone is not a staff factor, because signing in as staff by email also
// takes in-game approval.
const (
// reauthWindow is how long a proven factor lets the session make guarded
// changes. Long enough to finish a passkey ceremony or an email change.
reauthWindow = 5 * time.Minute
otpPurposeReauth = "reauth"
passkeyPurposeReauth = "passkey_reauth"
reauthFactorPasskey = "passkey"
reauthFactorEmail = "email"
// reauthFactorSignIn: sign out and back in through a proving door.
reauthFactorSignIn = "sign_in"
)
// reauthState is where the caller stands with the guarded changes.
type reauthState struct {
// Needed: a guarded change would be refused until the caller reauths.
Needed bool `json:"needed"`
// Until is when the current proof stops counting; absent when there is none
// or the account has nothing to guard.
Until *time.Time `json:"until,omitempty"`
// Factors are the ways this caller can reauth, best first.
Factors []string `json:"factors"`
}
func (a *API) reauthState(r *http.Request, p *Principal) (reauthState, error) {
st := reauthState{Factors: []string{}}
if !p.ViaSession {
// A Cloudflare Access caller is authenticated by the proxy on every
// request and has no session here to mark.
return st, nil
}
hasPasskey, err := a.userHasPasskey(r.Context(), p.UserID)
if err != nil {
return st, err
}
if hasPasskey {
st.Factors = append(st.Factors, reauthFactorPasskey)
}
if staffRole(p.Role) {
st.Factors = append(st.Factors, reauthFactorSignIn)
} else if p.EmailVerified {
st.Factors = append(st.Factors, reauthFactorEmail)
}
if !hasPasskey && !p.EmailVerified {
// Nothing to protect yet: the session is the account's only way in.
return st, nil
}
if until := p.ReauthAt.Add(reauthWindow); !p.ReauthAt.IsZero() && a.now().Before(until) {
until = until.UTC()
st.Until = &until
return st, nil
}
st.Needed = true
return st, nil
}
// requireReauth lets a guarded change through, or answers 403 reauth_required
// and returns false.
func (a *API) requireReauth(w http.ResponseWriter, r *http.Request, p *Principal) bool {
st, err := a.reauthState(r, p)
if err != nil {
writeError(w, r, err)
return false
}
if st.Needed {
writeError(w, r, newError(http.StatusForbidden, "reauth_required",
"confirm it's you first: this change needs your passkey or email code from the last few minutes"))
return false
}
return true
}
// markReauth records the proof on the caller's session and answers with the new
// window.
func (a *API) markReauth(w http.ResponseWriter, r *http.Request, p *Principal, factor string) {
now := a.now()
if err := a.Repo.MarkSessionReauth(r.Context(), currentSessionHash(r), now); err != nil {
writeError(w, r, err)
return
}
a.audit(r, "account.reauth", factor)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "until": now.Add(reauthWindow).UTC()})
}
// markReauthQuietly records a proof that happened as part of another change
// (a passkey registration, an email verification). The change already went
// through, so a failure here is logged and the next guarded change just asks.
func (a *API) markReauthQuietly(r *http.Request) {
hash := currentSessionHash(r)
if hash == "" {
return
}
if err := a.Repo.MarkSessionReauth(r.Context(), hash, a.now()); err != nil {
log.Printf("auth: could not record reauth on the session (request_id=%s): %v",
requestIDFromContext(r.Context()), err)
}
}
// handleReauthStatus reports whether a guarded change needs a reauth first and
// which factors can provide it, so the panel can ask before starting one.
func (a *API) handleReauthStatus(w http.ResponseWriter, r *http.Request) {
st, err := a.reauthState(r, principalFromContext(r.Context()))
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, st)
}
// requireReauthSession refuses the reauth endpoints to a caller with no session
// to mark.
func requireReauthSession(w http.ResponseWriter, r *http.Request, p *Principal) bool {
if !p.ViaSession || currentSessionHash(r) == "" {
writeError(w, r, newError(http.StatusBadRequest, "no_session",
"only a signed-in browser session can confirm it's you"))
return false
}
return true
}
func (a *API) handleReauthPasskeyBegin(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
if a.Passkey == nil {
writeError(w, r, errPasskeyUnavailable)
return
}
if !requireReauthSession(w, r, p) {
return
}
a.beginStepUpPasskey(w, r, p, passkeyPurposeReauth,
"no passkey enrolled; confirm with an email code instead")
}
func (a *API) handleReauthPasskeyFinish(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
if a.Passkey == nil {
writeError(w, r, errPasskeyUnavailable)
return
}
var req stepUpPasskeyFinishRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
if len(req.Assertion) == 0 {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "assertion is required"))
return
}
if !requireReauthSession(w, r, p) {
return
}
if !a.finishStepUpPasskey(w, r, p, passkeyPurposeReauth, "reauth_passkey", req.Assertion) {
return
}
a.markReauth(w, r, p, reauthFactorPasskey)
}
// requireEmailReauth admits a player with a verified address to the email-code
// reauth; staff confirm with a passkey or by signing in again.
func requireEmailReauth(w http.ResponseWriter, r *http.Request, p *Principal) bool {
if staffRole(p.Role) {
writeError(w, r, newError(http.StatusForbidden, "staff_reauth",
"operators confirm with a passkey or by signing in again"))
return false
}
if !p.EmailVerified || p.Email == "" {
writeError(w, r, newError(http.StatusConflict, "no_step_up_factor",
"there is no verified email on this account to send a code to"))
return false
}
return true
}
func (a *API) handleReauthEmailStart(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
if !requireReauthSession(w, r, p) || !requireEmailReauth(w, r, p) {
return
}
a.startStepUpOTP(w, r, p, otpPurposeReauth, "reauth:", "account.reauth.otp_sent")
}
func (a *API) handleReauthEmailVerify(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
var req stepUpOTPVerifyRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
code := strings.TrimSpace(req.Code)
if code == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
return
}
if !requireReauthSession(w, r, p) || !requireEmailReauth(w, r, p) {
return
}
if !a.verifyStepUpOTP(w, r, p, otpPurposeReauth, "reauth_email", code) {
return
}
a.markReauth(w, r, p, reauthFactorEmail)
}
// ---- step-up ceremonies shared by reauth and the migration confirm ----
// stepUpPasskeyFinishRequest is the assertion the browser produced, captured as
// raw bytes so the exact response reaches the verifier without re-encoding.
type stepUpPasskeyFinishRequest struct {
Assertion json.RawMessage `json:"assertion"`
}
// stepUpOTPVerifyRequest is the code from the step-up email.
type stepUpOTPVerifyRequest struct {
Code string `json:"code"`
}
// stepUpPasskeyUser builds the PasskeyUser the assertion ceremony needs for the
// already signed-in caller (contrast the login door, which resolves it from a
// typed email). The credential set must be identical between begin and finish.
func stepUpPasskeyUser(p *Principal, creds []PasskeyCredential) PasskeyUser {
name := p.Email
if name == "" {
name = p.UserID
}
return PasskeyUser{ID: p.UserID, Name: name, DisplayName: name, Credentials: creds}
}
// beginStepUpPasskey starts an assertion over the caller's own passkeys, its
// challenge stashed under purpose, and writes the options (go-webauthn's
// {"publicKey": {...}} document). The caller has checked a.Passkey.
func (a *API) beginStepUpPasskey(w http.ResponseWriter, r *http.Request, p *Principal, purpose, noPasskey string) {
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
if err != nil {
writeError(w, r, err)
return
}
if len(creds) == 0 {
writeError(w, r, newError(http.StatusBadRequest, "no_passkey", "%s", noPasskey))
return
}
options, sessionData, err := a.Passkey.BeginLogin(stepUpPasskeyUser(p, creds))
if err != nil {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed",
"could not start passkey confirmation"))
return
}
id, err := newPasskeyID()
if err != nil {
writeError(w, r, err)
return
}
expiresAt := a.now().Add(passkeyChallengeTTL)
if err := a.Repo.CreatePasskeyChallenge(r.Context(), id, p.UserID, purpose, sessionData, expiresAt); err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, options)
}
// finishStepUpPasskey consumes the purpose's stashed challenge and verifies the
// assertion against the caller's passkeys. It reports whether the caller passed;
// on false the error is written. door names the failure in metrics and audit.
// The caller has checked a.Passkey and that the assertion is present.
func (a *API) finishStepUpPasskey(w http.ResponseWriter, r *http.Request, p *Principal, purpose, door string, assertion json.RawMessage) bool {
invalid := func() bool {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey confirmation could not be completed; begin again"))
return false
}
sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), p.UserID, purpose, a.now())
if err != nil {
if errors.Is(err, ErrPasskeyChallengeInvalid) {
a.authFailure(r, door, "challenge_invalid", nil)
return invalid()
}
writeError(w, r, err)
return false
}
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
if err != nil {
writeError(w, r, err)
return false
}
va, err := a.Passkey.FinishLogin(stepUpPasskeyUser(p, creds), sessionData, bytes.NewReader(assertion))
if err != nil {
a.authFailure(r, door, "bad_assertion", nil)
return invalid()
}
// Same clone policy as the login door (applyAssertionCounter): a rolled-back
// counter fails closed with the opaque envelope, so a step-up never accepts an
// authenticator that login refuses. A clean assertion advances the stored
// sign-count, keeping the clone signal meaningful for the next login.
if err := a.applyAssertionCounter(r.Context(), va); err != nil {
if errors.Is(err, errPasskeyClonedAuthenticator) {
a.passkeyCloneRejected(r, door, nil, va.CredentialID)
return invalid()
}
writeError(w, r, err)
return false
}
return true
}
// startStepUpOTP mails a fresh code under purpose to the caller's (verified)
// address and answers 202. keyPrefix namespaces the per-mailbox resend cooldown
// so the step-up doors never perturb each other's throttle.
func (a *API) startStepUpOTP(w http.ResponseWriter, r *http.Request, p *Principal, purpose, keyPrefix, auditAction string) {
if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, purpose, a.now()); err != nil {
writeError(w, r, err)
return
} else if !until.IsZero() {
writeOTPAccountLocked(w, r, until, a.now())
return
}
emailKey := keyPrefix + strings.ToLower(p.Email)
lim := a.otpLimiter()
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
"a code was sent recently; wait a moment before requesting another"))
return
}
committed := false
defer func() {
if !committed {
lim.release(emailKey, emailAt)
}
}()
code, err := newEmailOTP()
if err != nil {
writeError(w, r, err)
return
}
id, err := newOTPID()
if err != nil {
writeError(w, r, err)
return
}
expiresAt := a.now().Add(otpTTL)
if err := a.Repo.CreateEmailOTP(r.Context(), id, p.UserID, p.Email, otpCodeHash(code), purpose, expiresAt); err != nil {
writeError(w, r, err)
return
}
if err := a.deliverOTP(r.Context(), p.Email, code); err != nil {
writeError(w, r, err)
return
}
committed = true
a.audit(r, auditAction, "")
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()})
}
// verifyStepUpOTP redeems a step-up code. The lifecycle is the login door's (no
// identity side effect): the address is already proven. It reports whether the
// code matched; on false the error is written.
func (a *API) verifyStepUpOTP(w http.ResponseWriter, r *http.Request, p *Principal, purpose, door, code string) bool {
var lock *OTPAccountLockedError
err := a.Repo.ConsumeLoginEmailOTP(r.Context(), p.UserID, purpose, otpCodeHash(code), a.now())
if isOTPRefusal(err) {
a.authFailure(r, door, otpFailureReason(err), nil)
}
switch {
case errors.As(err, &lock):
a.noteOTPLock(r, err, p.UserID, purpose)
writeOTPAccountLocked(w, r, lock.Until, a.now())
return false
case errors.Is(err, ErrOTPLocked):
writeError(w, r, newError(http.StatusTooManyRequests, "otp_locked",
"too many incorrect attempts; request a new code"))
return false
case errors.Is(err, ErrOTPInvalid):
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired"))
return false
case err != nil:
writeError(w, r, err)
return false
}
return true
}
+557
View File
@@ -0,0 +1,557 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"time"
)
// Reauth: once an account has a passkey or a verified email, adding or removing a
// passkey and changing the email need a factor proven within reauthWindow. These
// tests drive the real SessionAuth, so the proof is read from the session row the
// cookie names, exactly as in production.
const opTok = "tok-op"
// reauthFixture is the sessions fixture (steve: a player with a verified email,
// signed in on the laptop and the phone; alex: a player with no factor) plus a
// passkey verifier and an operator, pam, with a verified email. Every session
// starts with no proof on it.
func reauthFixture(t *testing.T) *sessionsFixture {
t.Helper()
f := newSessionsFixture(t)
f.api.Passkey = &fakePasskeyVerifier{}
f.repo.staff["pam"] = &StaffUser{ID: "u3", Username: "pam", Email: "[email protected]", Role: "admin", EmailVerified: true}
now := f.api.now()
f.repo.sessions[hashCookie(opTok)] = &fakeSession{userID: "u3", lastSeen: now, expiresAt: now.Add(time.Hour)}
for _, s := range f.repo.sessions {
s.reauthAt = time.Time{}
}
f.eh = f.api.ExternalHandler()
return f
}
func (f *sessionsFixture) reauthAt(tok string) time.Time {
return f.repo.sessions[hashCookie(tok)].reauthAt
}
func (f *sessionsFixture) setReauth(tok string, at time.Time) {
f.repo.sessions[hashCookie(tok)].reauthAt = at
}
func jsonCookie(tok string) map[string]string {
h := asCookie(tok)
h["Content-Type"] = "application/json"
return h
}
func TestSigningInByEmailCodeCountsAsReauth(t *testing.T) {
api, repo, mailer := seedLoginEmailAPI(t)
eh := api.ExternalHandler()
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"`+mailer.code+`"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
s := repo.sessions[hashCookie(sessionCookieValue(t, w))]
if !s.reauthAt.Equal(api.now()) {
t.Fatalf("reauth_at = %v, want the sign-in time %v", s.reauthAt, api.now())
}
}
// A bind code proves the Minecraft account, and nothing about the account's own
// passkey or mailbox.
func TestSigningInByBindCodeIsNoReauth(t *testing.T) {
api, repo := seedBindAPI(t)
mintBindCode(t, api, repo, "ABCD2345", bindTestUUID, authSourceMojang)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/bind", `{"code":"ABCD2345"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("bind = %d (%s)", w.Code, w.Body.String())
}
if s := repo.sessions[hashCookie(sessionCookieValue(t, w))]; !s.reauthAt.IsZero() {
t.Fatalf("reauth_at = %v, want none after a bind-code sign-in", s.reauthAt)
}
}
// guardedChange is a request the gate covers, the status it gets once the gate
// lets it through, and a check that it changed nothing when refused.
type guardedChange struct {
name, method, path, body string
ok int
untouched func(f *sessionsFixture) bool
}
var guardedChanges = []guardedChange{
{"add a passkey", "POST", "/api/v1/account/passkey/register/begin", "", http.StatusOK,
func(f *sessionsFixture) bool { return len(f.repo.passkeyChallenges) == 0 }},
{"remove a passkey", "DELETE", "/api/v1/account/passkey/credentials/a", "", http.StatusNoContent,
func(f *sessionsFixture) bool { _, ok := f.repo.passkeyCreds["a"]; return ok }},
{"change the email", "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, http.StatusAccepted,
func(f *sessionsFixture) bool { return len(f.repo.otps) == 0 }},
{"record an unverified email", "POST", "/api/v1/account/email", `{"email":"[email protected]"}`, http.StatusOK,
func(f *sessionsFixture) bool { return f.repo.staff["steve"].EmailVerified }},
}
func TestGuardedChangesNeedARecentReauth(t *testing.T) {
for _, tc := range []struct {
name string
proof time.Duration // how long ago the session proved a factor; -1 = never
wantOK bool
}{
{"never proved", -1, false},
{"proved 6 minutes ago", 6 * time.Minute, false},
{"proved exactly 5 minutes ago", 5 * time.Minute, false},
{"proved 4m59s ago", 5*time.Minute - time.Second, true},
{"proved just now", 0, true},
} {
for _, g := range guardedChanges {
t.Run(tc.name+"/"+g.name, func(t *testing.T) {
f := reauthFixture(t)
f.api.Mailer = &captureMailer{}
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
if tc.proof >= 0 {
f.setReauth(laptopTok, f.api.now().Add(-tc.proof))
}
w := do(f.eh, g.method, g.path, g.body, jsonCookie(laptopTok))
if tc.wantOK {
if w.Code != g.ok {
t.Fatalf("%s = %d (%s), want %d", g.name, w.Code, w.Body.String(), g.ok)
}
return
}
if w.Code != http.StatusForbidden || decodeErr(t, w) != "reauth_required" {
t.Fatalf("%s = %d (%s), want 403 reauth_required", g.name, w.Code, w.Body.String())
}
if !g.untouched(f) {
t.Fatalf("%s went through despite the refusal", g.name)
}
})
}
}
}
// With no passkey and no verified email the session is the account's only way
// in, so there is nothing a reauth could protect and nothing to give one with.
func TestAccountWithoutAFactorNeedsNoReauth(t *testing.T) {
f := reauthFixture(t)
f.api.Mailer = &captureMailer{}
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok)); w.Code != http.StatusOK {
t.Fatalf("register begin = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, jsonCookie(alexTok)); w.Code != http.StatusAccepted {
t.Fatalf("email start = %d (%s)", w.Code, w.Body.String())
}
}
// An unverified address is no factor: it never received a code.
func TestUnverifiedEmailIsNoFactor(t *testing.T) {
f := reauthFixture(t)
f.repo.staff["alex"].Email = "[email protected]"
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok)); w.Code != http.StatusOK {
t.Fatalf("register begin = %d (%s)", w.Code, w.Body.String())
}
}
// A passkey alone is a factor worth guarding.
func TestPasskeyAloneNeedsReauth(t *testing.T) {
f := reauthFixture(t)
f.repo.passkeyCreds["x"] = PasskeyCredential{ID: "x", UserID: "u2", CredentialID: "c-x", CreatedAt: frozenNow}
w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok))
if w.Code != http.StatusForbidden || decodeErr(t, w) != "reauth_required" {
t.Fatalf("register begin = %d (%s), want 403 reauth_required", w.Code, w.Body.String())
}
}
// A Cloudflare Access caller is authenticated by the proxy on every request and
// has no session to mark.
func TestAccessCallerNeedsNoReauth(t *testing.T) {
repo := newFakeRepo()
repo.staff["op"] = &StaffUser{ID: "u1", Username: "op", Role: "admin", Email: "[email protected]", EmailVerified: true}
repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
api := newTestAPI(repo, newFakeCluster())
api.Passkey = &fakePasskeyVerifier{}
api.External = staticExternal{p: &Principal{UserID: "u1", Email: "[email protected]", Role: "admin", EmailVerified: true}}
if w := do(api.ExternalHandler(), "POST", "/api/v1/account/passkey/register/begin", "", nil); w.Code != http.StatusOK {
t.Fatalf("register begin = %d (%s)", w.Code, w.Body.String())
}
}
type reauthStatusBody struct {
Needed bool `json:"needed"`
Until *time.Time `json:"until"`
Factors []string `json:"factors"`
}
func getReauthStatus(t *testing.T, f *sessionsFixture, tok string) reauthStatusBody {
t.Helper()
w := do(f.eh, "GET", "/api/v1/account/reauth", "", asCookie(tok))
if w.Code != http.StatusOK {
t.Fatalf("status = %d (%s)", w.Code, w.Body.String())
}
var b reauthStatusBody
if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil {
t.Fatal(err)
}
return b
}
func TestReauthStatusNamesTheFactors(t *testing.T) {
f := reauthFixture(t)
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
f.repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u3", CredentialID: "c-p", CreatedAt: frozenNow}
steve := getReauthStatus(t, f, laptopTok)
if !steve.Needed || steve.Until != nil || strings.Join(steve.Factors, ",") != "passkey,email" {
t.Fatalf("player status = %+v, want needed with passkey,email", steve)
}
// An operator's verified email is no factor: signing in as staff by email
// also takes in-game approval.
pam := getReauthStatus(t, f, opTok)
if !pam.Needed || strings.Join(pam.Factors, ",") != "passkey,sign_in" {
t.Fatalf("operator status = %+v, want needed with passkey,sign_in", pam)
}
alex := getReauthStatus(t, f, alexTok)
if alex.Needed || len(alex.Factors) != 0 {
t.Fatalf("no-factor status = %+v, want not needed and no factors", alex)
}
proved := f.api.now().Add(-time.Minute)
f.setReauth(laptopTok, proved)
steve = getReauthStatus(t, f, laptopTok)
if steve.Needed || steve.Until == nil || !steve.Until.Equal(proved.Add(reauthWindow)) {
t.Fatalf("after a proof status = %+v, want not needed until %v", steve, proved.Add(reauthWindow))
}
}
func TestReauthByEmailCode(t *testing.T) {
f := reauthFixture(t)
mailer := &captureMailer{}
f.api.Mailer = mailer
if w := do(f.eh, "POST", "/api/v1/account/reauth/email/start", "", asCookie(laptopTok)); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
if mailer.email != "[email protected]" || mailer.code == "" {
t.Fatalf("code went to %q (%q), want [email protected]", mailer.email, mailer.code)
}
wrong := "000000"
if mailer.code == wrong {
wrong = "111111"
}
w := do(f.eh, "POST", "/api/v1/account/reauth/email/verify", `{"code":"`+wrong+`"}`, jsonCookie(laptopTok))
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("wrong code = %d (%s), want 400 invalid_code", w.Code, w.Body.String())
}
if !f.reauthAt(laptopTok).IsZero() {
t.Fatal("a wrong code marked the session")
}
w = do(f.eh, "POST", "/api/v1/account/reauth/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(laptopTok))
if w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
var body struct {
OK bool `json:"ok"`
Until time.Time `json:"until"`
}
_ = json.Unmarshal(w.Body.Bytes(), &body)
if !body.OK || !body.Until.Equal(f.api.now().Add(reauthWindow)) {
t.Fatalf("verify body = %s, want ok until now+5m", w.Body.String())
}
if !f.reauthAt(laptopTok).Equal(f.api.now()) {
t.Fatalf("laptop reauth_at = %v, want now", f.reauthAt(laptopTok))
}
// The proof belongs to the session that gave it.
if !f.reauthAt(phoneTok).IsZero() {
t.Fatal("the phone was marked by the laptop's code")
}
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK {
t.Fatalf("register begin after reauth = %d (%s)", w.Code, w.Body.String())
}
var audited bool
for _, e := range f.repo.audits {
audited = audited || (e.Action == "account.reauth" && e.ServerName == "email")
}
if !audited {
t.Fatalf("no account.reauth audit naming the email factor: %+v", f.repo.audits)
}
}
// A code from another step-up (the email change, a migration) does not reauth.
func TestReauthCodeIsItsOwnPurpose(t *testing.T) {
f := reauthFixture(t)
mailer := &captureMailer{}
f.api.Mailer = mailer
f.setReauth(laptopTok, f.api.now())
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, jsonCookie(laptopTok)); w.Code != http.StatusAccepted {
t.Fatalf("email start = %d (%s)", w.Code, w.Body.String())
}
w := do(f.eh, "POST", "/api/v1/account/reauth/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(phoneTok))
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("onboarding code on the reauth door = %d (%s), want 400 invalid_code", w.Code, w.Body.String())
}
}
func TestOperatorsCannotReauthByEmail(t *testing.T) {
f := reauthFixture(t)
mailer := &captureMailer{}
f.api.Mailer = mailer
for _, path := range []string{"/api/v1/account/reauth/email/start", "/api/v1/account/reauth/email/verify"} {
w := do(f.eh, "POST", path, `{"code":"123456"}`, jsonCookie(opTok))
if w.Code != http.StatusForbidden || decodeErr(t, w) != "staff_reauth" {
t.Fatalf("%s = %d (%s), want 403 staff_reauth", path, w.Code, w.Body.String())
}
}
if mailer.calls != 0 {
t.Fatal("a code was mailed to an operator")
}
}
func TestReauthByEmailNeedsAVerifiedAddress(t *testing.T) {
f := reauthFixture(t)
f.api.Mailer = &captureMailer{}
f.repo.staff["alex"].Email = "[email protected]"
w := do(f.eh, "POST", "/api/v1/account/reauth/email/start", "", asCookie(alexTok))
if w.Code != http.StatusConflict || decodeErr(t, w) != "no_step_up_factor" {
t.Fatalf("start = %d (%s), want 409 no_step_up_factor", w.Code, w.Body.String())
}
}
func TestReauthByPasskey(t *testing.T) {
f := reauthFixture(t)
pv := f.api.Passkey.(*fakePasskeyVerifier)
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", SignCount: 4, CreatedAt: frozenNow}
finish := func() int {
t.Helper()
if w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK {
t.Fatalf("begin = %d (%s)", w.Code, w.Body.String())
}
if len(pv.lastUser.Credentials) != 1 || pv.lastUser.Credentials[0].CredentialID != "c-a" {
t.Fatalf("assertion offered %+v, want the caller's own passkey", pv.lastUser.Credentials)
}
return do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"c-a"}}`, jsonCookie(laptopTok)).Code
}
pv.failErr = errors.New("assertion rejected")
if code := finish(); code != http.StatusBadRequest {
t.Fatalf("bad assertion = %d, want 400", code)
}
pv.failErr = nil
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 2, CloneWarning: true}
if code := finish(); code != http.StatusBadRequest {
t.Fatalf("cloned authenticator = %d, want 400", code)
}
if !f.reauthAt(laptopTok).IsZero() {
t.Fatal("a failed assertion marked the session")
}
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 5}
if code := finish(); code != http.StatusOK {
t.Fatalf("finish = %d, want 200", code)
}
if !f.reauthAt(laptopTok).Equal(f.api.now()) {
t.Fatalf("reauth_at = %v, want now", f.reauthAt(laptopTok))
}
if got := f.repo.passkeyCreds["a"].SignCount; got != 5 {
t.Fatalf("sign count = %d, want it advanced to 5", got)
}
// The challenge was spent: a replayed finish has nothing to consume.
w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"c-a"}}`, jsonCookie(laptopTok))
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
t.Fatalf("replayed finish = %d (%s), want 400 passkey_login_invalid", w.Code, w.Body.String())
}
}
// A migration's passkey challenge cannot be spent on a reauth, or the reverse.
func TestReauthPasskeyChallengeIsItsOwnPurpose(t *testing.T) {
f := reauthFixture(t)
pv := f.api.Passkey.(*fakePasskeyVerifier)
pv.assertion = VerifiedAssertion{CredentialID: "c-a", SignCount: 5}
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
if err := f.repo.CreatePasskeyChallenge(t.Context(), "m1", "u1", passkeyPurposeMigrate, []byte("s"), f.api.now().Add(time.Minute)); err != nil {
t.Fatal(err)
}
w := do(f.eh, "POST", "/api/v1/account/reauth/passkey/finish", `{"assertion":{"id":"c-a"}}`, jsonCookie(laptopTok))
if w.Code != http.StatusBadRequest {
t.Fatalf("finish on a migration challenge = %d (%s), want 400", w.Code, w.Body.String())
}
}
// Proving an address by code is an email reauth, so a first-time setup can go on
// to its next guarded step.
func TestVerifyingAnEmailCountsAsReauth(t *testing.T) {
f := reauthFixture(t)
mailer := &captureMailer{}
f.api.Mailer = mailer
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, jsonCookie(alexTok)); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(alexTok)); w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
if !f.reauthAt(alexTok).Equal(f.api.now()) {
t.Fatalf("reauth_at = %v, want now", f.reauthAt(alexTok))
}
}
func TestRegisteringAPasskeyCountsAsReauth(t *testing.T) {
f := reauthFixture(t)
f.api.Passkey.(*fakePasskeyVerifier).credential = VerifiedCredential{CredentialID: "c-new", PublicKey: "pk"}
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(alexTok)); w.Code != http.StatusOK {
t.Fatalf("begin = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/finish", `{"attestation":{"id":"x"}}`, jsonCookie(alexTok)); w.Code != http.StatusCreated {
t.Fatalf("finish = %d (%s)", w.Code, w.Body.String())
}
if !f.reauthAt(alexTok).Equal(f.api.now()) {
t.Fatalf("reauth_at = %v, want now", f.reauthAt(alexTok))
}
}
// ---- change notices ----
func noticeFixture(t *testing.T) (*sessionsFixture, *noticeMailer) {
t.Helper()
f := reauthFixture(t)
mailer := &noticeMailer{}
f.api.Mailer = mailer
f.api.ClientIPHeader = "CF-Connecting-IP"
f.setReauth(laptopTok, f.api.now())
return f, mailer
}
func fromIP(h map[string]string) map[string]string {
h["CF-Connecting-IP"] = "203.0.113.9"
return h
}
func onlyNotice(t *testing.T, m *noticeMailer) (to, subject, body string) {
t.Helper()
if len(m.notices) != 1 {
t.Fatalf("notices = %q, want exactly one", m.notices)
}
parts := strings.SplitN(m.notices[0], "|", 3)
return parts[0], parts[1], parts[2]
}
func TestRemovingAPasskeyMailsTheAccount(t *testing.T) {
f, mailer := noticeFixture(t)
f.repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "u1", CredentialID: "c-a", CreatedAt: frozenNow}
if w := do(f.eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", fromIP(asCookie(laptopTok))); w.Code != http.StatusNoContent {
t.Fatalf("delete = %d (%s)", w.Code, w.Body.String())
}
to, subject, body := onlyNotice(t, mailer)
if to != "[email protected]" || subject != "Felis 已删除 Passkey · passkey removed" {
t.Fatalf("notice to %q subject %q", to, subject)
}
for _, want := range []string{
"A passkey was just removed from your Felis account, and every other device was signed out.",
"Time: 2023-11-14 22:13 UTC",
"From IP: 203.0.113.9",
"check the passkeys that remain",
"来源 IP:203.0.113.9",
} {
if !strings.Contains(body, want) {
t.Errorf("notice body lacks %q:\n%s", want, body)
}
}
}
func TestAddingAPasskeyMailsTheAccount(t *testing.T) {
f, mailer := noticeFixture(t)
f.api.Passkey.(*fakePasskeyVerifier).credential = VerifiedCredential{CredentialID: "c-new", PublicKey: "pk"}
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/begin", "", asCookie(laptopTok)); w.Code != http.StatusOK {
t.Fatalf("begin = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/passkey/register/finish", `{"attestation":{"id":"x"}}`, fromIP(jsonCookie(laptopTok))); w.Code != http.StatusCreated {
t.Fatalf("finish = %d (%s)", w.Code, w.Body.String())
}
to, subject, body := onlyNotice(t, mailer)
if to != "[email protected]" || subject != "Felis 已添加 Passkey · passkey added" ||
!strings.Contains(body, "A passkey was just added to your Felis account.") ||
!strings.Contains(body, "remove that passkey") {
t.Fatalf("notice to %q subject %q body:\n%s", to, subject, body)
}
}
// Replacing the address mails the OLD one, which is the mailbox the owner still
// reads if someone else made the change. The new address is masked.
func TestChangingTheEmailMailsTheOldAddress(t *testing.T) {
f, mailer := noticeFixture(t)
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, jsonCookie(laptopTok)); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, fromIP(jsonCookie(laptopTok))); w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
to, subject, body := onlyNotice(t, mailer)
if to != "[email protected]" || subject != "Felis 邮箱已更换 · email changed" {
t.Fatalf("notice to %q subject %q", to, subject)
}
if !strings.Contains(body, "The email on your Felis account was just changed to s***@new.example.") ||
!strings.Contains(body, "From IP: 203.0.113.9") {
t.Fatalf("notice body:\n%s", body)
}
if strings.Contains(body, "[email protected]") {
t.Fatalf("notice hands the new address to the old mailbox:\n%s", body)
}
}
// A first verification and a re-verification of the same address move nothing.
func TestVerifyingAFirstOrSameEmailMailsNoNotice(t *testing.T) {
for _, tc := range []struct {
name, tok, address string
}{
{"first address", alexTok, "[email protected]"},
{"same address", laptopTok, "[email protected]"},
} {
t.Run(tc.name, func(t *testing.T) {
f, mailer := noticeFixture(t)
if w := do(f.eh, "POST", "/api/v1/account/email/start", `{"email":"`+tc.address+`"}`, jsonCookie(tc.tok)); w.Code != http.StatusAccepted {
t.Fatalf("start = %d (%s)", w.Code, w.Body.String())
}
if w := do(f.eh, "POST", "/api/v1/account/email/verify", `{"code":"`+mailer.code+`"}`, jsonCookie(tc.tok)); w.Code != http.StatusOK {
t.Fatalf("verify = %d (%s)", w.Code, w.Body.String())
}
if len(mailer.notices) != 0 {
t.Fatalf("notices = %q, want none", mailer.notices)
}
})
}
}
// Nothing proves an unverified address belongs to the owner, so nothing is sent
// there.
func TestPasskeyNoticeSkipsAnUnverifiedAddress(t *testing.T) {
f, mailer := noticeFixture(t)
f.repo.staff["alex"].Email = "[email protected]"
// Two passkeys, so removing one is allowed without a verified email.
f.repo.passkeyCreds["x"] = PasskeyCredential{ID: "x", UserID: "u2", CredentialID: "c-x", CreatedAt: frozenNow}
f.repo.passkeyCreds["y"] = PasskeyCredential{ID: "y", UserID: "u2", CredentialID: "c-y", CreatedAt: frozenNow}
f.setReauth(alexTok, f.api.now())
if w := do(f.eh, "DELETE", "/api/v1/account/passkey/credentials/x", "", asCookie(alexTok)); w.Code != http.StatusNoContent {
t.Fatalf("delete = %d (%s)", w.Code, w.Body.String())
}
if len(mailer.notices) != 0 {
t.Fatalf("notices = %q, want none", mailer.notices)
}
}
func TestMaskEmail(t *testing.T) {
for in, want := range map[string]string{
"[email protected]": "a***@example.com",
"李雷@example.cn": "李***@example.cn",
"[email protected]": "a***@b.c",
"broken": "***",
"@nolocal.example": "***",
} {
if got := maskEmail(in); got != want {
t.Errorf("maskEmail(%q) = %q, want %q", in, got, want)
}
}
}
+9
View File
@@ -165,6 +165,9 @@ type SessionedUser struct {
// LastSeenAt is when the session last authenticated a request, as last
// recorded by TouchSession (so up to sessionTouchEvery stale).
LastSeenAt time.Time
// ReauthAt is when the holder last proved a factor of the account (see
// requireReauth); zero when the session never did.
ReauthAt time.Time
}
// NewSession is one session to record at sign-in: the sha-256 of the opaque
@@ -176,6 +179,9 @@ type NewSession struct {
ExpiresAt time.Time
UserAgent string
ClientIP string
// ReauthAt is set when the sign-in itself proved a factor (passkey, email
// code, op-login, setup token); zero for a bind-code sign-in.
ReauthAt time.Time
}
// OpLoginRequest is one op.console staff-login attempt (spec §B op-login): the
@@ -596,6 +602,9 @@ type Repo interface {
// never moves last_seen_at backwards, and touching an absent session is not
// an error.
TouchSession(ctx context.Context, tokenHash string, now time.Time) error
// MarkSessionReauth records that the holder of a live session proved a factor
// at the given time. Marking an absent or revoked session is not an error.
MarkSessionReauth(ctx context.Context, tokenHash string, at time.Time) error
// RevokeSession marks a session revoked (logout). It is idempotent: revoking an
// absent or already-revoked session is not an error.
RevokeSession(ctx context.Context, tokenHash string) error
+22 -2
View File
@@ -66,24 +66,43 @@ func hashCookie(value string) string {
return hex.EncodeToString(sum[:])
}
// signInProof says whether the sign-in minting a session proved a factor of the
// account. A proven sign-in counts as a fresh reauth, so the new session may add
// a passkey or change the email straight away (requireReauth).
type signInProof bool
const (
// provenSignIn: a passkey, an email code, op-login or the setup token.
provenSignIn signInProof = true
// bindCodeSignIn: the in-game identity alone, which never unlocks the
// account's other factors.
bindCodeSignIn signInProof = false
)
// startSession mints a session for userID and sets its cookie. Every sign-in door
// ends here, so every session records the device it was minted for.
func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string) error {
func (a *API) startSession(w http.ResponseWriter, r *http.Request, userID string, proof signInProof) error {
token, err := newSessionToken()
if err != nil {
return err
}
expires := a.now().Add(sessionTTL)
now := a.now()
expires := now.Add(sessionTTL)
ip := ""
if addr := a.clientIP(r); addr.IsValid() {
ip = addr.String()
}
var reauth time.Time
if proof == provenSignIn {
reauth = now
}
if err := a.Repo.CreateSession(r.Context(), NewSession{
TokenHash: hashCookie(token),
UserID: userID,
ExpiresAt: expires,
UserAgent: truncateUTF8(r.UserAgent(), maxSessionUserAgent),
ClientIP: ip,
ReauthAt: reauth,
}); err != nil {
return err
}
@@ -227,6 +246,7 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
ViaAdminAccess: staffRole(u.Role) && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname),
EmailVerified: u.EmailVerified,
ViaSession: true,
ReauthAt: u.ReauthAt,
}, nil
}
+56
View File
@@ -187,3 +187,59 @@ func TestRevokeOtherUserSessionsKeepsOne(t *testing.T) {
t.Fatalf("revoke-others keeping nothing = %d, %v; want 1", n, err)
}
}
// reauth_at: a proven sign-in stores the proof, a bind-code sign-in stores
// none, SessionUser reads it back, and a reauth marks only a live session.
func TestSessionReauthProof(t *testing.T) {
ctx := context.Background()
now := mustNow()
u := newUser(t, "user", "reauth")
unproven := newSession(t, u.ID, "unproven", now.Add(time.Hour))
su, err := repo.SessionUser(ctx, unproven, now)
if err != nil {
t.Fatalf("SessionUser: %v", err)
}
if !su.ReauthAt.IsZero() {
t.Fatalf("ReauthAt = %v on a session with no proof, want zero", su.ReauthAt)
}
proven := "proven-" + suffix(t)
signedIn := now.Add(-time.Minute)
if err := repo.CreateSession(ctx, api.NewSession{
TokenHash: proven, UserID: u.ID, ExpiresAt: now.Add(time.Hour), ReauthAt: signedIn,
}); err != nil {
t.Fatalf("CreateSession: %v", err)
}
if su, err = repo.SessionUser(ctx, proven, now); err != nil || !sameMicro(su.ReauthAt, signedIn) {
t.Fatalf("SessionUser = %+v, %v; want ReauthAt %v", su, err, signedIn)
}
if err := repo.MarkSessionReauth(ctx, unproven, now); err != nil {
t.Fatalf("MarkSessionReauth: %v", err)
}
if su, err = repo.SessionUser(ctx, unproven, now); err != nil || !sameMicro(su.ReauthAt, now) {
t.Fatalf("after a mark SessionUser = %+v, %v; want ReauthAt %v", su, err, now)
}
// The other session keeps its own proof.
if su, _ = repo.SessionUser(ctx, proven, now); !sameMicro(su.ReauthAt, signedIn) {
t.Fatalf("marking one session moved another's proof to %v", su.ReauthAt)
}
if err := repo.RevokeSession(ctx, proven); err != nil {
t.Fatal(err)
}
if err := repo.MarkSessionReauth(ctx, proven, now.Add(time.Minute)); err != nil {
t.Fatalf("marking a revoked session: %v", err)
}
var stored time.Time
if err := db.QueryRow(`SELECT reauth_at FROM sessions WHERE token_hash = $1`, proven).Scan(&stored); err != nil {
t.Fatal(err)
}
if !sameMicro(stored, signedIn) {
t.Fatalf("a revoked session's reauth_at moved to %v", stored)
}
if err := repo.MarkSessionReauth(ctx, "no-such-"+suffix(t), now); err != nil {
t.Fatalf("marking an absent session: %v", err)
}
}
@@ -0,0 +1,7 @@
-- When the holder of a session last proved a factor the account already had: a
-- passkey assertion, a code mailed to the verified address, or a sign-in through
-- one of those (op-login and the setup token count too). Adding or removing a
-- passkey and changing the email need that proof within the last few minutes,
-- so a stolen cookie alone cannot plant a lasting way in. NULL means the session
-- never proved one (a bind-code sign-in), which is what every existing row gets.
ALTER TABLE sessions ADD COLUMN reauth_at timestamptz;
+114 -4
View File
@@ -52,6 +52,10 @@ interface MockAccount {
updated_at?: string;
quota?: QuotaView;
sessions?: SessionView[];
// Until when this browser's session counts as re-authenticated (ms epoch),
// and the address an email-change code was last sent to.
reauthUntil?: number;
pendingEmail?: string;
}
interface MockServer extends ServerStatus {
@@ -568,6 +572,41 @@ function setSessionCookie(res: ServerResponse, accountID: string): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=${accountID}; Path=/; SameSite=Lax`);
}
// signInProven opens a session through a door that proved a factor (email code,
// passkey, operator login), which like the real API counts as a re-auth.
function signInProven(ctx: RequestContext, accountID: AccountID): void {
setSessionCookie(ctx.res, accountID);
const acc = ctx.state.accounts[accountID];
if (acc) acc.reauthUntil = Date.now() + REAUTH_MS;
}
const REAUTH_MS = 5 * 60_000;
/** reauthFactors mirrors reauthState: a passkey, an email code to a verified
* address (users) or a fresh sign-in (operators). None → nothing to re-prove. */
function reauthFactors(state: MockState, acc: MockAccount): string[] {
const hasPasskey = (state.passkeys[acc.id] ?? []).length > 0;
if (!hasPasskey && !acc.emailVerified) return [];
const factors = hasPasskey ? ["passkey"] : [];
if (acc.role !== "user") factors.push("sign_in");
else if (acc.emailVerified) factors.push("email");
return factors;
}
/** refusedForReauth answers 403 reauth_required, as the real API does, for a
* change to how the account signs in without a recent proof. */
function refusedForReauth(ctx: SessionContext): boolean {
const acc = ctx.account;
if (reauthFactors(ctx.state, acc).length === 0 || (acc.reauthUntil ?? 0) > Date.now()) return false;
sendError(ctx.res, 403, "reauth_required", "confirm it's you first: this change needs your passkey or email code from the last few minutes");
return true;
}
function markReauth(ctx: SessionContext): void {
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
sendJSON(ctx.res, 200, { ok: true, until: new Date(ctx.account.reauthUntil).toISOString() });
}
function clearSessionCookie(res: ServerResponse): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
}
@@ -784,7 +823,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
return true;
}
opLogins.delete(body.request_id!);
setSessionCookie(ctx.res, "owner");
signInProven(ctx, "owner");
sendJSON(ctx.res, 200, { user_id: "mock-owner", role: "owner" });
return true;
}
@@ -828,7 +867,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
sendError(ctx.res, 400, "bad_request", "login_id and assertion are required");
return true;
}
setSessionCookie(ctx.res, "owner");
signInProven(ctx, "owner");
sendJSON(ctx.res, 200, {
user_id: "mock-owner",
role: "owner"
@@ -857,7 +896,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
sendError(ctx.res, 400, "bad_request", "email and assertion are required");
return true;
}
setSessionCookie(ctx.res, "owner");
signInProven(ctx, "owner");
sendJSON(ctx.res, 200, {
user_id: "mock-owner",
role: "owner"
@@ -879,7 +918,7 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
setSessionCookie(ctx.res, "owner");
signInProven(ctx, "owner");
sendJSON(ctx.res, 200, {
user_id: "mock-owner",
role: "owner"
@@ -993,6 +1032,8 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
sendError(ctx.res, 400, "bad_request", "invalid email");
return true;
}
if (refusedForReauth(ctx)) return true;
ctx.account.pendingEmail = body.email.trim();
sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() });
return true;
}
@@ -1002,11 +1043,22 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
const next = ctx.account.pendingEmail ?? ctx.account.email;
// Like the real API: replacing a verified address signs the other devices
// out, and proving a code counts as a re-auth.
if (ctx.account.emailVerified && next.toLowerCase() !== ctx.account.email.toLowerCase()) {
const here = thisSessionHash(ctx.account);
ctx.account.sessions = accountSessions(ctx.account).filter((s) => s.token_hash === here);
}
ctx.account.email = next;
ctx.account.pendingEmail = undefined;
ctx.account.emailVerified = true;
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
sendJSON(ctx.res, 200, { verified: true, email: ctx.account.email });
return true;
}
case "POST account/passkey/register/begin": {
if (refusedForReauth(ctx)) return true;
sendJSON(ctx.res, 200, {
challenge: "c29tZV9jaGFsbGVuZ2VfZGF0YQ",
rp: { name: "Felis Dev" },
@@ -1034,6 +1086,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
ctx.state.passkeys[ctx.account.id] = [];
}
ctx.state.passkeys[ctx.account.id].unshift(newCred);
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
sendJSON(ctx.res, 201, newCred);
return true;
}
@@ -1042,6 +1095,61 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
sendJSON(ctx.res, 200, { credentials: list });
return true;
}
case "GET account/reauth": {
const factors = reauthFactors(ctx.state, ctx.account);
const until = ctx.account.reauthUntil ?? 0;
if (factors.length === 0) sendJSON(ctx.res, 200, { needed: false, factors });
else if (until > Date.now()) sendJSON(ctx.res, 200, { needed: false, until: new Date(until).toISOString(), factors });
else sendJSON(ctx.res, 200, { needed: true, factors });
return true;
}
case "POST account/reauth/passkey/begin": {
const list = ctx.state.passkeys[ctx.account.id] ?? [];
if (list.length === 0) {
sendError(ctx.res, 409, "no_passkey", "no passkey enrolled; confirm with an email code instead");
return true;
}
sendJSON(ctx.res, 200, {
publicKey: {
challenge: "c29tZV9yZWF1dGhfY2hhbGxlbmdl",
rpId: "dev.felis.localhost",
allowCredentials: list.map(() => ({ type: "public-key", id: "cGstMQ" })),
userVerification: "preferred",
timeout: 60000,
},
});
return true;
}
case "POST account/reauth/passkey/finish": {
const body = await readJSON<{ assertion?: unknown }>(ctx.req);
if (!body.assertion) {
sendError(ctx.res, 400, "bad_request", "assertion is required");
return true;
}
markReauth(ctx);
return true;
}
case "POST account/reauth/email/start": {
if (ctx.account.role !== "user") {
sendError(ctx.res, 403, "staff_reauth", "operators confirm with a passkey or by signing in again");
return true;
}
if (!ctx.account.emailVerified) {
sendError(ctx.res, 409, "no_step_up_factor", "no verified email to send a code to");
return true;
}
sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() });
return true;
}
case "POST account/reauth/email/verify": {
const body = await readJSON<{ code?: string }>(ctx.req);
if (body.code?.trim() !== MOCK_OTP_CODE) {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
markReauth(ctx);
return true;
}
case "GET account/migrate":
// No migration pending: the real API's answer until one is started in-game.
sendJSON(ctx.res, 200, { active: false });
@@ -1076,6 +1184,8 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
return true;
}
if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) {
// The real API asks for the re-auth before it looks the passkey up.
if (refusedForReauth(ctx)) return true;
const id = ctx.parts[5];
const list = ctx.state.passkeys[ctx.account.id] ?? [];
const idx = list.findIndex((k) => k.id === id);
+286
View File
@@ -0,0 +1,286 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { Fingerprint, Loader2, LogIn, Mail, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InlineError } from "@/components/MessageLine";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { api, clientError, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { requestAssertion } from "@/lib/passkey";
import { useTier } from "@/lib/tier";
// A change to how the account signs in (a passkey added or removed, the email
// changed) needs this session to have proven a factor in the last few minutes,
// so a browser left signed in cannot quietly swap the account's ways in. The API
// refuses such a change with 403 reauth_required; useReauth catches that, asks
// for the proof in this dialog and runs the change again once it is given.
export function isReauthRequired(e: unknown): boolean {
return (e as { code?: string } | null)?.code === "reauth_required";
}
/** isReauthCancelled: the confirmation was closed without a proof. Nothing
* failed, so callers show no error for it. */
export function isReauthCancelled(e: unknown): boolean {
return (e as { code?: string } | null)?.code === "reauth_cancelled";
}
export function useReauth() {
const [open, setOpen] = useState(false);
const settle = useRef<((ok: boolean) => void) | null>(null);
const finish = useCallback((ok: boolean) => {
setOpen(false);
const resolve = settle.current;
settle.current = null;
resolve?.(ok);
}, []);
// confirm opens the dialog and resolves true once a factor is proven, false
// when it is closed without one.
const confirm = useCallback(() => {
settle.current?.(false);
setOpen(true);
return new Promise<boolean>((resolve) => {
settle.current = resolve;
});
}, []);
// guard runs a change. Refused for want of a fresh proof, it asks for one and
// runs the change a second time; closing the dialog rejects with
// reauth_cancelled. A change that needs a user gesture of its own (a WebAuthn
// create) uses confirm and lets the user press its button again instead.
const guard = useCallback(
async <T,>(change: () => Promise<T>): Promise<T> => {
try {
return await change();
} catch (e) {
if (!isReauthRequired(e)) throw e;
if (!(await confirm())) throw clientError("reauth_cancelled");
return change();
}
},
[confirm],
);
const dialog = <ReauthDialog open={open} onDone={() => finish(true)} onCancel={() => finish(false)} />;
return { guard, confirm, dialog };
}
function ReauthDialog({ open, onDone, onCancel }: { open: boolean; onDone: () => void; onCancel: () => void }) {
const { t } = useTranslation("account");
return (
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-primary" />
{t("reauth_title")}
</DialogTitle>
<DialogDescription>{t("reauth_desc")}</DialogDescription>
</DialogHeader>
{/* Mounted per opening, so every confirmation starts from a fresh status. */}
{open && <ReauthBody onDone={onDone} onCancel={onCancel} />}
</DialogContent>
</Dialog>
);
}
type Pending = "passkey" | "send" | "verify" | "sign_in";
function ReauthBody({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
const { t } = useTranslation("account");
const { identity, refresh } = useTier();
const status = useAsync(() => api.reauthStatus(), []);
const [pending, setPending] = useState<Pending | null>(null);
const [error, setError] = useState<string | null>(null);
const [codeSent, setCodeSent] = useState(false);
const [code, setCode] = useState("");
// Proven meanwhile (in another tab, say): there is nothing to ask.
const proven = status.data?.needed === false;
useEffect(() => {
if (proven) onDone();
}, [proven, onDone]);
async function act(kind: Pending, fn: () => Promise<void>) {
if (pending) return;
setPending(kind);
setError(null);
try {
await fn();
} catch (e) {
setError(humanizeError(e));
} finally {
setPending(null);
}
}
const withPasskey = () =>
act("passkey", async () => {
const options = await api.reauthPasskeyBegin();
await api.reauthPasskeyFinish(await requestAssertion(options.publicKey));
onDone();
});
const sendCode = () =>
act("send", async () => {
await api.reauthEmailStart();
setCodeSent(true);
setCode("");
});
const verifyCode = (e: FormEvent) => {
e.preventDefault();
const trimmed = code.trim();
if (!trimmed) return;
void act("verify", async () => {
await api.reauthEmailVerify(trimmed);
onDone();
});
};
// A fresh sign-in counts as the proof. Signing out flips the session to
// signed-out and the route guard takes the browser to the sign-in page.
const signInAgain = () =>
act("sign_in", async () => {
await api.logout();
await refresh();
});
if (status.loading && !status.data) {
return (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("reauth_checking")}
</div>
);
}
if (status.error || !status.data || proven) {
return (
<>
<InlineError message={status.error ? humanizeError(status.error) : null} />
<DialogFooter>
<Button variant="outline" size="sm" onClick={onCancel}>
{t("common:cancel")}
</Button>
{status.error ? (
<Button size="sm" onClick={status.reload}>
{t("common:try_again")}
</Button>
) : null}
</DialogFooter>
</>
);
}
const factors = status.data.factors;
const email = identity?.email ?? "";
const options: React.ReactNode[] = [];
if (factors.includes("passkey")) {
options.push(
<Button key="passkey" className="w-full" onClick={() => void withPasskey()} disabled={pending !== null}>
{pending === "passkey" ? <Loader2 className="animate-spin" /> : <Fingerprint />}
{pending === "passkey" ? t("reauth_passkey_waiting") : t("reauth_passkey_btn")}
</Button>,
);
}
if (factors.includes("email")) {
options.push(
codeSent ? (
<form key="email" onSubmit={verifyCode} className="space-y-2">
<Label htmlFor="reauth-code">{t("reauth_code_label", { email })}</Label>
<div className="flex gap-2">
<Input
id="reauth-code"
inputMode="numeric"
autoComplete="one-time-code"
placeholder={t("otp_code_placeholder")}
value={code}
onChange={(e) => setCode(e.target.value)}
disabled={pending !== null}
maxLength={6}
autoFocus
className="font-mono text-center tracking-[0.2em]"
/>
<Button type="submit" disabled={pending !== null || code.trim().length !== 6} className="shrink-0">
{pending === "verify" && <Loader2 className="animate-spin" />}
{pending === "verify" ? t("reauth_confirming") : t("reauth_confirm_btn")}
</Button>
</div>
<Button
type="button"
variant="link"
size="sm"
onClick={() => void sendCode()}
disabled={pending !== null}
className="h-auto p-0 font-normal"
>
{pending === "send" ? t("sending_code") : t("reauth_resend")}
</Button>
</form>
) : (
<Button
key="email"
variant="outline"
className="w-full"
onClick={() => void sendCode()}
disabled={pending !== null}
>
{pending === "send" ? <Loader2 className="animate-spin" /> : <Mail />}
<span className="truncate">{pending === "send" ? t("sending_code") : t("reauth_email_btn", { email })}</span>
</Button>
),
);
}
if (factors.includes("sign_in")) {
options.push(
<div key="sign_in" className="space-y-2">
<p className="text-sm text-muted-foreground">{t("reauth_sign_in_desc")}</p>
<Button variant="outline" className="w-full" onClick={() => void signInAgain()} disabled={pending !== null}>
{pending === "sign_in" ? <Loader2 className="animate-spin" /> : <LogIn />}
{t("reauth_sign_in_btn")}
</Button>
</div>,
);
}
return (
<>
<div className="space-y-3 py-2">
{options.length === 0 ? (
<InlineError message={t("errors:no_step_up_factor")} />
) : (
options.flatMap((option, i) =>
i === 0
? [option]
: [
<div key={`or-${i}`} className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
{t("reauth_or")}
<span className="h-px flex-1 bg-border" />
</div>,
option,
],
)
)}
<InlineError message={error} />
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={onCancel}>
{t("common:cancel")}
</Button>
</DialogFooter>
</>
);
}
+16 -1
View File
@@ -89,5 +89,20 @@
"migration_redeem_placeholder": "Transfer code",
"migration_redeeming": "Redeeming…",
"migration_redeem_btn": "Redeem",
"migration_redeemed": "Migration complete — {{count}} server(s) moved to this account."
"migration_redeemed": "Migration complete — {{count}} server(s) moved to this account.",
"reauth_title": "Confirm it's you",
"reauth_desc": "Adding or removing a passkey and changing the email need a fresh check with a way in you already have. The check lasts 5 minutes.",
"reauth_checking": "Checking how you can confirm…",
"reauth_passkey_btn": "Use a passkey",
"reauth_passkey_waiting": "Waiting for your passkey…",
"reauth_or": "or",
"reauth_email_btn": "Email a code to {{email}}",
"reauth_code_label": "Code sent to {{email}}",
"reauth_confirm_btn": "Confirm",
"reauth_confirming": "Confirming…",
"reauth_resend": "Send another code",
"reauth_sign_in_desc": "Operator accounts can also sign out and sign in again. A fresh sign-in counts for 5 minutes.",
"reauth_sign_in_btn": "Sign out and sign in again",
"reauth_done_continue": "Confirmed. Press Continue to add the passkey.",
"email_change_desc": "Enter the new address and we'll send it a code. Once it's verified, sign-in codes go there, the old address gets a notice, and your other devices are signed out."
}
@@ -78,6 +78,9 @@
"passkey_login_failed": "Passkey verification failed — try again.",
"passkey_login_invalid": "That Passkey sign-in request is invalid or expired — start it again.",
"too_many_challenges": "Too many verification attempts right now — try again shortly.",
"reauth_required": "Confirm it's you first: this change needs your Passkey or an email code from the last few minutes.",
"staff_reauth": "Operator accounts confirm with a Passkey or by signing in again.",
"no_session": "This only works in a browser signed in to Felis.",
"op_login_invalid": "This operator sign-in couldn't be completed — restart the sign-in.",
"op_login_not_found": "No pending operator sign-in with that id.",
"too_many_streams": "Too many live streams are open — close some pages and try again.",
+16 -1
View File
@@ -88,5 +88,20 @@
"migration_redeem_placeholder": "转移码",
"migration_redeeming": "兑换中…",
"migration_redeem_btn": "兑换",
"migration_redeemed": "迁移完成——已有 {{count}} 台服务器转移至本账户。"
"migration_redeemed": "迁移完成——已有 {{count}} 台服务器转移至本账户。",
"reauth_title": "确认是你本人",
"reauth_desc": "添加或删除 Passkey、修改邮箱前,需要用你已有的登录方式再验证一次,验证后 5 分钟内有效。",
"reauth_checking": "正在查看可用的验证方式…",
"reauth_passkey_btn": "用 Passkey 验证",
"reauth_passkey_waiting": "等待 Passkey…",
"reauth_or": "或",
"reauth_email_btn": "发送验证码到 {{email}}",
"reauth_code_label": "验证码已发送到 {{email}}",
"reauth_confirm_btn": "确认",
"reauth_confirming": "确认中…",
"reauth_resend": "重新发送验证码",
"reauth_sign_in_desc": "管理员账户也可以退出后重新登录,重新登录后 5 分钟内视为已验证。",
"reauth_sign_in_btn": "退出并重新登录",
"reauth_done_continue": "已确认。点“继续”添加 Passkey。",
"email_change_desc": "输入新邮箱,我们会向新地址发送验证码。验证通过后登录验证码改发到新邮箱,旧邮箱会收到通知,其它设备会退出登录。"
}
@@ -78,6 +78,9 @@
"passkey_login_failed": "Passkey 验证失败——请重试。",
"passkey_login_invalid": "Passkey 登录请求无效或已过期——请重新发起。",
"too_many_challenges": "验证请求过于频繁——请稍后再试。",
"reauth_required": "请先确认是你本人:这项更改需要你在几分钟内用 Passkey 或邮箱验证码验证过。",
"staff_reauth": "管理员账户须用 Passkey 或重新登录来确认身份。",
"no_session": "只能在已登录 Felis 的浏览器中进行此操作。",
"op_login_invalid": "本次管理员登录未能完成——请重新发起登录。",
"op_login_not_found": "找不到该管理员登录请求。",
"too_many_streams": "同时打开的实时连接过多——请关闭一些页面后再试。",
+12
View File
@@ -1015,4 +1015,16 @@ describe("copy for the generic server codes", () => {
expect(err.status).toBe(0);
expect(humanizeError(err)).toBe("The browser returned no passkey. Try again.");
});
it("words the re-authentication refusals", () => {
expect(humanizeError({ status: 403, code: "reauth_required", message: "raw" })).toBe(
"Confirm it's you first: this change needs your Passkey or an email code from the last few minutes.",
);
expect(humanizeError({ status: 403, code: "staff_reauth", message: "raw" })).toBe(
"Operator accounts confirm with a Passkey or by signing in again.",
);
expect(humanizeError({ status: 400, code: "no_session", message: "raw" })).toBe(
"This only works in a browser signed in to Felis.",
);
});
});
+27
View File
@@ -590,6 +590,26 @@ export const api = rejectingSync({
revokeMyOtherSessions: () =>
request<{ revoked: number }>("POST", "/account/sessions/revoke-others"),
// Re-authentication. Adding or removing a passkey and changing the email are
// refused with 403 reauth_required unless this session proved a factor in the
// last few minutes. Status names the factors that can prove it: "passkey",
// "email" (a code to the verified address) or "sign_in" (operators sign in
// again). Like the migrate begin, the passkey begin returns the raw
// {"publicKey": {...}} document.
reauthStatus: () =>
request<{ needed: boolean; until?: string; factors: string[] }>("GET", "/account/reauth"),
reauthPasskeyBegin: () => request<any>("POST", "/account/reauth/passkey/begin"),
reauthPasskeyFinish: (assertion: any) =>
request<{ ok: boolean; until: string }>("POST", "/account/reauth/passkey/finish", { assertion }),
reauthEmailStart: () =>
request<{ sent: boolean; expires_at: string }>("POST", "/account/reauth/email/start"),
reauthEmailVerify: (code: string) =>
request<{ ok: boolean; until: string }>("POST", "/account/reauth/email/verify", { code }),
// Account migration (spec §B3 inherit). Started in-game with /felis migrate; the
// web side then drives: status → step-up confirm (passkey when enrolled, email-OTP
// otherwise) → issue-code (source names the target account and reads the one-time
@@ -982,6 +1002,13 @@ export function humanizeError(e: unknown): string {
return t("passkey_login_invalid");
case "too_many_challenges":
return t("too_many_challenges");
// Re-authentication before a change to how the account signs in.
case "reauth_required":
return t("reauth_required");
case "staff_reauth":
return t("staff_reauth");
case "no_session":
return t("no_session");
// Operator-login approvals, live streams, and the remaining auth doors.
case "op_login_invalid":
return t("op_login_invalid");
+335 -6
View File
@@ -1363,7 +1363,7 @@ export interface paths {
put?: never;
/**
* Mint and deliver an email one-time code for the caller (web onboarding, spec §B2).
* @description Generates a one-time code bound to the authenticated principal and the supplied address, persists only its hash, and delivers it out of band. The code is never returned in the response. A re-request supersedes the prior unconsumed code.
* @description Generates a one-time code bound to the authenticated principal and the supplied address, persists only its hash, and delivers it out of band. The code is never returned in the response. A re-request supersedes the prior unconsumed code. Once the account has a passkey or a verified email, the session must have reauthed within 5 minutes (403 reauth_required).
*/
post: operations["emailOtpStart"];
delete?: never;
@@ -1383,7 +1383,7 @@ export interface paths {
put?: never;
/**
* Redeem an email one-time code and mark the caller's email verified (spec §B2).
* @description Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. When the new address replaces a different verified one, every other session of the caller is signed out: sign-in codes now go to the new address, so a session opened through the old one ends. Too many incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, consumed, or mismatched code is a 400.
* @description Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. When the new address replaces a different verified one, every other session of the caller is signed out: sign-in codes now go to the new address, so a session opened through the old one ends; the old address is mailed a notice with the new one masked. A verified code also counts as a reauth for this session. Too many incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, consumed, or mismatched code is a 400.
*/
post: operations["emailOtpVerify"];
delete?: never;
@@ -1403,7 +1403,7 @@ export interface paths {
put?: never;
/**
* Record the caller's email WITHOUT verifying it (setup bootstrap, spec §B2).
* @description Writes the supplied address to the authenticated principal's user row and clears email_verified (already false for a fresh Owner). The setup bootstrap has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP flow proves control of the address via /account/email/verify.
* @description Writes the supplied address to the authenticated principal's user row and clears email_verified (already false for a fresh Owner). The setup bootstrap has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP flow proves control of the address via /account/email/verify. Clearing a verified address strips a factor, so once the account has one the session must have reauthed within 5 minutes (403 reauth_required).
*/
post: operations["setEmail"];
delete?: never;
@@ -1423,7 +1423,7 @@ export interface paths {
put?: never;
/**
* Begin a passkey (WebAuthn) registration ceremony for the caller (spec §14, Phase 6 bind).
* @description Mints a credential-creation challenge bound to the authenticated principal, stashes the server-side ceremony state under a short TTL, and returns the WebAuthn publicKey creation options for navigator.credentials.create(). The challenge is never echoed by the client. Enrollment only — passkey login is a deferred slice. 503 when the WebAuthn verifier is not configured on this instance.
* @description Mints a credential-creation challenge bound to the authenticated principal, stashes the server-side ceremony state under a short TTL, and returns the WebAuthn publicKey creation options for navigator.credentials.create(). The challenge is never echoed by the client. Once the account has a passkey or a verified email, the session must have reauthed within 5 minutes (403 reauth_required). 503 when the WebAuthn verifier is not configured on this instance.
*/
post: operations["passkeyRegisterBegin"];
delete?: never;
@@ -1443,7 +1443,7 @@ export interface paths {
put?: never;
/**
* Finish a passkey registration ceremony and bind the credential (spec §14, Phase 6 bind).
* @description Consumes the caller's live registration challenge (single-use), verifies the authenticator's attestation against the server-stashed ceremony state, and persists the public credential. A missing or expired ceremony is a 400; an attestation that fails verification is a 400; a credential already bound to any account is a 409. 503 when the WebAuthn verifier is not configured.
* @description Consumes the caller's live registration challenge (single-use), verifies the authenticator's attestation against the server-stashed ceremony state, and persists the public credential. A missing or expired ceremony is a 400; an attestation that fails verification is a 400; a credential already bound to any account is a 409. The verified email is mailed a notice, and the ceremony counts as a reauth for this session. 503 when the WebAuthn verifier is not configured.
*/
post: operations["passkeyRegisterFinish"];
delete?: never;
@@ -1484,7 +1484,7 @@ export interface paths {
post?: never;
/**
* Unbind one of the caller's passkeys (spec §14, Phase 6 bind).
* @description Removes a passkey scoped to the authenticated principal, so a caller can only unbind their OWN credential. An unknown or cross-user id is a 404; it never silently no-ops as success. The account's only passkey cannot be removed while its email is unverified (409 last_passkey): it is then the account's only durable way in. Removing a passkey signs out every other session of the caller, so a session opened with that passkey ends with it.
* @description Removes a passkey scoped to the authenticated principal, so a caller can only unbind their OWN credential. An unknown or cross-user id is a 404; it never silently no-ops as success. The account's only passkey cannot be removed while its email is unverified (409 last_passkey): it is then the account's only durable way in. Removing a passkey signs out every other session of the caller, so a session opened with that passkey ends with it, and mails the verified email a notice. The session must have reauthed within 5 minutes (403 reauth_required).
*/
delete: operations["passkeyDelete"];
options?: never;
@@ -1492,6 +1492,103 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/account/reauth": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Say whether a passkey or email change needs a reauth first, and how to give one.
* @description needed is true when the account has a passkey or a verified email and this session has not proven one within the last 5 minutes. until is when the current proof stops counting. factors lists the ways this caller can reauth, best first: passkey (an enrolled passkey), email (a player's verified address), sign_in (an operator signs out and back in through op-login or a passkey).
*/
get: operations["reauthStatus"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/reauth/passkey/begin": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Begin a passkey assertion that reauths this session.
* @description Returns WebAuthn assertion request options over the caller's own passkeys, bound to a fresh reauth-purpose challenge.
*/
post: operations["reauthPasskeyBegin"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/reauth/passkey/finish": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Finish the passkey assertion and mark this session reauthed for 5 minutes.
* @description Verifies the assertion against the reauth challenge with the login door's clone check (a cloned authenticator is 400 passkey_login_invalid).
*/
post: operations["reauthPasskeyFinish"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/reauth/email/start": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Mail a reauth code to the caller's verified address.
* @description For players with a verified email. Operators reauth with a passkey or by signing in again (403 staff_reauth).
*/
post: operations["reauthEmailStart"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/reauth/email/verify": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Redeem the reauth code and mark this session reauthed for 5 minutes. */
post: operations["reauthEmailVerify"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/account/sessions": {
parameters: {
query?: never;
@@ -2339,6 +2436,29 @@ export interface components {
"application/json": components["schemas"]["Error"];
};
};
/** @description This session is reauthed until the returned time. */
Reauthed: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @constant */
ok: true;
/** Format: date-time */
until: string;
};
};
};
/** @description reauth_required: this change adds, removes or moves a way into the account, and the account has a passkey or a verified email, so the session must have proven one of them within the last 5 minutes. Signing in by passkey, email code, op-login or the setup token counts; a bind-code sign-in does not. GET /api/v1/account/reauth lists the factors that can give the proof, then retry the change. */
ReauthRequired: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The configured SMTP relay refused the message (code mail_undeliverable), so no code was delivered. Distinct from 500 because the fault is in the install's [smtp] settings, not in the request or the platform — most often a From address the relay will not let this account send as. The relay's own text is deliberately withheld (it names the SMTP account) and written to the felis-api log instead, keyed by the same request_id this response carries. Retrying the same address changes nothing until an operator fixes the relay. */
MailUndeliverable: {
headers: {
@@ -5765,6 +5885,7 @@ export interface operations {
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["ReauthRequired"];
/** @description Resend requested before the cooldown elapsed (otp_resend_cooldown); or the account spent its daily wrong-code budget (otp_account_locked, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */
429: {
headers: {
@@ -5865,6 +5986,7 @@ export interface operations {
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["ReauthRequired"];
};
};
passkeyRegisterBegin: {
@@ -5886,6 +6008,7 @@ export interface operations {
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["ReauthRequired"];
/** @description Passkey subsystem is not configured. */
503: {
headers: {
@@ -5997,6 +6120,7 @@ export interface operations {
content?: never;
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["ReauthRequired"];
/** @description No such passkey for this caller. */
404: {
headers: {
@@ -6017,6 +6141,211 @@ export interface operations {
};
};
};
reauthStatus: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Where the caller stands. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
needed: boolean;
/** Format: date-time */
until?: string;
factors: ("passkey" | "email" | "sign_in")[];
};
};
};
401: components["responses"]["Unauthorized"];
};
};
reauthPasskeyBegin: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": Record<string, never>;
};
};
/** @description The caller has no enrolled passkey (no_passkey), or no browser session to mark (no_session). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
503: components["responses"]["ServiceUnavailable"];
};
};
reauthPasskeyFinish: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description The navigator.credentials.get() PublicKeyCredential assertion. */
assertion: Record<string, never>;
};
};
};
responses: {
200: components["responses"]["Reauthed"];
/** @description Assertion invalid, challenge stale, or a cloned authenticator (passkey_login_invalid); no browser session (no_session). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
503: components["responses"]["ServiceUnavailable"];
};
};
reauthEmailStart: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Code minted and dispatched. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @constant */
sent: true;
/** Format: date-time */
expires_at: string;
};
};
};
/** @description No browser session to mark (no_session). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
/** @description Operators cannot reauth by email (staff_reauth). */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The account has no verified email (no_step_up_factor). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Resend requested before the cooldown elapsed (otp_resend_cooldown); or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
502: components["responses"]["MailUndeliverable"];
};
};
reauthEmailVerify: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
code: string;
};
};
};
responses: {
200: components["responses"]["Reauthed"];
/** @description Invalid or expired code (invalid_code), or no browser session (no_session). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
/** @description Operators cannot reauth by email (staff_reauth). */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The account has no verified email (no_step_up_factor). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Too many incorrect attempts on this code (otp_locked), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
listMySessions: {
parameters: {
query?: never;
+31
View File
@@ -0,0 +1,31 @@
import { clientError } from "@/lib/api";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
/** requestAssertion runs the WebAuthn get() ceremony for the server's request
* options (their JSON form: challenge and credential ids base64url) and returns
* the assertion in the JSON form the API's finish endpoints decode. Begins that
* answer with go-webauthn's {"publicKey": {...}} envelope pass the inner object. */
export async function requestAssertion(options: any) {
const publicKey: PublicKeyCredentialRequestOptions = {
...options,
challenge: base64urlToBytes(options.challenge),
allowCredentials: options.allowCredentials?.map((cred: any) => ({
...cred,
id: base64urlToBytes(cred.id),
})),
};
const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null;
if (!credential) throw clientError("passkey_no_credential");
const response = credential.response as AuthenticatorAssertionResponse;
return {
id: credential.id,
rawId: bytesToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64url(response.clientDataJSON),
authenticatorData: bytesToBase64url(response.authenticatorData),
signature: bytesToBase64url(response.signature),
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
},
};
}
+59 -31
View File
@@ -14,8 +14,10 @@ import { formatAbsolute } from "@/lib/format";
import type { PasskeyCredential } from "@/lib/types";
import { useAsync } from "@/lib/hooks";
import { AccountSessionsCard } from "@/pages/AccountSessions";
import { isReauthCancelled, isReauthRequired, useReauth } from "@/components/ReauthDialog";
import { useTier } from "@/lib/tier";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
import { requestAssertion } from "@/lib/passkey";
import {
Dialog,
DialogContent,
@@ -36,8 +38,12 @@ export function Account() {
const status = useAsync(() => api.linkStatus(), []);
const { identity, refresh } = useTier();
const { t, i18n } = useTranslation("account");
// Adding or removing a passkey and changing the email ask for a fresh proof
// of a factor first (ReauthDialog).
const reauth = useReauth();
// Email verification state
// Email verification state. A verified address is changed through the same
// two steps, opened with changingEmail.
const [emailInput, setEmailInput] = useState("");
const [otpCodeInput, setOtpCodeInput] = useState("");
const [emailSending, setEmailSending] = useState(false);
@@ -46,6 +52,7 @@ export function Account() {
const [emailSent, setEmailSent] = useState(false);
const [sentEmailAddress, setSentEmailAddress] = useState("");
const [initializedEmail, setInitializedEmail] = useState(false);
const [changingEmail, setChangingEmail] = useState(false);
useEffect(() => {
if (identity?.email && !initializedEmail) {
@@ -61,16 +68,30 @@ export function Account() {
setEmailSending(true);
setEmailError(null);
try {
await api.emailStart(trimmed);
await reauth.guard(() => api.emailStart(trimmed));
setEmailSent(true);
setSentEmailAddress(trimmed);
} catch (err) {
setEmailError(humanizeError(err));
if (!isReauthCancelled(err)) setEmailError(humanizeError(err));
} finally {
setEmailSending(false);
}
}
function startChangeEmail() {
setChangingEmail(true);
setEmailInput("");
setEmailSent(false);
setOtpCodeInput("");
setEmailError(null);
}
function cancelChangeEmail() {
setChangingEmail(false);
setEmailSent(false);
setEmailError(null);
}
async function verifyEmailOtp(e: FormEvent) {
e.preventDefault();
const trimmedCode = otpCodeInput.trim();
@@ -80,6 +101,11 @@ export function Account() {
try {
await api.emailVerify(trimmedCode);
await refresh();
if (changingEmail) {
// Replacing a verified address signs the other devices out.
setChangingEmail(false);
setSessionsVersion((v) => v + 1);
}
setEmailSent(false);
setEmailInput("");
setOtpCodeInput("");
@@ -97,6 +123,7 @@ export function Account() {
const [passkeyNickname, setPasskeyNickname] = useState("");
const [registeringPasskey, setRegisteringPasskey] = useState(false);
const [passkeyError, setPasskeyError] = useState<string | null>(null);
const [passkeyNotice, setPasskeyNotice] = useState<string | null>(null);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
// Deleting a passkey goes through a confirm dialog that names it. The API
// refuses to remove the only passkey of an account whose email is unverified
@@ -118,6 +145,7 @@ export function Account() {
setRegisterDialogOpen(false);
setPasskeyNickname("");
setPasskeyError(null);
setPasskeyNotice(null);
setRegisteringPasskey(false);
}
@@ -127,12 +155,22 @@ export function Account() {
if (!name || registeringPasskey) return;
setRegisteringPasskey(true);
setPasskeyError(null);
setPasskeyNotice(null);
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const options = await api.passkeyRegisterBegin();
let options;
try {
options = await api.passkeyRegisterBegin();
} catch (err) {
if (!isReauthRequired(err)) throw err;
// The browser lets navigator.credentials.create run only right after a
// click, which the confirmation used up: the user presses Continue again.
if (await reauth.confirm()) setPasskeyNotice(t("reauth_done_continue"));
return;
}
const publicKey: PublicKeyCredentialCreationOptions = {
...options,
challenge: base64urlToBytes(options.challenge),
@@ -189,12 +227,13 @@ export function Account() {
setDeletingPasskey(true);
setDeleteError(null);
try {
await api.passkeyDelete(pendingDelete.id);
await reauth.guard(() => api.passkeyDelete(pendingDelete.id));
setPendingDelete(null);
// The server signed the other devices out along with the passkey.
setSessionsVersion((v) => v + 1);
await passkeys.reload();
} catch (err) {
if (isReauthCancelled(err)) return;
// Another device may have changed the list meanwhile: refresh it. A 404
// means the passkey is already gone, which is what was asked for.
void passkeys.reload();
@@ -290,17 +329,20 @@ export function Account() {
</CardTitle>
</CardHeader>
<CardContent className="text-sm">
{identity?.email_verified ? (
{identity?.email_verified && !changingEmail ? (
<div className="space-y-3">
<div className="flex items-center gap-2 font-medium text-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
{t("email_verified")}
</div>
<p className="text-muted-foreground">{t("email_desc")}</p>
<div className="flex items-center gap-2 text-muted-foreground">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
{identity.email}
</code>
<Button variant="link" size="sm" onClick={startChangeEmail} className="h-auto p-0 font-normal">
{t("change_email")}
</Button>
</div>
</div>
) : (
@@ -309,7 +351,7 @@ export function Account() {
<StepBadge n={1} />
<div className="w-full space-y-2">
<p className="font-medium text-foreground">{t("email_step1")}</p>
<p className="text-muted-foreground">{t("email_step1_desc")}</p>
<p className="text-muted-foreground">{t(changingEmail ? "email_change_desc" : "email_step1_desc")}</p>
{!emailSent ? (
<form onSubmit={sendEmailOtp} className="flex gap-2 max-w-md">
<Input
@@ -324,6 +366,11 @@ export function Account() {
<Button type="submit" disabled={emailSending || !emailInput}>
{emailSending ? t("sending_code") : t("send_code")}
</Button>
{changingEmail && (
<Button type="button" variant="ghost" onClick={cancelChangeEmail} disabled={emailSending}>
{t("common:cancel")}
</Button>
)}
</form>
) : (
<div className="flex items-center gap-2 text-emerald-600 font-medium dark:text-emerald-400">
@@ -409,6 +456,7 @@ export function Account() {
required
/>
</div>
{passkeyNotice && <MessageLine kind="success" message={passkeyNotice} />}
<InlineError message={passkeyError} />
</div>
<DialogFooter>
@@ -518,6 +566,8 @@ export function Account() {
onSignOut={() => void signOut()}
signingOut={signingOut}
/>
{reauth.dialog}
</>
);
}
@@ -654,29 +704,7 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo
function confirmWithPasskey() {
void run(async () => {
const options = await api.migrateConfirmPasskeyBegin();
const pk = options.publicKey;
const publicKey: PublicKeyCredentialRequestOptions = {
...pk,
challenge: base64urlToBytes(pk.challenge),
allowCredentials: pk.allowCredentials?.map((cred: any) => ({
...cred,
id: base64urlToBytes(cred.id),
})),
};
const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential;
if (!credential) throw clientError("passkey_no_credential");
const response = credential.response as AuthenticatorAssertionResponse;
await api.migrateConfirmPasskeyFinish({
id: credential.id,
rawId: bytesToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64url(response.clientDataJSON),
authenticatorData: bytesToBase64url(response.authenticatorData),
signature: bytesToBase64url(response.signature),
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
},
});
await api.migrateConfirmPasskeyFinish(await requestAssertion(options.publicKey));
await mig.reload();
});
}
+303
View File
@@ -0,0 +1,303 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { Identity, PasskeyCredential } from "@/lib/types";
import { Account } from "./Account";
// The Account page's changes to how the account signs in (delete or add a
// passkey, change the email) meet 403 reauth_required when the session has not
// proven a factor lately. These drive the page through that refusal: the check
// dialog, each factor, and the change running again afterwards.
const mocks = vi.hoisted(() => ({
passkeyList: vi.fn(),
passkeyDelete: vi.fn(),
passkeyRegisterBegin: vi.fn(),
listMySessions: vi.fn(),
emailStart: vi.fn(),
emailVerify: vi.fn(),
reauthStatus: vi.fn(),
reauthPasskeyBegin: vi.fn(),
reauthPasskeyFinish: vi.fn(),
reauthEmailStart: vi.fn(),
reauthEmailVerify: vi.fn(),
logout: vi.fn(),
refresh: vi.fn(),
credentialsGet: vi.fn(),
credentialsCreate: vi.fn(),
identity: null as Identity | null,
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
api: {
...actual.api,
linkStatus: () => Promise.resolve({ linked: true }),
migrateStatus: () => Promise.resolve({ active: false }),
passkeyList: mocks.passkeyList,
passkeyDelete: mocks.passkeyDelete,
passkeyRegisterBegin: mocks.passkeyRegisterBegin,
listMySessions: mocks.listMySessions,
emailStart: mocks.emailStart,
emailVerify: mocks.emailVerify,
reauthStatus: mocks.reauthStatus,
reauthPasskeyBegin: mocks.reauthPasskeyBegin,
reauthPasskeyFinish: mocks.reauthPasskeyFinish,
reauthEmailStart: mocks.reauthEmailStart,
reauthEmailVerify: mocks.reauthEmailVerify,
logout: mocks.logout,
},
};
});
vi.mock("@/lib/tier", () => ({
useTier: () => ({ identity: mocks.identity, refresh: mocks.refresh }),
}));
const refused = { status: 403, code: "reauth_required", message: "confirm it's you first" };
const laptop: PasskeyCredential = { id: "pk-1", name: "Laptop", created_at: "2026-03-01T10:00:00Z" };
const phone: PasskeyCredential = { id: "pk-2", name: "Phone", created_at: "2026-04-01T10:00:00Z" };
function identity(role: Identity["role"] = "user"): Identity {
return {
user_id: "u-1",
email: "[email protected]",
role,
is_admin: role !== "user",
is_owner: role === "owner",
email_verified: true,
};
}
const bytes = (s: string) => new TextEncoder().encode(s).buffer;
function renderAccount() {
return render(
<MemoryRouter>
<Account />
</MemoryRouter>,
);
}
const checkDialog = () => screen.findByRole("dialog", { name: "Confirm it's you" });
async function askToDeleteLaptop() {
await userEvent.click(await screen.findByRole("button", { name: "Delete passkey “Laptop”" }));
const confirmDelete = screen.getByRole("dialog", { name: "Delete this passkey?" });
await userEvent.click(within(confirmDelete).getByRole("button", { name: "Delete" }));
}
async function proveByEmail(dialog: HTMLElement) {
await userEvent.click(await within(dialog).findByRole("button", { name: "Email a code to [email protected]" }));
await userEvent.type(await within(dialog).findByLabelText("Code sent to [email protected]"), "123456");
await userEvent.click(within(dialog).getByRole("button", { name: "Confirm" }));
}
beforeEach(() => {
for (const fn of Object.values(mocks)) if (typeof fn === "function") fn.mockReset();
mocks.identity = identity();
mocks.listMySessions.mockResolvedValue([]);
mocks.reauthEmailStart.mockResolvedValue({ sent: true, expires_at: "2026-09-25T10:10:00Z" });
mocks.reauthEmailVerify.mockResolvedValue({ ok: true, until: "2026-09-25T10:05:00Z" });
mocks.reauthPasskeyFinish.mockResolvedValue({ ok: true, until: "2026-09-25T10:05:00Z" });
Object.defineProperty(navigator, "credentials", {
value: { get: mocks.credentialsGet, create: mocks.credentialsCreate },
configurable: true,
});
});
describe("Account re-authentication", () => {
it("asks for an email code before deleting a passkey, then deletes it", async () => {
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
mocks.passkeyDelete.mockRejectedValueOnce(refused).mockResolvedValue(undefined);
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
renderAccount();
await askToDeleteLaptop();
const dialog = await checkDialog();
expect(within(dialog).queryByRole("button", { name: "Use a passkey" })).toBeNull();
await proveByEmail(dialog);
expect(mocks.reauthEmailStart).toHaveBeenCalledTimes(1);
expect(mocks.reauthEmailVerify).toHaveBeenCalledWith("123456");
await waitFor(() => expect(mocks.passkeyDelete).toHaveBeenCalledTimes(2));
expect(mocks.passkeyDelete).toHaveBeenLastCalledWith("pk-1");
await waitFor(() => expect(screen.queryByRole("button", { name: "Delete passkey “Laptop”" })).toBeNull());
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
});
it("closing the check keeps the passkey and the delete dialog, with no error", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
mocks.passkeyDelete.mockRejectedValue(refused);
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
renderAccount();
await askToDeleteLaptop();
const dialog = await checkDialog();
await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Confirm it's you" })).toBeNull());
const confirmDelete = screen.getByRole("dialog", { name: "Delete this passkey?" });
expect(within(confirmDelete).queryByRole("alert")).toBeNull();
expect(mocks.passkeyDelete).toHaveBeenCalledTimes(1);
expect(mocks.passkeyList).toHaveBeenCalledTimes(1);
});
it("a wrong code keeps the check open with the reason and changes nothing", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
mocks.passkeyDelete.mockRejectedValue(refused);
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
mocks.reauthEmailVerify.mockRejectedValue({ status: 400, code: "invalid_code", message: "raw" });
renderAccount();
await askToDeleteLaptop();
const dialog = await checkDialog();
await proveByEmail(dialog);
const alert = await within(dialog).findByRole("alert");
expect(alert.textContent).toBe("That code is invalid or expired — request a fresh one and try again.");
expect(mocks.passkeyDelete).toHaveBeenCalledTimes(1);
});
it("proves with a passkey from the envelope the begin returns", async () => {
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
mocks.passkeyDelete.mockRejectedValueOnce(refused).mockResolvedValue(undefined);
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["passkey", "email"] });
mocks.reauthPasskeyBegin.mockResolvedValue({
publicKey: { challenge: "Y2hhbGxlbmdl", allowCredentials: [{ type: "public-key", id: "cGstMQ" }] },
});
mocks.credentialsGet.mockResolvedValue({
id: "cred-1",
rawId: bytes("raw"),
type: "public-key",
response: {
clientDataJSON: bytes("cd"),
authenticatorData: bytes("ad"),
signature: bytes("sig"),
userHandle: null,
},
});
renderAccount();
await askToDeleteLaptop();
const dialog = await checkDialog();
// Both factors are offered, the passkey first.
const buttons = within(dialog).getAllByRole("button").map((b) => b.textContent);
expect(buttons.indexOf("Use a passkey")).toBeLessThan(buttons.indexOf("Email a code to [email protected]"));
await userEvent.click(within(dialog).getByRole("button", { name: "Use a passkey" }));
await waitFor(() => expect(mocks.passkeyDelete).toHaveBeenCalledTimes(2));
const publicKey = mocks.credentialsGet.mock.calls[0][0].publicKey;
expect(new TextDecoder().decode(publicKey.challenge)).toBe("challenge");
expect(new TextDecoder().decode(publicKey.allowCredentials[0].id)).toBe("pk-1");
expect(mocks.reauthPasskeyFinish).toHaveBeenCalledWith({
id: "cred-1",
rawId: "cmF3",
type: "public-key",
response: { clientDataJSON: "Y2Q", authenticatorData: "YWQ", signature: "c2ln", userHandle: null },
});
expect(mocks.reauthEmailStart).not.toHaveBeenCalled();
});
it("offers operators a fresh sign-in instead of an email code", async () => {
mocks.identity = identity("admin");
mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
mocks.passkeyDelete.mockRejectedValue(refused);
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["sign_in"] });
mocks.logout.mockResolvedValue(undefined);
renderAccount();
await askToDeleteLaptop();
const dialog = await checkDialog();
expect(
within(dialog).getByText("Operator accounts can also sign out and sign in again. A fresh sign-in counts for 5 minutes."),
).toBeTruthy();
expect(within(dialog).queryByRole("button", { name: /Email a code/ })).toBeNull();
await userEvent.click(within(dialog).getByRole("button", { name: "Sign out and sign in again" }));
expect(mocks.logout).toHaveBeenCalledTimes(1);
await waitFor(() => expect(mocks.refresh).toHaveBeenCalled());
});
it("goes straight on when the session was proven meanwhile", async () => {
mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
mocks.passkeyDelete.mockRejectedValueOnce(refused).mockResolvedValue(undefined);
mocks.reauthStatus.mockResolvedValue({ needed: false, until: "2026-09-25T10:05:00Z", factors: ["email"] });
renderAccount();
await askToDeleteLaptop();
await waitFor(() => expect(mocks.passkeyDelete).toHaveBeenCalledTimes(2));
expect(mocks.reauthEmailStart).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("after the check, adding a passkey waits for Continue instead of starting the browser prompt", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [laptop] });
mocks.passkeyRegisterBegin.mockRejectedValue(refused);
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
renderAccount();
await userEvent.click(await screen.findByRole("button", { name: "Add Passkey" }));
const register = screen.getByRole("dialog", { name: "Add Passkey" });
await userEvent.type(within(register).getByLabelText("Device Nickname"), "Phone");
await userEvent.click(within(register).getByRole("button", { name: "Continue" }));
await proveByEmail(await checkDialog());
const status = await within(screen.getByRole("dialog", { name: "Add Passkey" })).findByRole("status");
expect(status.textContent).toBe("Confirmed. Press Continue to add the passkey.");
expect(mocks.passkeyRegisterBegin).toHaveBeenCalledTimes(1);
expect(mocks.credentialsCreate).not.toHaveBeenCalled();
expect(within(screen.getByRole("dialog", { name: "Add Passkey" })).getByRole("button", { name: "Continue" })).toHaveProperty(
"disabled",
false,
);
});
it("changes a verified email after the check and refreshes the devices it signs out", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [] });
mocks.emailStart.mockRejectedValueOnce(refused).mockResolvedValue({ sent: true, expires_at: "2026-09-25T10:10:00Z" });
mocks.emailVerify.mockResolvedValue({ verified: true, email: "[email protected]" });
mocks.reauthStatus.mockResolvedValue({ needed: true, factors: ["email"] });
renderAccount();
await userEvent.click(await screen.findByRole("button", { name: "Change email" }));
expect(
screen.getByText(
"Enter the new address and we'll send it a code. Once it's verified, sign-in codes go there, the old address gets a notice, and your other devices are signed out.",
),
).toBeTruthy();
const address = screen.getByPlaceholderText("[email protected]");
expect((address as HTMLInputElement).value).toBe("");
await userEvent.type(address, "[email protected]");
await userEvent.click(screen.getByRole("button", { name: "Send Code" }));
await proveByEmail(await checkDialog());
await waitFor(() => expect(mocks.emailStart).toHaveBeenCalledTimes(2));
expect(mocks.emailStart).toHaveBeenLastCalledWith("[email protected]");
expect(await screen.findByText(/Verification code sent\./)).toBeTruthy();
await userEvent.type(screen.getByPlaceholderText("6-digit code"), "654321");
await userEvent.click(screen.getByRole("button", { name: "Verify" }));
expect(mocks.emailVerify).toHaveBeenCalledWith("654321");
await waitFor(() => expect(mocks.listMySessions).toHaveBeenCalledTimes(2));
expect(mocks.refresh).toHaveBeenCalled();
});
it("cancelling a change of email returns to the verified address", async () => {
mocks.passkeyList.mockResolvedValue({ credentials: [] });
renderAccount();
await userEvent.click(await screen.findByRole("button", { name: "Change email" }));
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.getByText("[email protected]")).toBeTruthy();
expect(screen.queryByPlaceholderText("[email protected]")).toBeNull();
expect(mocks.emailStart).not.toHaveBeenCalled();
});
});
+4 -65
View File
@@ -9,9 +9,9 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTier } from "@/lib/tier";
import { loginReturnPath } from "@/lib/auth";
import { api, clientError, humanizeError } from "@/lib/api";
import { api, humanizeError } from "@/lib/api";
import { loadConfig } from "@/lib/config";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
import { requestAssertion } from "@/lib/passkey";
import { InlineError } from "@/components/MessageLine";
import { formatCountdown, opLoginDeadline, useOpLoginPoll } from "@/lib/opLoginPoll";
@@ -143,41 +143,10 @@ export function Login() {
const identifier = email.trim();
try {
let assertion: any;
if (!identifier) {
// Discoverable (Usernameless) passkey login
const options = await api.authPasskeyDiscoverableBegin();
const publicKey: PublicKeyCredentialRequestOptions = {
...options.publicKey,
challenge: base64urlToBytes(options.publicKey.challenge),
allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
...cred,
id: base64urlToBytes(cred.id),
})),
};
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential;
if (!credential) {
throw clientError("passkey_no_credential");
}
const response = credential.response as AuthenticatorAssertionResponse;
assertion = {
id: credential.id,
rawId: bytesToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64url(response.clientDataJSON),
authenticatorData: bytesToBase64url(response.authenticatorData),
signature: bytesToBase64url(response.signature),
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
},
};
await api.authPasskeyDiscoverableFinish(options.login_id, assertion);
await api.authPasskeyDiscoverableFinish(options.login_id, await requestAssertion(options.publicKey));
} else {
// Email-first passkey login
if (!identifier.includes("@")) {
@@ -185,37 +154,7 @@ export function Login() {
}
const options = await api.authPasskeyLoginBegin(identifier);
const publicKey: PublicKeyCredentialRequestOptions = {
...options,
challenge: base64urlToBytes(options.challenge),
allowCredentials: options.allowCredentials?.map((cred: any) => ({
...cred,
id: base64urlToBytes(cred.id),
})),
};
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential;
if (!credential) {
throw clientError("passkey_no_credential");
}
const response = credential.response as AuthenticatorAssertionResponse;
assertion = {
id: credential.id,
rawId: bytesToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64url(response.clientDataJSON),
authenticatorData: bytesToBase64url(response.authenticatorData),
signature: bytesToBase64url(response.signature),
userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null,
},
};
await api.authPasskeyLoginFinish(identifier, assertion);
await api.authPasskeyLoginFinish(identifier, await requestAssertion(options));
}
await refresh();