feat: let the confirm-it-is-you prompt offer only the methods an account has, adding passkey, Google/Apple/SSO popup and native iOS Apple/Google re-authentication bound to the session and linked identity, across backend, dashboard, admin, iOS and docs

This commit is contained in:
Matthew Meszaros
2026-10-15 15:26:25 +02:00
parent a0bbddac51
commit 0ca6e7e642
31 changed files with 2077 additions and 242 deletions
+49 -24
View File
@@ -7,7 +7,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { reauth } from "@/lib/api/client/auth";
import { reauth, reauthMethods } from "@/lib/api/client/auth";
import { APIError } from "@/lib/api/client";
interface ReauthDetail {
@@ -22,6 +22,8 @@ export function ReauthDialog() {
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
// Null while loading or when the lookup failed, which offers both fields.
const [methods, setMethods] = useState<{ password: boolean; totp: boolean } | null>(null);
useEffect(() => {
const handler = (e: Event) => {
@@ -31,7 +33,9 @@ export function ReauthDialog() {
setBusy(false);
const detail = (e as CustomEvent<ReauthDetail>).detail;
detail.ack?.();
setMethods(null);
setPending(detail);
reauthMethods().then(setMethods, () => setMethods(null));
};
window.addEventListener("reauth-required", handler);
return () => window.removeEventListener("reauth-required", handler);
@@ -62,6 +66,17 @@ export function ReauthDialog() {
}
}
const showPassword = methods === null || methods.password;
const showCode = methods === null || methods.totp;
const description =
showPassword && showCode
? "This change needs a fresh check. Enter your password, or a code from your authenticator."
: showPassword
? "This change needs a fresh check. Enter your password."
: showCode
? "This change needs a fresh check. Enter a code from your authenticator app."
: "This account has no password or authenticator code to confirm with here. Sign in to the admin panel again and repeat this within five minutes.";
return (
<DialogPrimitive.Root open={pending !== null} onOpenChange={(open) => !open && cancel()}>
<DialogPrimitive.Portal>
@@ -82,37 +97,47 @@ export function ReauthDialog() {
Confirm it is you
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-[13px] leading-relaxed text-muted-foreground">
This change needs a fresh check. Enter your password, or a code from your
authenticator.
{description}
</DialogPrimitive.Description>
</div>
</div>
<div className="grid gap-2.5">
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
autoComplete="current-password"
autoFocus
/>
<Input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Two-factor or recovery code"
autoComplete="one-time-code"
data-ph-mask=""
/>
</div>
{showPassword || showCode ? (
<div className="grid gap-2.5">
{showPassword && (
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
aria-label="Password"
autoComplete="current-password"
autoFocus
/>
)}
{showCode && (
<Input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Two-factor or recovery code"
aria-label="Two-factor or recovery code"
autoComplete="one-time-code"
autoFocus={!showPassword}
data-ph-mask=""
/>
)}
</div>
) : null}
{error && <p className="text-sm text-[var(--admin-danger)]">{error}</p>}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={cancel}>
Cancel
</Button>
<Button type="submit" disabled={busy || (!password && !code)}>
{busy ? "Checking…" : "Confirm"}
</Button>
{(showPassword || showCode) && (
<Button type="submit" disabled={busy || (!password && !code)}>
{busy ? "Checking…" : "Confirm"}
</Button>
)}
</div>
</form>
</DialogPrimitive.Content>
+10
View File
@@ -73,6 +73,16 @@ export function logout(): Promise<void> {
});
}
// Which of password and authenticator code this account can confirm with.
export function reauthMethods(): Promise<{ password: boolean; totp: boolean }> {
return Request<{ password: boolean; totp: boolean }>({
method: "GET",
url: "/v1/auth/reauth/methods",
authorization: true,
skipReauthPrompt: true,
});
}
// Re-proves the operator behind the session; the changes gated on a recent
// confirmation (join tokens, admin grants, workspace archives) wait for it.
export function reauth(input: { password?: string; code?: string }): Promise<{ valid_for_seconds: number }> {
+26 -2
View File
@@ -525,6 +525,10 @@ With [multiple dashboards sharing a backend](/development/configuration/#google-
- `POST /auth/login`, `/auth/login/confirm`, `/auth/register`, `/auth/register/confirm`, `/auth/refresh`, `/auth/reset-password`, `/auth/reset-password/confirm`
- `POST /auth/reauth` (re-prove the account holder behind a live session, for the changes that require a recent confirmation)
- `GET /auth/reauth/methods` (which ways this account can confirm: password, two-factor code, passkey, and the linked sign-in providers it can re-verify)
- `POST /auth/reauth/passkey/begin`, `/auth/reauth/passkey/finish` (confirm with one of the account's own passkeys)
- `POST /auth/reauth/sso/begin`, `/auth/reauth/sso/finish` (confirm through the Google, Apple or single sign-on identity linked to the account, in a browser window)
- `POST /auth/reauth/native/begin`, `/auth/reauth/native` (confirm with a native Sign in with Apple or Google ID token carrying the session's nonce)
- `GET /auth/config` (public deployment capabilities: which sign-in methods this backend has enabled, whether a login code step follows, whether signups are open, whether the instance still needs claiming)
- `POST /auth/app-attest/challenge`, `/auth/app-attest/keys` (the native app's [App Attest](/api/reference/account-org/#app-attest-native-apps) challenge and one-time key registration, on their own per-IP budget `APP_ATTEST_IP_RATE_LIMIT`)
- `GET /auth/instance` (JWT only: the running Warmbly version of a self-hosted instance and whether a newer release exists, for the dashboard's version pill; a hosted deployment answers `self_hosted: false` and nothing else)
@@ -552,11 +556,31 @@ These changes require the session to have confirmed the account holder within th
| Record, use or remove an admin grant over a whole domain | `POST /emails/grants/google/finish`, `POST /emails/grants/microsoft/finish`, `POST /emails/grants/:id/connect`, `DELETE /emails/grants/:id` |
| Admin panel: issue a fleet join token, grant admin permissions, export or import any workspace | `POST /admin/fleet/join-token`, `POST /admin/admins/:userId/grant`, `POST /admin/organizations/:id/exports`, `POST /admin/organizations/:id/imports` |
Each either hands out a credential that outlives the session that created it, reveals a secret, changes who can get in, cannot be reversed by the person it was done to, or reaches a whole domain's mail. Without a confirmation they answer `403` with code `reauth_required`; confirm with `POST /auth/reauth` (password or a current two-factor code) and retry. See [error codes](/api/error-codes/#confirmation-required).
Each either hands out a credential that outlives the session that created it, reveals a secret, changes who can get in, cannot be reversed by the person it was done to, or reaches a whole domain's mail. Without a confirmation they answer `403` with code `reauth_required`; confirm and retry. See [error codes](/api/error-codes/#confirmation-required).
`GET /auth/reauth/methods` says which ways the account can confirm, so a client offers only those:
```json
{
"password": false,
"totp": false,
"passkey": true,
"providers": [{ "id": "google", "label": "Google" }],
"native_providers": ["google"],
"valid_for_seconds": 300
}
```
- **Password or code:** `POST /auth/reauth` with `password` or a current two-factor or recovery `code`.
- **Passkey:** `POST /auth/reauth/passkey/begin` returns WebAuthn request `options` limited to the account's own passkeys; pass `options.publicKey` to the browser and send the assertion to `POST /auth/reauth/passkey/finish` as `credential`. The authenticator must verify the user.
- **Linked identity in a browser:** `POST /auth/reauth/sso/begin` with `provider` (one of `providers[].id`) returns `url` and `binding`. Open `url` in a window and keep `binding`. The provider returns that window to the dashboard's `/reauth/complete` with a single-use `code`, which only the session that began the flow can redeem: `POST /auth/reauth/sso/finish` with `code` and `binding`. The identity the provider verifies must already be linked to the account; another account is refused with `reauth_identity_mismatch`, never matched by email address.
- **Native app:** `POST /auth/reauth/native/begin` returns a `nonce`. Pass it unchanged as the Sign in with Apple request nonce or the Google authorization `nonce`, then send the ID token to `POST /auth/reauth/native` with `provider` (`apple` or `google`). The nonce is single-use and bound to the session.
Each succeeds with `{ "valid_for_seconds": 300 }` and confirms only the session that made the call.
This applies to session callers. An API key or OAuth token has no session to confirm and is not the threat here, so it passes straight through to the route's permission gate.
A completed sign-in counts as a confirmation for the first five minutes of the session. An account with neither a password nor two-factor authentication has nothing else to confirm with, so after those five minutes `POST /auth/reauth` answers it `400` with code `reauth_no_factor` and it signs in again. Once 2FA is on, it confirms with an authenticator code like any other account.
A completed sign-in counts as a confirmation for the first five minutes of the session. `POST /auth/reauth` answers an account with neither a password nor two-factor authentication `400` with code `reauth_no_factor`; it confirms with a passkey or its linked identity instead. An account with none of those signs in again.
`GET /auth/config` gained two fields: `invites_required` (boolean, true when an invitation token is needed to create an account) and `docs_url` (string, the deployment's link to the accounts and access documentation, for a client to surface next to a refusal).
+5 -3
View File
@@ -546,13 +546,15 @@ Signup and invitation refusals carry their own `code`, so a client can branch on
|--------|--------|---------|
| `reauth_required` | 403 | The action needs a proof of identity newer than the session. Confirm with `POST /v1/auth/reauth`, then retry |
| `reauth_limited` | 400 | Too many failed confirmations for this account within the hour. Wait, or sign out and sign in again: a sign-in counts as a confirmation for five minutes |
| `reauth_no_factor` | 400 | The account has neither a password nor two-factor authentication, so there is nothing to confirm with. A sign-in counts as a confirmation for five minutes, so sign out and sign in again, then repeat the action; turning on two-factor authentication gives the account a way to confirm without signing in again |
| `reauth_no_factor` | 400 | `POST /auth/reauth` was sent a password or code for an account that has neither. Confirm with a passkey or the linked sign-in provider listed by `GET /auth/reauth/methods`; an account with none of those signs out and in again (a sign-in counts as a confirmation for five minutes) |
| `reauth_identity_mismatch` | 400 | The Google, Apple or single sign-on account verified during a confirmation is not the one linked to this Warmbly account. Continue with the account you sign in with |
| `reauth_expired` | 400 | The confirmation's code or nonce expired or was already used. Start the confirmation again |
| `admin_mfa_required` | 403 | An admin route was reached by a session that did not present a second factor. Turn on 2FA or add a passkey, then sign in again |
| `passkey_user_verification_required` | 400 | A passkey sign-in came from an authenticator that did not verify the user with a PIN or biometric. Set a PIN on the security key, or sign in another way |
| `two_fa_invalid_code` | 400 | The authenticator or recovery code did not match. Wait for a fresh code and try again; the login challenge allows five attempts per pending session |
| `password_changed_sign_in_again` | 409 | `POST /auth/me/password` stored the new password but could not issue the calling device a new session. Every earlier token is invalid; discard them and sign in again with the new password |
`reauth_required` guards the changes that hand out a durable credential, reveal a signing secret, change who can get in, cannot be undone, or reach a whole domain's mail. The full list is under [actions that need a recent confirmation](/api/endpoints/#actions-that-need-a-recent-confirmation). Confirm with a password or a current two-factor code:
`reauth_required` guards the changes that hand out a durable credential, reveal a signing secret, change who can get in, cannot be undone, or reach a whole domain's mail. The full list is under [actions that need a recent confirmation](/api/endpoints/#actions-that-need-a-recent-confirmation). List the ways the account can confirm with `GET /v1/auth/reauth/methods` (a passkey and a linked Google, Apple or single sign-on identity are among them; see [endpoints](/api/endpoints/#actions-that-need-a-recent-confirmation)). With a password or a current two-factor code:
```bash
curl -X POST "https://api.warmbly.com/v1/auth/reauth" \
@@ -567,7 +569,7 @@ curl -X POST "https://api.warmbly.com/v1/auth/reauth" \
The confirmation is recorded on the session and lasts for the window returned, so a run of related changes only asks once.
Turning on two-factor authentication (`POST /auth/2fa/enroll/start` and `/confirm`) is guarded too, since a confirmed authenticator is itself a way to confirm. An account with a password confirms as above. An account with neither a password nor 2FA (created through Google, Apple or single sign-on) has nothing to confirm with, so like every confirmed action it is allowed for five minutes after a sign-in; an older session gets `reauth_no_factor` and the fix is to sign out and sign in again. API keys and OAuth tokens have no session to confirm, so these routes are reachable only with a signed-in session; that is deliberate for key creation, which otherwise lets one leaked key mint more.
Turning on two-factor authentication (`POST /auth/2fa/enroll/start` and `/confirm`) is guarded too, since a confirmed authenticator is itself a way to confirm. An account with a password confirms as above. An account with neither a password nor 2FA (created through Google, Apple or single sign-on) confirms with its passkey or linked identity. One with none of those is allowed for five minutes after a sign-in like every confirmed action; an older session gets `reauth_no_factor` and the fix is to sign out and sign in again. API keys and OAuth tokens have no session to confirm, so these routes are reachable only with a signed-in session; that is deliberate for key creation, which otherwise lets one leaked key mint more.
```json
{
@@ -777,7 +777,7 @@ Auth: Session only (not available to API keys).
Generates a TOTP secret and the `otpauth://` provisioning URI (returned once). The client renders the URI as a QR code; the remaining fields spell out the same parameters for manual entry.
Requires a recent confirmation (`POST /auth/reauth`), like confirming enrollment below; without one it answers `403 reauth_required`. An account with neither a password nor 2FA cannot confirm, so it is accepted instead from a session signed in within the last five minutes, and an older session answers `400 reauth_no_factor`.
Requires a recent confirmation (`POST /auth/reauth`), like confirming enrollment below; without one it answers `403 reauth_required`. An account with neither a password nor 2FA confirms with a passkey or its linked sign-in provider (`GET /auth/reauth/methods`). One with none of those is accepted from a session signed in within the last five minutes, and an older session answers `400 reauth_no_factor`.
Auth: Session only (not available to API keys).
+9 -2
View File
@@ -135,9 +135,16 @@ Some changes ask you to confirm it is you even though you are already signed in:
- transferring a workspace to someone else
- scheduling a workspace or account for deletion
Enter your password or a code from your authenticator, whichever your account has. The confirmation lasts five minutes, so a run of related changes only asks once.
The prompt offers only what your account can actually use:
If you signed up with Google, Apple or your company's single sign-on, your account may have neither a password nor 2FA yet. There is nothing to confirm with in that case, so your sign-in is the confirmation: 2FA can be turned on within five minutes of signing in. If more time has passed, sign out, sign in again, and set it up straight away. From then on you confirm with your authenticator.
- **a passkey**, if you have added one
- **Continue with Google, Apple or your single sign-on**, if you sign in that way. A small window opens at your provider, and the account you pick there has to be the one linked to Warmbly. Allow pop-ups for the dashboard if your browser blocks the window
- **your password**, if your account has one
- **a code from your authenticator** or a recovery code, if two-factor authentication is on
The confirmation lasts five minutes, so a run of related changes only asks once. In the iOS app the same prompt offers Continue with Apple and Continue with Google alongside your password and authenticator code.
An account with none of these (for example one created through a sign-in provider this server no longer offers) is told so instead of being shown a field it cannot fill. Your sign-in is the confirmation in that case: sign in again and repeat the change within five minutes, then add a passkey or turn on 2FA so it does not happen again.
This exists because the things on that list either hand out a credential that outlives the session that made it, reveal a secret, change who can get in, or cannot be undone from your side. Someone who got hold of a signed-in browser should not be able to do any of them without knowing something you know.
+22 -3
View File
@@ -102,7 +102,16 @@ func (h *Handler) ssoCallback(c *gin.Context, in auth.SSOCallback) {
// A provider-side refusal arrives as error=, not as a failed exchange.
// A closed consent screen is not a failure, so it returns quietly.
if e := formOrQuery(c, "error"); e != "" {
if e == "access_denied" || e == "user_cancelled_authorize" {
cancelled := e == "access_denied" || e == "user_cancelled_authorize"
if auth.IsReauthState(in.State) {
if cancelled {
c.Redirect(http.StatusFound, base+"/reauth/complete?cancelled=1")
return
}
c.Redirect(http.StatusFound, base+"/reauth/complete?error="+url.QueryEscape(e))
return
}
if cancelled {
c.Redirect(http.StatusFound, base+"/auth/login")
return
}
@@ -113,13 +122,23 @@ func (h *Handler) ssoCallback(c *gin.Context, in auth.SSOCallback) {
in.IPAddress = c.ClientIP()
in.UserAgent = c.Request.UserAgent()
handoff, err := h.AuthService.SSOCallbackComplete(c.Request.Context(), in)
res, err := h.AuthService.SSOCallbackComplete(c.Request.Context(), in)
// A re-authentication runs in a window the dashboard opened, so it lands
// on the page that reports back to that window, never on sign-in.
if res != nil && res.Reauth {
if err != nil {
c.Redirect(http.StatusFound, base+"/reauth/complete?error="+url.QueryEscape(err.Message))
return
}
c.Redirect(http.StatusFound, base+"/reauth/complete?code="+url.QueryEscape(res.Code))
return
}
if err != nil {
c.Redirect(http.StatusFound, base+"/auth/login?sso_error="+url.QueryEscape(err.Message))
return
}
c.Redirect(http.StatusFound, base+"/auth/sso?code="+url.QueryEscape(handoff))
c.Redirect(http.StatusFound, base+"/auth/sso?code="+url.QueryEscape(res.Code))
}
// formOrQuery reads a parameter from either the posted form or the query
+223 -18
View File
@@ -1,6 +1,8 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"time"
@@ -53,12 +55,11 @@ func (h *Handler) Reauth(c *gin.Context) {
}
// An account created through Google, Apple or SSO has no password, and
// until it enrols 2FA it has no second factor either. There is nothing for
// it to confirm with, and silently accepting the live session instead would
// turn a stolen token into a permanent API key. Say what to do rather than
// refusing a credential the account does not have.
if !h.hasReauthFactor(c, uid) {
errx.Handle(c, errReauthNoFactor())
// until it enrols 2FA it has no code either. Accepting the live session
// instead would turn a stolen token into a permanent API key, so point it
// at what it does have rather than refusing a credential it lacks.
if m := h.reauthMethods(c, uid); !m.Password && !m.TOTP {
errx.Handle(c, errReauthNoFactor(m.any()))
return
}
@@ -86,17 +87,25 @@ func (h *Handler) Reauth(c *gin.Context) {
}
h.AuthService.ClearReauthFailures(ctx, uid)
h.stampReauth(c, session)
}
// stampReauth records the confirmation on the session and answers the window.
func (h *Handler) stampReauth(c *gin.Context, session *models.Session) {
if xerr := h.TokenService.StampReauth(c.Request.Context(), session.ID); xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, reauthResponse{ValidForSeconds: int(token.ReauthWindow.Seconds())})
}
// errReauthNoFactor answers an account with nothing to confirm with. Its way to
// a factor is enrolling 2FA, which a fresh sign-in unlocks.
func errReauthNoFactor() *errx.Error {
// errReauthNoFactor answers a password or code for an account that has
// neither. hasOther means it can confirm with a passkey or a linked identity.
func errReauthNoFactor(hasOther bool) *errx.Error {
if hasOther {
return errx.NewWithIdentifier(errx.BadRequest, "reauth_no_factor",
"This account has no password or authenticator code. Confirm with your passkey or the account you sign in with.")
}
return errx.NewWithIdentifier(errx.BadRequest, "reauth_no_factor",
"This account has no password and no two-factor authentication, so there is nothing to confirm with. "+
"Sign out and sign in again, then repeat the action within five minutes of signing in. Turning on two-factor authentication under Settings > Security gives the account a way to confirm without signing in again.")
@@ -110,7 +119,7 @@ func (h *Handler) allowEnrollment(c *gin.Context, uid uuid.UUID) bool {
errx.JSON(c, errx.ErrUnauthorized)
return false
}
if xerr := enrollmentGate(session, time.Now(), func() bool { return h.hasReauthFactor(c, uid) }); xerr != nil {
if xerr := enrollmentGate(session, time.Now(), func() bool { return h.reauthMethods(c, uid).any() }); xerr != nil {
errx.JSON(c, xerr)
return false
}
@@ -131,21 +140,217 @@ func enrollmentGate(session *models.Session, now time.Time, hasFactor func() boo
if now.Sub(session.CreatedAt) <= token.ReauthWindow {
return nil
}
return errReauthNoFactor()
return errReauthNoFactor(false)
}
// hasReauthFactor reports whether the account has anything to re-authenticate
// with: a stored password, or an enrolled authenticator.
func (h *Handler) hasReauthFactor(c *gin.Context, uid uuid.UUID) bool {
// reauthProvider is one linked identity the browser can re-verify with.
type reauthProvider struct {
ID string `json:"id"`
Label string `json:"label"`
}
// reauthMethodsResponse is every way this account can confirm it is the
// holder, so a client offers only those.
type reauthMethodsResponse struct {
Password bool `json:"password"`
TOTP bool `json:"totp"`
Passkey bool `json:"passkey"`
// Providers run in a browser window; NativeProviders take an ID token
// from the native app.
Providers []reauthProvider `json:"providers"`
NativeProviders []string `json:"native_providers"`
ValidForSeconds int `json:"valid_for_seconds"`
}
func (m reauthMethodsResponse) any() bool {
return m.Password || m.TOTP || m.Passkey || len(m.Providers) > 0 || len(m.NativeProviders) > 0
}
// reauthMethods resolves what the account can confirm with. A lookup that
// fails leaves that method out rather than failing the whole answer.
func (h *Handler) reauthMethods(c *gin.Context, uid uuid.UUID) reauthMethodsResponse {
ctx := c.Request.Context()
m := reauthMethodsResponse{
Providers: []reauthProvider{},
NativeProviders: []string{},
ValidForSeconds: int(token.ReauthWindow.Seconds()),
}
if h.TwoFAService != nil {
if enabled, err := h.TwoFAService.IsEnabled(ctx, uid); err == nil && enabled {
return true
m.TOTP = true
}
}
hash, xerr := h.AuthService.PasswordHashFor(ctx, uid)
return xerr == nil && hash != ""
if hash, xerr := h.AuthService.PasswordHashFor(ctx, uid); xerr == nil && hash != "" {
m.Password = true
}
if h.PasskeyService != nil && h.PasskeysUsable {
if has, xerr := h.PasskeyService.HasCredentials(ctx, uid); xerr == nil && has {
m.Passkey = true
}
}
if browser, native, xerr := h.AuthService.ReauthIdentities(ctx, uid); xerr == nil {
labels := h.AuthService.FederatedProviderLabels()
for _, id := range browser {
m.Providers = append(m.Providers, reauthProvider{ID: id, Label: labels[id]})
}
m.NativeProviders = append(m.NativeProviders, native...)
}
return m
}
// sessionUser is the session and user id every re-authentication step acts on.
func sessionUser(c *gin.Context) (*models.Session, uuid.UUID, bool) {
session := middleware.GetSession(c)
if session == nil {
errx.Handle(c, errx.ErrUnauthorized)
return nil, uuid.Nil, false
}
uid, err := uuid.Parse(middleware.GetUserID(c))
if err != nil || uid != session.UserID {
errx.Handle(c, errx.ErrUnauthorized)
return nil, uuid.Nil, false
}
return session, uid, true
}
// ReauthMethods lists the ways the signed-in account can confirm it is you.
func (h *Handler) ReauthMethods(c *gin.Context) {
_, uid, ok := sessionUser(c)
if !ok {
return
}
c.JSON(http.StatusOK, h.reauthMethods(c, uid))
}
// ReauthPasskeyBegin asks for one of the account's own passkeys.
func (h *Handler) ReauthPasskeyBegin(c *gin.Context) {
session, uid, ok := sessionUser(c)
if !ok {
return
}
if h.PasskeyService == nil || !h.PasskeysUsable {
errx.Handle(c, errx.ErrPasskeyNone)
return
}
options, xerr := h.PasskeyService.BeginReauth(c.Request.Context(), uid, session.ID)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"options": options})
}
type reauthPasskeyFinishRequest struct {
Credential json.RawMessage `json:"credential"`
}
// ReauthPasskeyFinish verifies the assertion and stamps the session.
func (h *Handler) ReauthPasskeyFinish(c *gin.Context) {
session, uid, ok := sessionUser(c)
if !ok {
return
}
if h.PasskeyService == nil {
errx.Handle(c, errx.ErrPasskeyNone)
return
}
var req reauthPasskeyFinishRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
errx.Handle(c, errx.ErrPasskey)
return
}
if xerr := h.PasskeyService.FinishReauth(c.Request.Context(), uid, session.ID, req.Credential); xerr != nil {
errx.Handle(c, xerr)
return
}
h.stampReauth(c, session)
}
type reauthSSOBeginRequest struct {
Provider string `json:"provider" binding:"required"`
}
// ReauthSSOBegin starts a provider authorization for the account's linked
// identity. The client opens the URL in a window and keeps the binding.
func (h *Handler) ReauthSSOBegin(c *gin.Context) {
session, uid, ok := sessionUser(c)
if !ok {
return
}
var req reauthSSOBeginRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.Handle(c, errx.InvalidBody(err))
return
}
redirect, xerr := h.AuthService.ReauthSSOBegin(c.Request.Context(), req.Provider, uid, session.ID)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, redirect)
}
type reauthSSOFinishRequest struct {
Code string `json:"code"`
Binding string `json:"binding"`
}
// ReauthSSOFinish redeems the callback's code with the binding from begin.
func (h *Handler) ReauthSSOFinish(c *gin.Context) {
session, uid, ok := sessionUser(c)
if !ok {
return
}
var req reauthSSOFinishRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.Handle(c, errx.InvalidBody(err))
return
}
if xerr := h.AuthService.ReauthSSOFinish(c.Request.Context(), req.Code, req.Binding, uid, session.ID); xerr != nil {
errx.Handle(c, xerr)
return
}
h.stampReauth(c, session)
}
// ReauthNativeBegin returns the nonce the app passes to Apple or Google.
func (h *Handler) ReauthNativeBegin(c *gin.Context) {
session, _, ok := sessionUser(c)
if !ok {
return
}
nonce, xerr := h.AuthService.ReauthNativeBegin(c.Request.Context(), session.ID)
if xerr != nil {
errx.Handle(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"nonce": nonce})
}
type reauthNativeRequest struct {
Provider string `json:"provider" binding:"required"`
IDToken string `json:"id_token" binding:"required"`
}
// ReauthNative accepts a native Apple or Google ID token carrying the nonce.
func (h *Handler) ReauthNative(c *gin.Context) {
session, uid, ok := sessionUser(c)
if !ok {
return
}
var req reauthNativeRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.Handle(c, errx.InvalidBody(err))
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), authRequestTimeout)
defer cancel()
if xerr := h.AuthService.ReauthNativeVerify(ctx, req.Provider, req.IDToken, uid, session.ID); xerr != nil {
errx.Handle(c, xerr)
return
}
h.stampReauth(c, session)
}
// reauthProofValid checks whichever factor the caller offered.
+9
View File
@@ -496,6 +496,15 @@ func Run(
// Re-prove the account holder behind a live session. What the routes
// marked RequireFreshAuth below are waiting for.
protectedAuth.POST("/reauth", h.Reauth)
// The other ways to confirm, each answering only for the account's
// own passkeys and linked identities, and stamping only this session.
protectedAuth.GET("/reauth/methods", h.ReauthMethods)
protectedAuth.POST("/reauth/passkey/begin", h.ReauthPasskeyBegin)
protectedAuth.POST("/reauth/passkey/finish", h.ReauthPasskeyFinish)
protectedAuth.POST("/reauth/sso/begin", h.ReauthSSOBegin)
protectedAuth.POST("/reauth/sso/finish", h.ReauthSSOFinish)
protectedAuth.POST("/reauth/native/begin", h.ReauthNativeBegin)
protectedAuth.POST("/reauth/native", h.ReauthNative)
// Passkey enrollment + management require an authenticated session.
//
+216
View File
@@ -0,0 +1,216 @@
package auth
import (
"context"
"crypto/subtle"
"encoding/json"
"strings"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/observability/errs"
"github.com/warmbly/warmbly/internal/pkg/idtoken"
)
// A signed-in account with no password re-proves itself the way it signs in:
// through the identity provider it is linked to. Unlike sign-in, nothing here
// resolves an account. The verified (issuer, subject) must already belong to
// the session's own user, so the email fallback never applies.
const ssoPurposeReauth = "reauth"
// reauthStatePrefix routes a provider callback to the re-authentication
// landing instead of the sign-in one. Hex states never contain "_".
const reauthStatePrefix = "ra_"
// IsReauthState reports whether a callback's state was minted by ReauthSSOBegin.
func IsReauthState(state string) bool { return strings.HasPrefix(state, reauthStatePrefix) }
// reauthHandoffTTL covers the popup's redirect, its message to the opener and
// the opener's finish call.
const reauthHandoffTTL = 2 * time.Minute
// reauthNonceTTL is how long the app has to finish the provider sheet.
const reauthNonceTTL = 10 * time.Minute
func reauthHandoffKey(code string) string { return "reauth_handoff:" + code }
func reauthNonceKey(sessionID uuid.UUID) string { return "reauth_nonce:" + sessionID.String() }
// reauthHandoff is a verified identity waiting for the session that began the
// flow to collect it.
type reauthHandoff struct {
Binding string `json:"binding"`
UserID uuid.UUID `json:"user_id"`
SessionID uuid.UUID `json:"session_id"`
}
// ReauthIdentities lists the providers the user can re-authenticate with:
// browser flows this deployment has configured and native ID tokens it can
// verify, each only where the account has an identity from that provider.
func (s *authService) ReauthIdentities(ctx context.Context, userID uuid.UUID) ([]string, []string, *errx.Error) {
if s.identities == nil {
return nil, nil, nil
}
linked, err := s.identities.ListForUser(ctx, userID)
if err != nil {
errs.CaptureException(err)
return nil, nil, errx.InternalError()
}
has := map[string]bool{}
for _, id := range linked {
has[id.Provider] = true
}
browser := []string{}
for _, name := range s.FederatedProviders() {
if has[name] {
browser = append(browser, name)
}
}
native := []string{}
if has[models.IdentityProviderApple] && s.appleIDTokens != nil {
native = append(native, models.IdentityProviderApple)
}
if has[models.IdentityProviderGoogle] && s.googleIDTokens != nil {
native = append(native, models.IdentityProviderGoogle)
}
return browser, native, nil
}
// ReauthSSOBegin starts a provider authorization that re-proves the holder of
// one session rather than signing anyone in.
func (s *authService) ReauthSSOBegin(ctx context.Context, provider string, userID, sessionID uuid.UUID) (*SSORedirect, *errx.Error) {
p, ok := s.providers[provider]
if !ok || p == nil {
return nil, errx.ErrExternalProvider
}
return s.beginFlow(ctx, p, ssoFlow{
Provider: provider,
Purpose: ssoPurposeReauth,
UserID: userID,
SessionID: sessionID,
}, reauthStatePrefix)
}
// completeReauthFlow runs at the callback once the provider's claims are
// verified: the identity must be the flow user's own, and the result waits
// behind a single-use code only the initiating browser can redeem.
func (s *authService) completeReauthFlow(ctx context.Context, flow ssoFlow, claims *idtoken.Claims) (string, *errx.Error) {
if xerr := s.identityBelongsTo(ctx, claims, flow.UserID); xerr != nil {
return "", xerr
}
code, err := randomHex(32)
if err != nil {
errs.CaptureException(err)
return "", errx.InternalError()
}
payload, err := json.Marshal(reauthHandoff{Binding: flow.Binding, UserID: flow.UserID, SessionID: flow.SessionID})
if err != nil {
errs.CaptureException(err)
return "", errx.InternalError()
}
if err := s.cache.SetEx(ctx, reauthHandoffKey(code), payload, reauthHandoffTTL).Err(); err != nil {
errs.CaptureException(err)
return "", errx.InternalError()
}
return code, nil
}
// ReauthSSOFinish redeems the callback's code for the session that began the
// flow. The binding never left that browser, so a code forwarded elsewhere is
// worthless, and a wrong binding leaves the code for its rightful owner.
func (s *authService) ReauthSSOFinish(ctx context.Context, code, binding string, userID, sessionID uuid.UUID) *errx.Error {
if code == "" || binding == "" {
return errx.ErrReauthExpired
}
key := reauthHandoffKey(code)
raw, err := s.cache.Get(ctx, key).Bytes()
if err != nil || len(raw) == 0 {
return errx.ErrReauthExpired
}
var handoff reauthHandoff
if err := json.Unmarshal(raw, &handoff); err != nil {
errs.CaptureException(err)
return errx.InternalError()
}
if subtle.ConstantTimeCompare([]byte(handoff.Binding), []byte(binding)) != 1 ||
handoff.UserID != userID || handoff.SessionID != sessionID {
return errx.ErrSSOBrowser
}
// Del reports whether this call removed it, which makes redemption single-use.
if n, derr := s.cache.Del(ctx, key).Result(); derr != nil || n != 1 {
return errx.ErrReauthExpired
}
return s.refuseSuspended(ctx, userID)
}
// ReauthNativeBegin mints the nonce the app hands the provider, so the ID
// token it gets back is single-use and bound to this session.
func (s *authService) ReauthNativeBegin(ctx context.Context, sessionID uuid.UUID) (string, *errx.Error) {
nonce, err := randomHex(32)
if err != nil {
errs.CaptureException(err)
return "", errx.InternalError()
}
if err := s.cache.SetEx(ctx, reauthNonceKey(sessionID), nonce, reauthNonceTTL).Err(); err != nil {
errs.CaptureException(err)
return "", errx.InternalError()
}
return nonce, nil
}
// ReauthNativeVerify accepts a native Sign in with Apple or Google ID token
// carrying the session's nonce and an identity linked to the account.
func (s *authService) ReauthNativeVerify(ctx context.Context, provider, rawToken string, userID, sessionID uuid.UUID) *errx.Error {
var verifier IDTokenVerifier
switch provider {
case models.IdentityProviderApple:
verifier = s.appleIDTokens
case models.IdentityProviderGoogle:
verifier = s.googleIDTokens
}
if verifier == nil {
return errx.ErrExternalProvider
}
if rawToken == "" {
return errx.ErrExternalCode
}
// Spent before the token is checked, so one nonce answers one attempt.
nonce, err := s.cache.GetDel(ctx, reauthNonceKey(sessionID)).Result()
if err != nil || nonce == "" {
return errx.ErrReauthExpired
}
claims, verr := verifier.Verify(ctx, rawToken)
if verr != nil {
return errx.ErrExternalCode
}
if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(nonce)) != 1 {
return errx.ErrReauthExpired
}
if xerr := s.identityBelongsTo(ctx, claims, userID); xerr != nil {
return xerr
}
return s.refuseSuspended(ctx, userID)
}
// identityBelongsTo accepts only an identity already bound to userID.
func (s *authService) identityBelongsTo(ctx context.Context, claims *idtoken.Claims, userID uuid.UUID) *errx.Error {
if s.identities == nil || claims == nil || claims.Issuer == "" || claims.Subject == "" {
return errx.ErrReauthIdentityMismatch
}
owner, err := s.identities.FindUserByIdentity(ctx, claims.Issuer, claims.Subject)
if err != nil {
errs.CaptureException(err)
return errx.InternalError()
}
if owner == uuid.Nil || owner != userID {
return errx.ErrReauthIdentityMismatch
}
_ = s.identities.TouchLogin(ctx, claims.Issuer, claims.Subject)
return nil
}
+275
View File
@@ -0,0 +1,275 @@
package auth
import (
"context"
"encoding/json"
"errors"
"net/url"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/infrastructure/cache"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/idtoken"
"github.com/warmbly/warmbly/internal/repository"
)
// claimsProvider answers every exchange with fixed claims and records the nonce it was asked for.
type claimsProvider struct {
claims *idtoken.Claims
nonces map[string]string
}
func (p *claimsProvider) AuthCodeURL(state, nonce, verifier string) string {
p.nonces[state] = nonce
return "https://provider/authorize?state=" + url.QueryEscape(state)
}
func (p *claimsProvider) Exchange(ctx context.Context, code, verifier, nonce string) (*idtoken.Claims, error) {
if p.claims == nil {
return nil, errors.New("exchange failed")
}
return p.claims, nil
}
func (p *claimsProvider) ProviderName() string { return "Google" }
type identityStub struct {
owners map[string]uuid.UUID
linked map[uuid.UUID][]models.UserIdentity
}
func (r *identityStub) FindUserByIdentity(ctx context.Context, issuer, subject string) (uuid.UUID, error) {
return r.owners[issuer+"|"+subject], nil
}
func (r *identityStub) Link(ctx context.Context, userID uuid.UUID, identity models.UserIdentity) error {
return errors.New("re-authentication must never link an identity")
}
func (r *identityStub) HasIdentityForIssuer(ctx context.Context, userID uuid.UUID, issuer string) (bool, error) {
return false, nil
}
func (r *identityStub) TouchLogin(ctx context.Context, issuer, subject string) error { return nil }
func (r *identityStub) ListForUser(ctx context.Context, userID uuid.UUID) ([]models.UserIdentity, error) {
return r.linked[userID], nil
}
// banUsers reports a ban scope for every user.
type banUsers struct {
repository.UserRepository
scope uint32
}
func (u banUsers) GetBanState(context.Context, uuid.UUID) (uint32, error) { return u.scope, nil }
type tokenVerifierStub struct{ claims *idtoken.Claims }
func (v tokenVerifierStub) Verify(ctx context.Context, raw string) (*idtoken.Claims, error) {
if raw != "valid" {
return nil, errors.New("bad token")
}
return v.claims, nil
}
type reauthFixture struct {
s *authService
provider *claimsProvider
owner uuid.UUID
session uuid.UUID
}
func newReauthFixture(t *testing.T) *reauthFixture {
t.Helper()
redisURL := os.Getenv("WARMBLY_TEST_REDIS")
if redisURL == "" {
t.Skip("WARMBLY_TEST_REDIS not set")
}
c, err := cache.New(redisURL)
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
owner := uuid.New()
provider := &claimsProvider{
claims: &idtoken.Claims{Issuer: "https://accounts.google.com", Subject: "owner-sub", Email: "owner@example.com"},
nonces: map[string]string{},
}
s := svc(banUsers{}, "")
s.cache = c
s.identities = &identityStub{
owners: map[string]uuid.UUID{"https://accounts.google.com|owner-sub": owner},
linked: map[uuid.UUID][]models.UserIdentity{owner: {{Provider: models.IdentityProviderGoogle, Issuer: "https://accounts.google.com", Subject: "owner-sub"}}},
}
s.WireFederatedProvider(models.IdentityProviderGoogle, provider)
return &reauthFixture{s: s, provider: provider, owner: owner, session: uuid.New()}
}
// stateOf pulls the state back out of the authorization URL begin returned.
func stateOf(t *testing.T, raw string) string {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return u.Query().Get("state")
}
func (f *reauthFixture) begin(t *testing.T) (state, binding string) {
t.Helper()
redirect, err := f.s.ReauthSSOBegin(context.Background(), models.IdentityProviderGoogle, f.owner, f.session)
if err != nil {
t.Fatalf("begin: %v", err)
}
return stateOf(t, redirect.URL), redirect.Binding
}
func (f *reauthFixture) callback(state string) (*SSOCallbackResult, error) {
res, err := f.s.SSOCallbackComplete(context.Background(), SSOCallback{Provider: models.IdentityProviderGoogle, Code: "c", State: state})
if err != nil {
return res, err
}
return res, nil
}
func TestReauthSSOConfirmsOnlyTheSessionThatBeganIt(t *testing.T) {
f := newReauthFixture(t)
ctx := context.Background()
state, binding := f.begin(t)
if !IsReauthState(state) {
t.Fatalf("state %q does not route to the re-authentication landing", state)
}
res, err := f.callback(state)
if err != nil || res == nil || !res.Reauth || res.Code == "" {
t.Fatalf("callback: res=%+v err=%v", res, err)
}
// A wrong binding, user or session refuses without spending the code.
if xerr := f.s.ReauthSSOFinish(ctx, res.Code, "not-the-binding", f.owner, f.session); xerr == nil {
t.Fatal("a forwarded code was redeemed without the binding")
}
if xerr := f.s.ReauthSSOFinish(ctx, res.Code, binding, f.owner, uuid.New()); xerr == nil {
t.Fatal("another session of the same user redeemed the code")
}
if xerr := f.s.ReauthSSOFinish(ctx, res.Code, binding, uuid.New(), f.session); xerr == nil {
t.Fatal("another user redeemed the code")
}
if xerr := f.s.ReauthSSOFinish(ctx, res.Code, binding, f.owner, f.session); xerr != nil {
t.Fatalf("the initiating session was refused: %v", xerr)
}
if xerr := f.s.ReauthSSOFinish(ctx, res.Code, binding, f.owner, f.session); xerr == nil {
t.Fatal("the code was redeemed twice")
}
}
func TestReauthSSORefusesAnotherAccountsIdentity(t *testing.T) {
f := newReauthFixture(t)
f.provider.claims = &idtoken.Claims{Issuer: "https://accounts.google.com", Subject: "someone-else", Email: "owner@example.com"}
state, _ := f.begin(t)
res, err := f.callback(state)
if err == nil {
t.Fatal("an identity not linked to the account confirmed it, on the email address alone")
}
if res == nil || !res.Reauth || res.Code != "" {
t.Fatalf("a refused re-authentication must still land on its own page with no code: %+v", res)
}
}
// The state's prefix only routes the browser; the stored purpose is what counts.
func TestSSOCallbackRefusesAPurposeTheStateDoesNotCarry(t *testing.T) {
f := newReauthFixture(t)
ctx := context.Background()
plant := func(state string, flow ssoFlow) {
raw, _ := json.Marshal(flow)
if err := f.s.cache.SetEx(ctx, ssoStateKey(state), raw, time.Minute).Err(); err != nil {
t.Fatal(err)
}
}
plant("plain-state", ssoFlow{Provider: models.IdentityProviderGoogle, Purpose: ssoPurposeReauth, UserID: f.owner, SessionID: f.session, Binding: "b"})
if res, err := f.callback("plain-state"); err == nil {
t.Fatalf("a re-authentication flow completed as a sign-in: %+v", res)
}
plant(reauthStatePrefix+"login-state", ssoFlow{Provider: models.IdentityProviderGoogle, Binding: "b"})
if res, err := f.callback(reauthStatePrefix + "login-state"); err == nil {
t.Fatalf("a sign-in flow completed as a re-authentication: %+v", res)
}
}
func TestReauthNativeNeedsTheSessionNonceOnce(t *testing.T) {
f := newReauthFixture(t)
ctx := context.Background()
claims := &idtoken.Claims{Issuer: "https://accounts.google.com", Subject: "owner-sub"}
f.s.googleIDTokens = tokenVerifierStub{claims: claims}
if xerr := f.s.ReauthNativeVerify(ctx, models.IdentityProviderGoogle, "valid", f.owner, f.session); xerr == nil {
t.Fatal("a token was accepted with no nonce issued")
}
nonce, xerr := f.s.ReauthNativeBegin(ctx, f.session)
if xerr != nil {
t.Fatal(xerr)
}
claims.Nonce = "something-else"
if xerr := f.s.ReauthNativeVerify(ctx, models.IdentityProviderGoogle, "valid", f.owner, f.session); xerr == nil {
t.Fatal("a token carrying another nonce was accepted")
}
nonce, _ = f.s.ReauthNativeBegin(ctx, f.session)
claims.Nonce = nonce
if xerr := f.s.ReauthNativeVerify(ctx, models.IdentityProviderGoogle, "valid", f.owner, uuid.New()); xerr == nil {
t.Fatal("another session's nonce was accepted")
}
if xerr := f.s.ReauthNativeVerify(ctx, models.IdentityProviderGoogle, "valid", uuid.New(), f.session); xerr == nil {
t.Fatal("an identity linked to someone else confirmed the account")
}
nonce, _ = f.s.ReauthNativeBegin(ctx, f.session)
claims.Nonce = nonce
if xerr := f.s.ReauthNativeVerify(ctx, models.IdentityProviderGoogle, "valid", f.owner, f.session); xerr != nil {
t.Fatalf("the owner's token with the session nonce was refused: %v", xerr)
}
if xerr := f.s.ReauthNativeVerify(ctx, models.IdentityProviderGoogle, "valid", f.owner, f.session); xerr == nil {
t.Fatal("one nonce confirmed twice")
}
}
func TestReauthIdentitiesListsOnlyLinkedAndConfigured(t *testing.T) {
f := newReauthFixture(t)
f.s.appleIDTokens = tokenVerifierStub{}
browser, native, xerr := f.s.ReauthIdentities(context.Background(), f.owner)
if xerr != nil {
t.Fatal(xerr)
}
if len(browser) != 1 || browser[0] != models.IdentityProviderGoogle {
t.Fatalf("browser = %v, want only google", browser)
}
// Apple is configured but not linked; Google is linked but has no native verifier.
if len(native) != 0 {
t.Fatalf("native = %v, want none", native)
}
browser, native, _ = f.s.ReauthIdentities(context.Background(), uuid.New())
if len(browser) != 0 || len(native) != 0 {
t.Fatalf("an account with no identity was offered %v / %v", browser, native)
}
}
func TestReauthRefusesASuspendedAccount(t *testing.T) {
f := newReauthFixture(t)
f.s.userRepository = banUsers{scope: uint32(models.BanScopeLogin)}
state, binding := f.begin(t)
res, err := f.callback(state)
if err != nil {
t.Fatal(err)
}
if xerr := f.s.ReauthSSOFinish(context.Background(), res.Code, binding, f.owner, f.session); xerr == nil {
t.Fatal("a suspended account confirmed a sensitive change")
}
}
+10 -1
View File
@@ -145,12 +145,21 @@ type AuthService interface {
SSOBegin(ctx context.Context, provider string) (*SSORedirect, *errx.Error)
// SSOCallbackComplete returns a single-use handoff code, not a session: the
// provider redirects a browser here, so the response must be a redirect.
SSOCallbackComplete(ctx context.Context, in SSOCallback) (string, *errx.Error)
SSOCallbackComplete(ctx context.Context, in SSOCallback) (*SSOCallbackResult, *errx.Error)
SSOExchange(ctx context.Context, code, binding string) (*models.LoginResult, *errx.Error)
// SSOLinkConfirm completes a federated sign-in that came back with
// link_required: the address already belongs to a password account, and
// the identity is attached only once that password is presented.
SSOLinkConfirm(ctx context.Context, data *SSOLinkData, ipaddr, userAgent string) (*models.LoginResult, *errx.Error)
// Re-authentication of a signed-in session through an identity linked to
// the account. Each succeeds only for (issuer, subject) already bound to
// userID, and only for the session that began it; the caller stamps it.
ReauthIdentities(ctx context.Context, userID uuid.UUID) (browser, native []string, err *errx.Error)
ReauthSSOBegin(ctx context.Context, provider string, userID, sessionID uuid.UUID) (*SSORedirect, *errx.Error)
ReauthSSOFinish(ctx context.Context, code, binding string, userID, sessionID uuid.UUID) *errx.Error
ReauthNativeBegin(ctx context.Context, sessionID uuid.UUID) (string, *errx.Error)
ReauthNativeVerify(ctx context.Context, provider, rawToken string, userID, sessionID uuid.UUID) *errx.Error
}
// OperatorNotifier is the instance-wide operator alert surface, injected
+40 -2
View File
@@ -9,6 +9,7 @@ import (
"net/mail"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/token"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
@@ -68,6 +69,18 @@ type ssoFlow struct {
Verifier string `json:"verifier"`
Nonce string `json:"nonce"`
Binding string `json:"binding"`
// Purpose is empty for a sign-in and ssoPurposeReauth for a signed-in
// session re-proving its holder, which also pins the user and session.
Purpose string `json:"purpose,omitempty"`
UserID uuid.UUID `json:"user_id"`
SessionID uuid.UUID `json:"session_id"`
}
// SSOCallbackResult is where the callback sends the browser: Reauth is set
// whenever the state belonged to a re-authentication, error or not.
type SSOCallbackResult struct {
Code string
Reauth bool
}
// ssoHandoff is a completed login waiting to be collected, carrying the same
@@ -189,11 +202,19 @@ func (s *authService) SSOBegin(ctx context.Context, provider string) (*SSORedire
return nil, errx.ErrExternalProvider
}
return s.beginFlow(ctx, p, ssoFlow{Provider: provider}, "")
}
// beginFlow mints the state, nonce, verifier and binding for one authorization
// and stores them under the state. statePrefix only routes the callback; the
// stored Purpose is what is trusted.
func (s *authService) beginFlow(ctx context.Context, p FederatedProvider, flow ssoFlow, statePrefix string) (*SSORedirect, *errx.Error) {
state, err := randomHex(32)
if err != nil {
errs.CaptureException(err)
return nil, errx.InternalError()
}
state = statePrefix + state
nonce, err := randomHex(32)
if err != nil {
errs.CaptureException(err)
@@ -210,7 +231,8 @@ func (s *authService) SSOBegin(ctx context.Context, provider string) (*SSORedire
return nil, errx.InternalError()
}
payload, err := json.Marshal(ssoFlow{Provider: provider, Verifier: verifier, Nonce: nonce, Binding: binding})
flow.Verifier, flow.Nonce, flow.Binding = verifier, nonce, binding
payload, err := json.Marshal(flow)
if err != nil {
errs.CaptureException(err)
return nil, errx.InternalError()
@@ -225,7 +247,14 @@ func (s *authService) SSOBegin(ctx context.Context, provider string) (*SSORedire
// SSOCallbackComplete finishes an authorization and returns a single-use
// handoff code the dashboard exchanges for the session.
func (s *authService) SSOCallbackComplete(ctx context.Context, in SSOCallback) (string, *errx.Error) {
func (s *authService) SSOCallbackComplete(ctx context.Context, in SSOCallback) (*SSOCallbackResult, *errx.Error) {
out := &SSOCallbackResult{Reauth: IsReauthState(in.State)}
code, err := s.ssoCallbackComplete(ctx, in, out.Reauth)
out.Code = code
return out, err
}
func (s *authService) ssoCallbackComplete(ctx context.Context, in SSOCallback, reauthState bool) (string, *errx.Error) {
p, ok := s.providers[in.Provider]
if !ok || p == nil {
return "", errx.ErrExternalProvider
@@ -250,6 +279,11 @@ func (s *authService) SSOCallbackComplete(ctx context.Context, in SSOCallback) (
if flow.Provider != "" && flow.Provider != in.Provider {
return "", errx.ErrExternalCode
}
// The stored purpose decides, and it must agree with the state's routing prefix.
isReauth := flow.Purpose == ssoPurposeReauth
if isReauth != reauthState {
return "", errx.ErrExternalCode
}
claims, err := p.Exchange(ctx, in.Code, flow.Verifier, flow.Nonce)
if err != nil {
@@ -259,6 +293,10 @@ func (s *authService) SSOCallbackComplete(ctx context.Context, in SSOCallback) (
return "", errx.ErrExternalCode
}
if isReauth {
return s.completeReauthFlow(ctx, flow, claims)
}
email, perr := mail.ParseAddress(claims.Email)
if perr != nil {
return "", errx.ErrExternalEmail
+74
View File
@@ -0,0 +1,74 @@
package passkey
import (
"context"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
)
// Re-authentication asks for one of the signed-in user's own passkeys, never a
// discoverable one, and keys the challenge by the session it will vouch for.
func reauthKey(sessionID uuid.UUID) string {
return "webauthn_reauth:" + sessionID.String()
}
func (s *service) BeginReauth(ctx context.Context, userID, sessionID uuid.UUID) (*protocol.CredentialAssertion, *errx.Error) {
wuser, xerr := s.loadUser(ctx, userID)
if xerr != nil {
return nil, xerr
}
if len(wuser.credentials) == 0 {
return nil, errx.ErrPasskeyNone
}
options, sessionData, err := s.web.BeginLogin(wuser, webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
return nil, errx.ErrPasskey
}
if xerr := s.saveSession(ctx, reauthKey(sessionID), sessionData); xerr != nil {
return nil, xerr
}
return options, nil
}
func (s *service) FinishReauth(ctx context.Context, userID, sessionID uuid.UUID, credential []byte) *errx.Error {
sessionData, xerr := s.takeSession(ctx, reauthKey(sessionID))
if xerr != nil {
return xerr
}
parsed, perr := protocol.ParseCredentialRequestResponseBytes(credential)
if perr != nil {
return errx.ErrPasskey
}
// The same bar as a passkey sign-in: a PIN or biometric, not just a touch.
if !parsed.Response.AuthenticatorData.Flags.HasUserVerified() {
return errx.ErrPasskeyUserVerification
}
wuser, xerr := s.loadUser(ctx, userID)
if xerr != nil {
return xerr
}
// Refuses a challenge minted for another user and a credential that is not one of theirs.
wcred, verr := s.web.ValidateLogin(wuser, *sessionData, parsed)
if verr != nil {
return errx.ErrPasskey
}
_ = s.repo.TouchCredential(ctx, wcred.ID, wcred.Authenticator.SignCount, wcred.Authenticator.CloneWarning, wcred.Flags.BackupState, time.Now())
return nil
}
func (s *service) HasCredentials(ctx context.Context, userID uuid.UUID) (bool, *errx.Error) {
creds, xerr := s.repo.ListByUser(ctx, userID)
if xerr != nil {
return false, xerr
}
return len(creds) > 0, nil
}
+7
View File
@@ -36,6 +36,13 @@ type Service interface {
// handle, and mints a full session — single step, no email OTP.
FinishLogin(ctx context.Context, session string, credential []byte, ipaddr, userAgent string) (*models.Token, *errx.Error)
// BeginReauth asks a signed-in user for one of their own passkeys, keyed
// by the session it will re-authenticate. FinishReauth verifies it.
BeginReauth(ctx context.Context, userID, sessionID uuid.UUID) (*protocol.CredentialAssertion, *errx.Error)
FinishReauth(ctx context.Context, userID, sessionID uuid.UUID, credential []byte) *errx.Error
// HasCredentials reports whether the user has enrolled any passkey.
HasCredentials(ctx context.Context, userID uuid.UUID) (bool, *errx.Error)
// ListCredentials returns the user's passkeys for the manager UI.
ListCredentials(ctx context.Context, userID uuid.UUID) ([]*CredentialView, *errx.Error)
// RenameCredential updates a passkey's friendly name.
+6
View File
@@ -83,6 +83,12 @@ var (
ErrSSOLinkExpired = NewWithIdentifier(BadRequest, "sso_link_expired",
"That sign-in request expired or had too many attempts. Start the sign-in again.")
// Re-authentication through a linked identity.
ErrReauthIdentityMismatch = NewWithIdentifier(BadRequest, "reauth_identity_mismatch",
"That account is not the one linked to this Warmbly account. Continue with the account you sign in with.")
ErrReauthExpired = NewWithIdentifier(BadRequest, "reauth_expired",
"That confirmation expired or was already used. Try again.")
ErrExternalCode = New(BadRequest, "Invalid or expired code, please try again.")
ErrExternalEmail = New(BadRequest, "Invalid or unverified email address.")
ErrExternalProvider = New(BadRequest, "This sign-in method isn't available on this server.")
+5 -1
View File
@@ -71,6 +71,10 @@ The email step shows native **Sign in with Apple** and **Continue with Google**
First-time social sign-ins auto-provision the user, workspace, and trial server-side; there is no OTP step because the provider identity is already verified.
### Confirming a recent sign-in
Connecting a grant's mailboxes and deleting the account need a recent sign-in, asked for by the **Confirm it's you** sheet (`Features/Auth/ReauthSheet.swift`). It reads `GET /v1/auth/reauth/methods` and offers only what the account can use: **Continue with Apple** or **Continue with Google** for a linked identity the server can verify (`native_providers`), then the password, and a two-factor code when 2FA is on. A provider confirmation calls `POST /v1/auth/reauth/native/begin` for a single-use nonce, runs the provider sheet with it, and sends the ID token to `POST /v1/auth/reauth/native`; password and code go to `POST /v1/auth/reauth`. An account with none of these is told to sign out and back in. Passkeys and browser-only providers confirm on the web, not in the app. If the methods call fails, the sheet falls back to the password and code form.
## Pointing at another host (staging, self-hosted)
The server is an in-app runtime setting, not a build-time constant, so one binary serves everyone: the hosted SaaS, a self-hosted instance, or a local stack. Users switch hosts from the Server sheet on the sign-in screen; nobody rebuilds the app to reach their own infrastructure.
@@ -185,4 +189,4 @@ A fork that wants its own build changes `PRODUCT_BUNDLE_IDENTIFIER`, registers t
## What stays on the web
By design, a few flows stay on the dashboard instead of being reimplemented on mobile: setting up a new admin grant, campaign sequence editing, and changing a plan or payment method (the app shows billing read-only). Mailbox connect works natively with app passwords, existing admin grants (connecting a grant's mailboxes asks for a recent sign-in through `POST /v1/auth/reauth`), inbox vendor keys, file and pasted imports (`POST /emails/imports/preview`, then `POST /emails/imports`) and Warmbly Cloud workspace mailboxes; per-mailbox Google or Microsoft sign-in is not offered. See the [README](README.md#known-limits) for the full list.
By design, a few flows stay on the dashboard instead of being reimplemented on mobile: setting up a new admin grant, campaign sequence editing, and changing a plan or payment method (the app shows billing read-only). Mailbox connect works natively with app passwords, existing admin grants (connecting a grant's mailboxes asks for a recent sign-in, see [Confirming a recent sign-in](#confirming-a-recent-sign-in)), inbox vendor keys, file and pasted imports (`POST /emails/imports/preview`, then `POST /emails/imports`) and Warmbly Cloud workspace mailboxes; per-mailbox Google or Microsoft sign-in is not offered. See the [README](README.md#known-limits) for the full list.
+380
View File
@@ -0,0 +1,380 @@
import AuthenticationServices
import SwiftUI
/// Confirms the account holder before a change that needs a recent sign-in,
/// then hands control back so the caller retries. Offers only the ways this
/// account can confirm (`GET auth/reauth/methods`), best first.
struct ReauthSheet: View {
var reason = "Connecting a whole domain's mailboxes needs a recent sign-in."
var actionTitle = "Confirm and connect"
var onConfirmed: () -> Void
private enum Phase {
case loading
case loaded(ReauthMethods)
/// Methods could not be read, so the password and code form stays as it was.
case fallback
}
@Environment(AppEnvironment.self) private var env
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@State private var phase: Phase = .loading
@State private var googleClientID: String?
@State private var secret = ""
@State private var useCode = false
@State private var busy = false
@State private var nativeBusy = false
@State private var errorMessage: String?
@State private var contentHeight: CGFloat = 420
@State private var appleFlow = AppleIDReauthFlow()
@State private var googleFlow = GoogleSignInFlow()
@FocusState private var focused: Bool
private var methods: ReauthMethods? {
if case let .loaded(methods) = phase { return methods }
return nil
}
private var isLoading: Bool {
if case .loading = phase { return true }
return false
}
private var showApple: Bool { methods?.nativeProviders.contains("apple") == true }
private var showGoogle: Bool { methods?.nativeProviders.contains("google") == true && googleClientID != nil }
private var allowsPassword: Bool { methods?.password ?? true }
private var allowsCode: Bool { methods?.totp ?? true }
private var showsField: Bool { !isLoading && (allowsPassword || allowsCode) }
private var hasProviders: Bool { showApple || showGoogle }
private var hasNothing: Bool { !isLoading && !showsField && !hasProviders }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
.padding(.bottom, 22)
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.frame(height: 54)
} else if hasNothing {
Text("This account has no way to confirm yet. Sign out and sign in again, then repeat this within five minutes.")
.font(.system(size: 14.5))
.foregroundStyle(WTheme.ink)
.fixedSize(horizontal: false, vertical: true)
} else {
if hasProviders { providerButtons }
if hasProviders && showsField { orDivider }
if showsField { secretField }
}
if let errorMessage {
Text(errorMessage)
.font(.system(size: 13.5))
.foregroundStyle(WTheme.negative)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 12)
.transition(.opacity)
}
if hasNothing {
Button("Close") { dismiss() }
.buttonStyle(InkButtonStyle(size: .large, fullWidth: true))
.padding(.top, 24)
} else if showsField {
Button {
Task { await confirm() }
} label: {
if busy {
ProgressView().tint(WTheme.inkInverse)
} else {
Text(actionTitle)
}
}
.buttonStyle(InkButtonStyle(size: .large, fullWidth: true))
.disabled(secret.isEmpty || nativeBusy)
.allowsHitTesting(!busy)
.padding(.top, 24)
}
}
.padding(24)
.fixedSize(horizontal: false, vertical: true)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { contentHeight = $0 }
.frame(maxHeight: .infinity, alignment: .top)
.background(WTheme.canvas)
.presentationDetents([.height(contentHeight)])
.presentationDragIndicator(.visible)
.sensoryFeedback(.error, trigger: errorMessage)
.animation(.snappy, value: isLoading)
.task { await loadMethods() }
}
// MARK: Sections
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 5) {
Text("Confirm it's you")
.displayFont(26)
.foregroundStyle(WTheme.ink)
Text(reason)
.font(.system(size: 14.5))
.foregroundStyle(WTheme.inkSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 12)
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(WTheme.inkSecondary)
.frame(width: 32, height: 32)
.background(WTheme.fill, in: Circle())
}
.buttonStyle(TapScaleStyle())
.accessibilityLabel("Close")
}
}
private var providerButtons: some View {
VStack(spacing: 12) {
if showApple {
Button {
Task { await confirmWithApple() }
} label: {
HStack(spacing: 8) {
Image(systemName: "applelogo")
.font(.system(size: 18, weight: .medium))
Text("Continue with Apple")
.font(.system(size: 16.5, weight: .medium))
}
.foregroundStyle(colorScheme == .dark ? Color.black : Color.white)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(colorScheme == .dark ? Color.white : Color.black, in: Capsule())
}
.buttonStyle(PressableButtonStyle())
}
if showGoogle, let googleClientID {
Button {
Task { await confirmWithGoogle(clientID: googleClientID) }
} label: {
HStack(spacing: 10) {
GoogleGlyph()
.frame(width: 19, height: 19)
Text("Continue with Google")
.font(.system(size: 16.5, weight: .medium))
.foregroundStyle(WTheme.ink)
}
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(WTheme.raised, in: Capsule())
.overlay(Capsule().strokeBorder(WTheme.hairlineStrong, lineWidth: 1))
}
.buttonStyle(PressableButtonStyle())
}
}
.disabled(busy || nativeBusy)
}
private var orDivider: some View {
HStack(spacing: 12) {
Rectangle().fill(WTheme.hairlineStrong).frame(height: 1)
Text("or")
.font(.system(size: 13))
.foregroundStyle(WTheme.inkTertiary)
Rectangle().fill(WTheme.hairlineStrong).frame(height: 1)
}
.padding(.vertical, 18)
}
private var secretField: some View {
VStack(alignment: .leading, spacing: 0) {
Group {
if useCode {
TextField("6-digit code", text: $secret)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
} else {
SecureField("Password", text: $secret)
.textContentType(.password)
}
}
.focused($focused)
.submitLabel(.done)
.onSubmit { Task { await confirm() } }
.font(.system(size: 16))
.padding(.horizontal, 16)
.frame(height: 52)
.background(WTheme.raised, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(focused ? WTheme.ink : WTheme.hairlineStrong, lineWidth: focused ? 1.5 : 1)
)
if allowsPassword && allowsCode {
Button(useCode ? "Use my password instead" : "Use a two-factor code instead") {
withAnimation(.snappy) {
useCode.toggle()
secret = ""
errorMessage = nil
}
}
.font(.system(size: 13.5, weight: .medium))
.foregroundStyle(WTheme.accent)
.padding(.top, 12)
}
}
}
// MARK: Actions
private func loadMethods() async {
do {
let loaded: ReauthMethods = try await env.api.get("auth/reauth/methods")
if loaded.nativeProviders.contains("google") {
googleClientID = await env.session.fetchAuthProviders()?.googleClientID
}
useCode = !loaded.password && loaded.totp
phase = .loaded(loaded)
// A provider button is the better first choice, so the keyboard waits for it.
if !loaded.nativeProviders.contains("apple") && !(loaded.nativeProviders.contains("google") && googleClientID != nil) {
focused = loaded.password || loaded.totp
}
} catch {
phase = .fallback
focused = true
}
}
private func confirm() async {
guard !secret.isEmpty, !busy, !nativeBusy else { return }
busy = true
defer { busy = false }
let body = useCode ? ReauthBody(code: secret.filter(\.isNumber)) : ReauthBody(password: secret)
do {
let _: EmptyBody = try await env.api.post("auth/reauth", body: body)
finish()
} catch {
show(error)
}
}
private func confirmWithApple() async {
await confirmNative(provider: "apple") { nonce in
try await appleFlow.identityToken(nonce: nonce)
}
}
private func confirmWithGoogle(clientID: String) async {
await confirmNative(provider: "google") { nonce in
try await googleFlow.signIn(clientID: clientID, nonce: nonce)
}
}
/// Begin, run the provider sheet with the session's nonce, then hand the ID token back.
private func confirmNative(provider: String, idToken: (String) async throws -> String) async {
guard !busy, !nativeBusy else { return }
nativeBusy = true
defer { nativeBusy = false }
errorMessage = nil
do {
let begin: ReauthNativeBegin = try await env.api.post("auth/reauth/native/begin")
let token = try await idToken(begin.nonce)
let _: EmptyBody = try await env.api.post(
"auth/reauth/native",
body: ReauthNativeBody(provider: provider, idToken: token)
)
finish()
} catch AppleIDReauthFlow.Failure.cancelled {
// Closing the Apple sheet is not an error worth surfacing.
} catch GoogleSignInFlow.Failure.cancelled {
// Closing the Google sheet is not an error worth surfacing.
} catch {
show(error)
}
}
private func finish() {
dismiss()
onConfirmed()
}
private func show(_ error: Error) {
withAnimation(.easeOut(duration: 0.2)) {
errorMessage = (error as? APIError)?.errorDescription ?? error.localizedDescription
}
}
}
/// Native Sign in with Apple run for its ID token alone, with a nonce the
/// server minted, so the token proves a fresh sign-in to this session.
@MainActor
final class AppleIDReauthFlow: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
enum Failure: Error {
case cancelled
case malformedResponse
}
private var controller: ASAuthorizationController?
private var presentationWindow: UIWindow?
private var continuation: CheckedContinuation<String, Error>?
func identityToken(nonce: String) async throws -> String {
guard let window = Self.keyWindow else { throw Failure.malformedResponse }
presentationWindow = window
defer { presentationWindow = nil }
let request = ASAuthorizationAppleIDProvider().createRequest()
// Passed verbatim: the server compares the token's nonce claim to the raw string.
request.nonce = nonce
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
self.controller = controller
return try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
controller.performRequests()
}
}
private func resume(_ result: Result<String, Error>) {
controller = nil
continuation?.resume(with: result)
continuation = nil
}
nonisolated func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
let token = (authorization.credential as? ASAuthorizationAppleIDCredential)?
.identityToken
.flatMap { String(data: $0, encoding: .utf8) }
MainActor.assumeIsolated {
if let token {
resume(.success(token))
} else {
resume(.failure(Failure.malformedResponse))
}
}
}
nonisolated func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
let cancelled = (error as? ASAuthorizationError)?.code == .canceled
MainActor.assumeIsolated {
resume(.failure(cancelled ? Failure.cancelled : error))
}
}
nonisolated func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
MainActor.assumeIsolated {
// identityToken guards that a window exists before the request starts.
presentationWindow ?? Self.keyWindow!
}
}
private static var keyWindow: UIWindow? {
let windows = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
return windows.first(where: \.isKeyWindow) ?? windows.first
}
}
+5 -1
View File
@@ -158,7 +158,8 @@ final class GoogleSignInFlow: NSObject, ASWebAuthenticationPresentationContextPr
private var activeSession: ASWebAuthenticationSession?
private var presentationWindow: UIWindow?
func signIn(clientID: String) async throws -> String {
/// `nonce`, when set, is echoed in the ID token's `nonce` claim.
func signIn(clientID: String, nonce: String? = nil) async throws -> String {
// Captured up front (we're on the main actor) so the presentation
// callback never has to conjure a window of its own.
guard let window = Self.keyWindow else { throw Failure.malformedResponse }
@@ -180,6 +181,9 @@ final class GoogleSignInFlow: NSObject, ASWebAuthenticationPresentationContextPr
URLQueryItem(name: "code_challenge", value: challenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
]
if let nonce {
authorize.queryItems?.append(URLQueryItem(name: "nonce", value: nonce))
}
let callback = try await runWebAuth(url: authorize.url!, callbackScheme: scheme)
guard
@@ -2831,125 +2831,3 @@ private extension View {
)
}
}
/// Confirms the account holder before a change that needs a recent sign-in,
/// then hands control back so the caller retries.
struct ReauthSheet: View {
var reason = "Connecting a whole domain's mailboxes needs a recent sign-in."
var actionTitle = "Confirm and connect"
var onConfirmed: () -> Void
@Environment(AppEnvironment.self) private var env
@Environment(\.dismiss) private var dismiss
@State private var secret = ""
@State private var useCode = false
@State private var busy = false
@State private var errorMessage: String?
@FocusState private var focused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 5) {
Text("Confirm it's you")
.displayFont(26)
.foregroundStyle(WTheme.ink)
Text(reason)
.font(.system(size: 14.5))
.foregroundStyle(WTheme.inkSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 12)
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(WTheme.inkSecondary)
.frame(width: 32, height: 32)
.background(WTheme.fill, in: Circle())
}
.buttonStyle(TapScaleStyle())
.accessibilityLabel("Close")
}
.padding(.bottom, 22)
Group {
if useCode {
TextField("6-digit code", text: $secret)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
} else {
SecureField("Password", text: $secret)
.textContentType(.password)
}
}
.focused($focused)
.submitLabel(.done)
.onSubmit { Task { await confirm() } }
.font(.system(size: 16))
.padding(.horizontal, 16)
.frame(height: 52)
.background(WTheme.raised, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(focused ? WTheme.ink : WTheme.hairlineStrong, lineWidth: focused ? 1.5 : 1)
)
Button(useCode ? "Use my password instead" : "Use a two-factor code instead") {
withAnimation(.snappy) {
useCode.toggle()
secret = ""
errorMessage = nil
}
}
.font(.system(size: 13.5, weight: .medium))
.foregroundStyle(WTheme.accent)
.padding(.top, 12)
if let errorMessage {
Text(errorMessage)
.font(.system(size: 13.5))
.foregroundStyle(WTheme.negative)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 12)
.transition(.opacity)
}
Spacer(minLength: 20)
Button {
Task { await confirm() }
} label: {
if busy {
ProgressView().tint(WTheme.inkInverse)
} else {
Text(actionTitle)
}
}
.buttonStyle(InkButtonStyle(size: .large, fullWidth: true))
.disabled(secret.isEmpty)
.allowsHitTesting(!busy)
}
.padding(24)
.background(WTheme.canvas)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
.sensoryFeedback(.error, trigger: errorMessage)
.onAppear { focused = true }
}
private func confirm() async {
guard !secret.isEmpty, !busy else { return }
busy = true
defer { busy = false }
let body = useCode ? ReauthBody(code: secret.filter(\.isNumber)) : ReauthBody(password: secret)
do {
let _: EmptyBody = try await env.api.post("auth/reauth", body: body)
dismiss()
onConfirmed()
} catch {
withAnimation(.easeOut(duration: 0.2)) {
errorMessage = (error as? APIError)?.errorDescription ?? error.localizedDescription
}
}
}
}
@@ -127,6 +127,44 @@ struct ReauthBody: Encodable {
var code: String?
}
/// `GET /auth/reauth/methods`: the ways this account can confirm a recent sign-in.
struct ReauthMethods: Decodable, Sendable {
var password: Bool
var totp: Bool
var passkey: Bool
/// Linked identities the server can verify a native ID token for ("apple", "google").
var nativeProviders: [String]
enum CodingKeys: String, CodingKey {
case password, totp, passkey
case nativeProviders = "native_providers"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
password = try c.decodeIfPresent(Bool.self, forKey: .password) ?? false
totp = try c.decodeIfPresent(Bool.self, forKey: .totp) ?? false
passkey = try c.decodeIfPresent(Bool.self, forKey: .passkey) ?? false
nativeProviders = try c.decodeIfPresent([String].self, forKey: .nativeProviders) ?? []
}
}
/// `POST /auth/reauth/native/begin`: the single-use nonce the provider signs into its ID token.
struct ReauthNativeBegin: Decodable, Sendable {
var nonce: String
}
/// `POST /auth/reauth/native` body.
struct ReauthNativeBody: Encodable {
var provider: String
var idToken: String
enum CodingKeys: String, CodingKey {
case provider
case idToken = "id_token"
}
}
// MARK: - Inbox vendors (`/emails/vendors`)
struct VendorField: Decodable, Sendable, Hashable {
+60
View File
@@ -0,0 +1,60 @@
// Where a "confirm it is you" provider window lands. It hands the result to the
// dashboard tab that opened it and closes; the code is worthless without the
// binding that tab kept.
import React from "react";
import { Link } from "@tanstack/react-router";
import { CheckIcon, XIcon } from "lucide-react";
import { Logo } from "@/components/svg";
import { reauthReturnMessage, relayOAuthReturn } from "@/lib/oauthReturn";
export default function ReauthCompletePage() {
const [message] = React.useState(() => reauthReturnMessage(window.location.search));
const [delivered, setDelivered] = React.useState(false);
React.useEffect(() => {
// The query carries a single-use code; keep it out of history.
window.history.replaceState(null, "", window.location.pathname);
let sent = false;
try {
if (window.opener && window.opener !== window) {
window.opener.postMessage(message, window.location.origin);
sent = true;
}
} catch {
/* the opener is gone or cross-origin; the channel below still reaches it */
}
if (relayOAuthReturn(message)) sent = true;
if (!sent) return;
setDelivered(true);
const t = window.setTimeout(() => window.close(), 400);
return () => window.clearTimeout(t);
}, [message]);
const ok = Boolean(message.code) && !message.error;
const title = ok ? "Confirmed" : message.cancelled ? "Cancelled" : "Could not confirm";
return (
<div className="min-h-dvh flex items-center justify-center bg-slate-50 px-4">
<div className="w-full max-w-sm rounded-xl border border-slate-200 bg-white p-6 text-center shadow-sm">
<Logo className="w-7 mx-auto text-slate-900" />
<span className={`mt-4 mx-auto size-10 rounded-full inline-flex items-center justify-center ${ok ? "bg-emerald-50 text-emerald-600" : "bg-rose-50 text-rose-600"}`}>
{ok ? <CheckIcon className="w-5 h-5" /> : <XIcon className="w-5 h-5" />}
</span>
<p className="mt-3 text-[14px] font-semibold text-slate-900">{title}</p>
{message.error && !ok && <p className="mt-1 text-[12.5px] text-rose-600 leading-relaxed">{message.error}</p>}
<p className="mt-1 text-[12.5px] text-slate-500 leading-relaxed">
{!delivered
? "This window could not reach your Warmbly tab. Close it and try again from there."
: ok
? "Finishing in your Warmbly tab. This window closes on its own; if it stays open, close it and go back to that tab."
: "This window closes on its own. Go back to your Warmbly tab to try again."}
</p>
{!delivered && (
<Link to="/app" className="mt-4 inline-flex h-8 px-3 items-center rounded-md bg-slate-900 text-white text-[12.5px] font-medium">
Back to Warmbly
</Link>
)}
</div>
</div>
);
}
@@ -0,0 +1,145 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type ReauthMethods from "@/lib/api/models/auth/ReauthMethods";
import ReauthModal from "./ReauthModal";
const navigate = vi.fn();
const logout = vi.fn(() => Promise.resolve());
const methods = vi.fn<() => Promise<ReauthMethods>>();
const reauth = vi.fn();
const beginSSO = vi.fn();
const finishSSO = vi.fn();
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => navigate }));
vi.mock("@/lib/api/hooks/auth/useLogout", () => ({ default: () => ({ mutateAsync: logout, isPending: false }) }));
vi.mock("@/lib/api/client/auth/reauth", () => ({ default: (...a: unknown[]) => reauth(...a) }));
vi.mock("@/lib/api/client/auth/reauth/methods", () => ({ default: () => methods() }));
vi.mock("@/lib/api/client/auth/reauth/passkey", () => ({ beginPasskeyReauth: () => Promise.resolve({ options: { publicKey: {} } }) }));
vi.mock("@/lib/api/client/auth/reauth/sso", () => ({
beginSSOReauth: (...a: unknown[]) => beginSSO(...a),
finishSSOReauth: (...a: unknown[]) => finishSSO(...a),
}));
vi.mock("@/lib/passkey", () => ({
PasskeyCancelled: class extends Error {},
confirmWithPasskey: vi.fn(),
passkeySupported: () => true,
}));
const none: ReauthMethods = { password: false, totp: false, passkey: false, providers: [], native_providers: [], valid_for_seconds: 300 };
function ask() {
const resolve = vi.fn();
const reject = vi.fn();
act(() => {
window.dispatchEvent(new CustomEvent("reauth-required", { detail: { resolve, reject } }));
});
return { resolve, reject };
}
describe("ReauthModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("offers a Google-only account its provider and no password field", async () => {
methods.mockResolvedValue({ ...none, providers: [{ id: "google", label: "Google" }] });
render(<ReauthModal />);
ask();
expect(await screen.findByRole("button", { name: "Continue with Google" })).toBeInTheDocument();
expect(screen.queryByLabelText("Password")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument();
});
it("confirms through the provider window and retries the waiting request", async () => {
methods.mockResolvedValue({ ...none, providers: [{ id: "google", label: "Google" }] });
const popup = { closed: false, location: { href: "about:blank", replace: vi.fn() }, document: { body: { childElementCount: 1 } }, focus: vi.fn(), close: vi.fn() };
vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
beginSSO.mockResolvedValue({ url: "https://accounts.google.com/x", binding: "kept-here" });
finishSSO.mockResolvedValue({ valid_for_seconds: 300 });
render(<ReauthModal />);
const { resolve, reject } = ask();
fireEvent.click(await screen.findByRole("button", { name: "Continue with Google" }));
await waitFor(() => expect(popup.location.replace).toHaveBeenCalledWith("https://accounts.google.com/x"));
act(() => {
window.dispatchEvent(new MessageEvent("message", {
origin: window.location.origin,
data: { type: "warmbly_reauth", code: "handoff", error: "", cancelled: false },
}));
});
await waitFor(() => expect(resolve).toHaveBeenCalled());
expect(finishSSO).toHaveBeenCalledWith("handoff", "kept-here");
expect(reject).not.toHaveBeenCalled();
});
it("ignores a result posted from another origin", async () => {
methods.mockResolvedValue({ ...none, providers: [{ id: "google", label: "Google" }] });
const popup = { closed: false, location: { href: "about:blank", replace: vi.fn() }, document: { body: { childElementCount: 1 } }, focus: vi.fn(), close: vi.fn() };
vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
beginSSO.mockResolvedValue({ url: "https://accounts.google.com/x", binding: "b" });
render(<ReauthModal />);
const { resolve } = ask();
fireEvent.click(await screen.findByRole("button", { name: "Continue with Google" }));
await waitFor(() => expect(popup.location.replace).toHaveBeenCalled());
act(() => {
window.dispatchEvent(new MessageEvent("message", {
origin: "https://evil.example",
data: { type: "warmbly_reauth", code: "handoff", error: "", cancelled: false },
}));
});
await new Promise((r) => setTimeout(r, 20));
expect(finishSSO).not.toHaveBeenCalled();
expect(resolve).not.toHaveBeenCalled();
});
it("explains the way out to an account with nothing to confirm with", async () => {
methods.mockResolvedValue(none);
render(<ReauthModal />);
const { reject } = ask();
expect(await screen.findByText(/no password, passkey or authenticator yet/)).toBeInTheDocument();
expect(screen.queryByLabelText("Password")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Sign in again" }));
await waitFor(() => expect(navigate).toHaveBeenCalledWith(expect.objectContaining({ to: "/auth/login" })));
expect(logout).toHaveBeenCalled();
expect(reject).toHaveBeenCalled();
});
it("falls back to both fields when the methods lookup fails", async () => {
methods.mockRejectedValue(new Error("offline"));
render(<ReauthModal />);
ask();
expect(await screen.findByLabelText("Password")).toBeInTheDocument();
expect(screen.getByLabelText("Two-factor or recovery code")).toBeInTheDocument();
});
it("answers every request that was waiting with one confirmation", async () => {
methods.mockResolvedValue({ ...none, password: true });
reauth.mockResolvedValue({ valid_for_seconds: 300 });
render(<ReauthModal />);
const first = ask();
const second = ask();
fireEvent.change(await screen.findByLabelText("Password"), { target: { value: "hunter22" } });
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor(() => expect(first.resolve).toHaveBeenCalled());
expect(second.resolve).toHaveBeenCalled();
expect(screen.queryByLabelText("Two-factor or recovery code")).not.toBeInTheDocument();
});
it("rejects every waiting request on Escape", async () => {
methods.mockResolvedValue({ ...none, password: true });
render(<ReauthModal />);
const first = ask();
const second = ask();
await screen.findByLabelText("Password");
fireEvent.keyDown(window, { key: "Escape" });
expect(first.reject).toHaveBeenCalled();
expect(second.reject).toHaveBeenCalled();
});
});
+330 -61
View File
@@ -10,12 +10,28 @@
// reject, waits for one of them, and retries the original request on success.
// That way every gated action gets the prompt without each call site knowing
// about it.
//
// The prompt asks the backend how this account can confirm and offers only
// those: a passkey, the identity it signs in with (Google, Apple, SSO), a
// password, an authenticator code. An account with none of them is told how
// to get one instead of being shown a field it cannot fill.
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ShieldCheckIcon, XIcon } from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { RiAppleFill } from "@remixicon/react";
import { KeyRoundIcon, Loader2Icon, LogInIcon, ShieldCheckIcon, XIcon } from "lucide-react";
import reauth from "@/lib/api/client/auth/reauth";
import getReauthMethods from "@/lib/api/client/auth/reauth/methods";
import { beginPasskeyReauth, type ReauthPasskeyChallenge } from "@/lib/api/client/auth/reauth/passkey";
import { beginSSOReauth, finishSSOReauth } from "@/lib/api/client/auth/reauth/sso";
import type ReauthMethods from "@/lib/api/models/auth/ReauthMethods";
import useLogout from "@/lib/api/hooks/auth/useLogout";
import { Google } from "@/components/svg";
import { PasskeyCancelled, confirmWithPasskey, passkeySupported } from "@/lib/passkey";
import { acceptReauthReturn } from "@/lib/oauthReturn";
import { POPUP_CLOSED, closePopup, navigatePopup, notifyPopupBlocked, reservePopup, waitForPopupMessage } from "@/lib/popup";
interface ReauthDetail {
resolve: () => void;
@@ -23,62 +39,225 @@ interface ReauthDetail {
ack?: () => void;
}
const POPUP_NAME = "warmbly-reauth";
// null while loading; "unknown" when the lookup failed and every field is offered.
type MethodsState = ReauthMethods | "unknown" | null;
function errorOf(err: unknown): { code?: string; message?: string } {
return (err as { code?: string; message?: string } | undefined) ?? {};
}
export default function ReauthModal() {
const [pending, setPending] = React.useState<ReauthDetail | null>(null);
const navigate = useNavigate();
const logout = useLogout();
// Every gated request waiting on this prompt; one confirmation answers them all.
const [pending, setPending] = React.useState<ReauthDetail[] | null>(null);
const [methods, setMethods] = React.useState<MethodsState>(null);
const [password, setPassword] = React.useState("");
const [code, setCode] = React.useState("");
const [error, setError] = React.useState("");
const [busy, setBusy] = React.useState(false);
// Which method is in flight: "form", "passkey" or a provider id.
const [busy, setBusy] = React.useState<string | null>(null);
const pendingRef = React.useRef<ReauthDetail[] | null>(null);
// Bumped on every open and close, so a late answer from an abandoned attempt does nothing.
const attempt = React.useRef(0);
const popupRef = React.useRef<Window | null>(null);
const passkeyChallenge = React.useRef<Promise<ReauthPasskeyChallenge> | null>(null);
// Fetched ahead of the click: Safari drops the gesture WebAuthn needs across a round trip.
const preparePasskey = React.useCallback(() => {
const p = beginPasskeyReauth();
p.catch(() => undefined);
passkeyChallenge.current = p;
}, []);
React.useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<ReauthDetail>).detail;
detail.ack?.();
// Already open: this request waits on the same confirmation.
if (pendingRef.current) {
const joined = [...pendingRef.current, detail];
pendingRef.current = joined;
setPending(joined);
return;
}
attempt.current += 1;
const mine = attempt.current;
setPassword("");
setCode("");
setError("");
setBusy(false);
const detail = (e as CustomEvent<ReauthDetail>).detail;
detail.ack?.();
setPending(detail);
setBusy(null);
setMethods(null);
passkeyChallenge.current = null;
pendingRef.current = [detail];
setPending([detail]);
getReauthMethods()
.then((m) => {
if (attempt.current !== mine) return;
setMethods(m);
if (m.passkey && passkeySupported()) preparePasskey();
})
.catch(() => {
if (attempt.current === mine) setMethods("unknown");
});
};
window.addEventListener("reauth-required", handler);
return () => window.removeEventListener("reauth-required", handler);
}, [preparePasskey]);
const close = React.useCallback((outcome: "resolve" | "reject") => {
const waiting = pendingRef.current;
if (!waiting) return;
attempt.current += 1;
closePopup(popupRef.current);
popupRef.current = null;
pendingRef.current = null;
setPending(null);
for (const detail of waiting) {
if (outcome === "resolve") detail.resolve();
else detail.reject();
}
}, []);
const cancel = React.useCallback(() => {
pending?.reject();
setPending(null);
}, [pending]);
const cancel = React.useCallback(() => close("reject"), [close]);
// Topmost layer, so Escape is ours; dialogs underneath bail while an alertdialog is up.
React.useEffect(() => {
if (!pending) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.stopPropagation();
cancel();
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [pending, cancel]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (busy || !pending) return;
setBusy(true);
const mine = attempt.current;
setBusy("form");
setError("");
try {
await reauth({ password: password || undefined, code: code || undefined });
pending.resolve();
setPending(null);
if (attempt.current === mine) close("resolve");
} catch (err) {
const code = (err as { code?: string } | undefined)?.code;
if (code === "reauth_no_factor") {
// An account created through Google, Apple or SSO has nothing
// to confirm with until it adds one. Say what to do; the
// generic message below would be a dead end.
setError(
(err as { message?: string }).message ??
"This account has nothing to confirm with yet. Turn on two-factor authentication under Settings > Security.",
);
} else if (code === "reauth_limited") {
setError((err as { message?: string }).message ?? "Too many attempts. Try again later.");
if (attempt.current !== mine) return;
const { code: errCode, message } = errorOf(err);
if (errCode === "reauth_no_factor" || errCode === "reauth_limited") {
setError(message ?? "That did not work. Try again.");
} else {
// Otherwise one message: which factor matched is not something
// One message for both factors: which one matched is not something
// to spell out to whoever is sitting at the keyboard.
setError("That did not match. Try again.");
}
setBusy(false);
setBusy(null);
}
}
async function confirmPasskey() {
if (busy) return;
const mine = attempt.current;
setBusy("passkey");
setError("");
try {
const challenge = await (passkeyChallenge.current ?? beginPasskeyReauth());
passkeyChallenge.current = null;
await confirmWithPasskey(challenge);
if (attempt.current === mine) close("resolve");
} catch (err) {
if (attempt.current !== mine) return;
if (err instanceof PasskeyCancelled) {
if (err.reason === "not-allowed") setError("No passkey was used. Try again, or pick another way.");
if (err.reason === "timeout") setError("The passkey prompt timed out. Try again.");
} else {
setError(errorOf(err).message ?? "That passkey could not be verified. Try again.");
}
setBusy(null);
// A challenge is single use, so have the next one ready.
preparePasskey();
}
}
async function confirmProvider(provider: string) {
if (busy) return;
const mine = attempt.current;
// Opened inside the click, or Safari blocks it.
const reserved = reservePopup(POPUP_NAME);
popupRef.current = reserved;
setBusy(provider);
setError("");
try {
const { url, binding } = await beginSSOReauth(provider);
if (attempt.current !== mine) {
closePopup(reserved);
return;
}
const opened = navigatePopup(reserved, url, POPUP_NAME);
if (opened.status === "closed") {
setBusy(null);
return;
}
const popup = opened.status === "open" ? opened.window : null;
popupRef.current = popup;
if (!popup) notifyPopupBlocked();
const result = await waitForPopupMessage(popup, acceptReauthReturn);
if (attempt.current !== mine) return;
if (result.cancelled) {
setBusy(null);
return;
}
if (result.error || !result.code) {
setError(result.error || "That sign-in did not finish. Try again.");
setBusy(null);
return;
}
await finishSSOReauth(result.code, binding);
if (attempt.current === mine) close("resolve");
} catch (err) {
closePopup(popupRef.current);
if (attempt.current !== mine) return;
const message = err instanceof Error ? err.message : "";
// Closing the window is changing your mind, not a failure.
if (message !== POPUP_CLOSED) setError(errorOf(err).message ?? "That did not work. Try again.");
setBusy(null);
}
}
async function signInAgain() {
const next = window.location.pathname + window.location.search;
close("reject");
await logout.mutateAsync();
navigate({ to: "/auth/login", search: { next }, replace: true });
}
const known = methods !== null && methods !== "unknown" ? methods : null;
const showPassword = methods === "unknown" || Boolean(known?.password);
const showCode = methods === "unknown" || Boolean(known?.totp);
const showPasskey = Boolean(known?.passkey) && passkeySupported();
const providers = known?.providers ?? [];
const hasButtons = showPasskey || providers.length > 0;
const hasFields = showPassword || showCode;
const nothing = known !== null && !hasButtons && !hasFields;
let description = "This change needs a fresh check that it is you.";
if (methods !== null && !nothing) {
if (hasButtons) description += " Pick one of the ways below.";
else if (showPassword && showCode) description += " Enter your password, or a code from your authenticator.";
else if (showPassword) description += " Enter your password.";
else description += " Enter a code from your authenticator app.";
}
const optionClass =
"h-9 w-full rounded-md border border-slate-200 bg-white px-3 text-[12.5px] font-medium text-slate-800 inline-flex items-center justify-center gap-2 hover:bg-slate-50 disabled:opacity-60 disabled:hover:bg-white";
return (
<AnimatePresence>
{pending && (
@@ -96,6 +275,7 @@ export default function ReauthModal() {
// alertdialog so a dialog underneath leaves Escape to this prompt.
role="alertdialog"
aria-modal="true"
aria-labelledby="reauth-title"
initial={{ opacity: 0, scale: 0.97, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
@@ -108,11 +288,8 @@ export default function ReauthModal() {
<ShieldCheckIcon className="h-4 w-4 text-sky-700" />
</div>
<div className="flex-1">
<h2 className="text-[13.5px] font-medium text-slate-900">Confirm it is you</h2>
<p className="mt-1 text-[12.5px] text-slate-600">
This change needs a fresh check. Enter your password, or a code from
your authenticator.
</p>
<h2 id="reauth-title" className="text-[13.5px] font-medium text-slate-900">Confirm it is you</h2>
<p className="mt-1 text-[12.5px] text-slate-600">{description}</p>
</div>
<button
type="button"
@@ -124,29 +301,108 @@ export default function ReauthModal() {
</button>
</div>
<div className="mt-4 space-y-2.5">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
autoComplete="current-password"
autoFocus
className="h-9 w-full rounded-md border border-slate-200 px-2.5 text-[12.5px] outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
/>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Two-factor or recovery code"
inputMode="text"
autoComplete="one-time-code"
data-ph-mask=""
className="h-9 w-full rounded-md border border-slate-200 px-2.5 text-[12.5px] outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
/>
</div>
{methods === null ? (
<div className="mt-5 mb-1 flex justify-center" aria-label="Loading">
<Loader2Icon className="h-4 w-4 animate-spin text-slate-400" />
</div>
) : nothing ? (
<div className="mt-4 rounded-md border border-slate-200 bg-slate-50 px-3 py-2.5 text-[12.5px] leading-relaxed text-slate-600">
This account has no password, passkey or authenticator yet. Sign in again and repeat
this within five minutes. To skip this next time, add a passkey or two-factor
authentication under Settings &gt; Security.
</div>
) : (
<>
{hasButtons && (
<div className="mt-4 space-y-2">
{showPasskey && (
<button
type="button"
onClick={confirmPasskey}
disabled={busy !== null}
className={optionClass}
>
{busy === "passkey" ? (
<Loader2Icon className="h-4 w-4 animate-spin text-sky-500" />
) : (
<KeyRoundIcon className="h-4 w-4 text-sky-500" />
)}
{busy === "passkey" ? "Waiting for your passkey…" : "Use a passkey"}
</button>
)}
{providers.map((p) => {
const label = p.label || (p.id === "google" ? "Google" : p.id === "apple" ? "Apple" : "single sign-on");
return (
<button
key={p.id}
type="button"
onClick={() => confirmProvider(p.id)}
disabled={busy !== null}
className={optionClass}
>
{busy === p.id ? (
<Loader2Icon className="h-4 w-4 animate-spin text-sky-500" />
) : p.id === "google" ? (
<Google className="w-4 shrink-0" />
) : p.id === "apple" ? (
<RiAppleFill className="h-4 w-4 shrink-0" />
) : (
<LogInIcon className="h-4 w-4 text-slate-500" />
)}
{busy === p.id ? `Waiting for ${label}…` : `Continue with ${label}`}
</button>
);
})}
{busy !== null && busy !== "form" && busy !== "passkey" && (
<p className="text-center text-[12px] text-slate-500">
Finish in the window that opened.
</p>
)}
</div>
)}
{error && <p className="mt-2 text-[12px] text-rose-600">{error}</p>}
{hasButtons && hasFields && (
<div className="mt-4 flex items-center gap-3">
<div className="h-px flex-1 bg-slate-200" />
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400">or</span>
<div className="h-px flex-1 bg-slate-200" />
</div>
)}
{hasFields && (
<div className="mt-4 space-y-2.5">
{showPassword && (
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
aria-label="Password"
autoComplete="current-password"
autoFocus={!hasButtons}
className="h-9 w-full rounded-md border border-slate-200 px-2.5 text-[12.5px] outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
/>
)}
{showCode && (
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Two-factor or recovery code"
aria-label="Two-factor or recovery code"
inputMode="text"
autoComplete="one-time-code"
autoFocus={!hasButtons && !showPassword}
data-ph-mask=""
className="h-9 w-full rounded-md border border-slate-200 px-2.5 text-[12.5px] outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
/>
)}
</div>
)}
</>
)}
{error && <p role="alert" className="mt-2 text-[12px] text-rose-600">{error}</p>}
<div className="mt-4 flex justify-end gap-2">
<button
@@ -156,13 +412,26 @@ export default function ReauthModal() {
>
Cancel
</button>
<button
type="submit"
disabled={busy || (!password && !code)}
className="h-8 rounded-md bg-sky-600 px-3 text-[12.5px] font-medium text-white disabled:opacity-50"
>
{busy ? "Checking…" : "Confirm"}
</button>
{nothing ? (
<button
type="button"
onClick={signInAgain}
disabled={logout.isPending}
className="h-8 rounded-md bg-sky-600 px-3 text-[12.5px] font-medium text-white disabled:opacity-50"
>
{logout.isPending ? "Signing out…" : "Sign in again"}
</button>
) : (
hasFields && (
<button
type="submit"
disabled={busy !== null || (!password && !code)}
className="h-8 rounded-md bg-sky-600 px-3 text-[12.5px] font-medium text-white disabled:opacity-50"
>
{busy === "form" ? "Checking…" : "Confirm"}
</button>
)
)}
</div>
</motion.form>
</motion.div>
@@ -0,0 +1,12 @@
import Request from "../../Request";
import type ReauthMethods from "@/lib/api/models/auth/ReauthMethods";
export default function getReauthMethods(signal?: AbortSignal) {
return Request<ReauthMethods>({
method: "get",
url: "/auth/reauth/methods",
authorization: true,
skipReauthPrompt: true,
signal,
});
}
@@ -0,0 +1,26 @@
import type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON } from "@simplewebauthn/browser";
import Request from "../../Request";
export interface ReauthPasskeyChallenge {
options: { publicKey: PublicKeyCredentialRequestOptionsJSON };
}
/** Asks for one of the signed-in account's own passkeys. */
export function beginPasskeyReauth() {
return Request<ReauthPasskeyChallenge>({
method: "post",
url: "/auth/reauth/passkey/begin",
authorization: true,
skipReauthPrompt: true,
});
}
export function finishPasskeyReauth(credential: AuthenticationResponseJSON) {
return Request<{ valid_for_seconds: number }>({
method: "post",
url: "/auth/reauth/passkey/finish",
data: { credential },
authorization: true,
skipReauthPrompt: true,
});
}
+22
View File
@@ -0,0 +1,22 @@
import Request from "../../Request";
/** Starts a provider window for the account's linked identity; the binding stays in this tab. */
export function beginSSOReauth(provider: string) {
return Request<{ url: string; binding: string }>({
method: "post",
url: "/auth/reauth/sso/begin",
data: { provider },
authorization: true,
skipReauthPrompt: true,
});
}
export function finishSSOReauth(code: string, binding: string) {
return Request<{ valid_for_seconds: number }>({
method: "post",
url: "/auth/reauth/sso/finish",
data: { code, binding },
authorization: true,
skipReauthPrompt: true,
});
}
@@ -0,0 +1,10 @@
// GET /auth/reauth/methods: every way this account can confirm it is you.
export default interface ReauthMethods {
password: boolean;
totp: boolean;
passkey: boolean;
// Linked identities this deployment can re-verify in a browser window.
providers: { id: string; label: string }[];
native_providers: string[];
valid_for_seconds: number;
}
+34 -1
View File
@@ -9,7 +9,40 @@ const CHANNEL = "warmbly-oauth-return";
export type OAuthReturnMessage =
| { type: "email_oauth_callback"; provider: string; code: string; state: string; error: string }
| { source: "warmbly-integration-oauth"; code: string; state: string; error: string }
| { type: "cloud_oauth_callback"; session: string; status: "ok" | "error"; error?: string; message?: string };
| { type: "cloud_oauth_callback"; session: string; status: "ok" | "error"; error?: string; message?: string }
| ReauthReturnMessage;
/** What /reauth/complete reports to the tab waiting on a re-authentication window. */
export interface ReauthReturnMessage {
type: "warmbly_reauth";
code: string;
error: string;
cancelled: boolean;
}
/** Reads the provider callback's query on /reauth/complete. */
export function reauthReturnMessage(search: string): ReauthReturnMessage {
const p = new URLSearchParams(search);
return {
type: "warmbly_reauth",
code: p.get("code") ?? "",
error: p.get("error") ?? "",
cancelled: p.get("cancelled") === "1",
};
}
/** The re-authentication result in a window message from this origin, or undefined. */
export function acceptReauthReturn(event: MessageEvent): ReauthReturnMessage | undefined {
if (event.origin !== window.location.origin) return undefined;
const data = event.data as Partial<ReauthReturnMessage> | null;
if (!data || data.type !== "warmbly_reauth") return undefined;
return {
type: "warmbly_reauth",
code: typeof data.code === "string" ? data.code : "",
error: typeof data.error === "string" ? data.error : "",
cancelled: data.cancelled === true,
};
}
/** The message the callback page put in the fragment of /oauth-return, or null when it is not one. */
export function oauthReturnMessage(hash: string): OAuthReturnMessage | null {
+25
View File
@@ -20,6 +20,7 @@ import passkeyLoginBegin from "@/lib/api/client/auth/passkey/loginBegin";
import passkeyLoginFinish from "@/lib/api/client/auth/passkey/loginFinish";
import passkeyRegisterBegin from "@/lib/api/client/auth/passkey/registerBegin";
import passkeyRegisterFinish from "@/lib/api/client/auth/passkey/registerFinish";
import { finishPasskeyReauth, type ReauthPasskeyChallenge } from "@/lib/api/client/auth/reauth/passkey";
import type Token from "@/lib/api/models/auth/Token";
import type Passkey from "@/lib/api/models/auth/Passkey";
import type PasskeyLoginBegin from "@/lib/api/models/auth/PasskeyLoginBegin";
@@ -271,6 +272,30 @@ export async function passkeyLogin(opts?: { conditional?: boolean; signal?: Abor
return await finishPasskeyLogin(await beginPasskeyLogin(opts?.signal), opts);
}
/**
* Confirms the signed-in user with one of their own passkeys. Pass a challenge
* fetched before the click, for the same Safari reason as beginPasskeyLogin.
*/
export async function confirmWithPasskey(challenge: ReauthPasskeyChallenge): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const credential = await Promise.race([
startExplicitAuthentication(challenge.options.publicKey, 60000),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
cancelPasskeyCeremony();
reject(new PasskeyCancelled("timeout"));
}, 60000);
}),
]);
await finishPasskeyReauth(credential);
} catch (e) {
throw mapError(e, "authentication");
} finally {
if (timeout) clearTimeout(timeout);
}
}
/** Enroll a new passkey for the signed-in user. */
export async function registerPasskey(name?: string): Promise<Passkey> {
try {
+3
View File
@@ -234,6 +234,8 @@ const inviteRoute = standalone("invite", () => import("./app/invite/page"), "Joi
const connectRoute = standalone("connect", () => import("./app/connect/page"), "Connect");
// Where `warmbly auth login` sends the browser to approve its code.
const cliRoute = standalone("cli", () => import("./app/cli/page"), "Authorize CLI");
// Where a "confirm it is you" provider window reports back to the dashboard tab.
const reauthCompleteRoute = standalone("reauth/complete", () => import("./app/reauth/complete/page"), "Confirm it is you");
// Where Warmbly Cloud sends the Google/Microsoft popup back to on a linked instance.
const cloudOAuthDoneRoute = standalone("cloud-oauth/done", () => import("./app/cloud-oauth/done/page"), "Mailbox connected");
// Where a sign-in window without an opener hands its result to the waiting dashboard tab.
@@ -418,6 +420,7 @@ const routeTree = rootRoute.addChildren([
cliRoute,
cloudOAuthDoneRoute,
oauthReturnRoute,
reauthCompleteRoute,
setupRoute,
oauthRoute.addChildren([oauthAuthorize]),
appRoute.addChildren([