@verdify/auth-browser
v0.3.0
Published
Verdify Tier-2 browser auth client — client-direct credential ceremony for relying parties.
Readme
@verdify/auth-browser
Verdify Tier-2 browser auth client — the credential ceremony for product frontends.
Generated from verdify-contracts (auth.v1). Runs in the SPA; holds body tokens.
Browser-token security note: This package intentionally holds
RpSessionTokens(access token, refresh token, device ID) in the browser — the design for the Tier-2 client-direct flow. This is the opposite posture from@verdify/sdk-ts, which is server-side only (D-On7). Do not confuse the two.Because the tokens are browser-held, these mitigations are required, not optional:
- Short access TTL — the server issues a 10-minute access token (
expiresIn: 600), so an exfiltrated token is usable only for the remainder of that window.- Rotating refresh tokens — every
POST /rp/tokenissues a newrefreshTokenand invalidates the previous one. Replaying a rotated refresh token trips Verdify's reuse-detection and revokes the entire session family.- Prefer the in-memory store (the default). Only opt into
WebStorageTokenStorewhere persistence across reloads is genuinely needed.- Ship a strict
script-srcContent Security Policy — it is the main control that limits the injected-script XSS vector these tokens are exposed to.The full decision record ships inside this package at
docs/adr/0001-browser-token-posture.md(node_modules/@verdify/auth-browser/docs/adr/…). The source repository is private, so that link resolves against the installed package, not against GitHub.
Install
pnpm add @verdify/auth-browserQuick start — integrating Verdify auth into your product frontend
import { createRpAuthClient } from "@verdify/auth-browser";
// baseUrl MUST include /auth/v1 — the generated paths are server-relative /rp/...
const rp = createRpAuthClient({
baseUrl: "https://<auth-host>/auth/v1",
clientId: "rp_spare",
});Replace <auth-host> with the per-environment Verdify auth host. The client adds
X-Verdify-Client-Id: rp_spare on every request; the browser sets Origin
automatically.
Credential ceremony
Sign up + verify email
// Step 1: initiate sign-up (triggers OTP email)
const signUpResult = await rp.signUpEmail({
email: "[email protected]",
password: "S3cur3P@ss!",
});
if (!signUpResult.ok) {
throw new Error(`sign-up failed: ${signUpResult.status}`);
}
const { authSessionId } = signUpResult.data; // AuthChallenge: { authSessionId, next, otpSentTo? }
// Step 2: verify the OTP from email — carry the authSessionId returned by signUpEmail
const verifyResult = await rp.verifyEmail({
authSessionId,
code: "123456",
});
if (verifyResult.ok) {
// Tokens stored automatically; verifyResult.data holds RpSessionTokens
console.log("access token:", rp.store.get()?.accessToken); // vfy_at_...
}Login
const loginResult = await rp.login({
email: "[email protected]",
password: "S3cur3P@ss!",
});
if (loginResult.ok) {
// Tokens stored automatically
const tokens = rp.store.get();
console.log("session:", tokens?.sessionId);
}Do not read
rp.store.get()unconditionally after a login. A login that does not issue a session leaves the store holding eithernullor whatever was already there — never a fabricated session — soconst { accessToken } = rp.store.get()throws. Branch onokfirst, or userp.store.get()?.accessToken.
Login when the account has MFA enabled
POST /rp/login answers 202 with an MFA challenge instead of a session whenever the
identity has a confirmed TOTP. That is not a completed login: ok is false and
nothing is written to the token store. verifyEmail() returns the same union and the same
branch applies.
import type { RpLoginResult } from "@verdify/auth-browser";
const r: RpLoginResult = await rp.login({ email: "[email protected]", password: "S3cur3P@ss!" });
if (r.ok) {
// Session issued and stored; r.data is RpSessionTokens.
} else if (Object.hasOwn(r, "mfaRequired") && r.mfaRequired) {
// r.mfaToken — opaque grant to present alongside the second factor
// r.expiresIn — seconds until that grant expires
promptForSecondFactor(r.mfaToken);
} else {
// Ordinary failure. r.error is the ErrorEnvelope.
show(r.error.error.message);
}Narrow with Object.hasOwn(r, "mfaRequired") && r.mfaRequired — not "mfaRequired" in r.
in walks the prototype chain, so a prototype-pollution gadget anywhere else in your bundle
could make a genuine 401 read as an MFA challenge carrying an attacker-supplied mfaToken.
Every ok: false result carries mfaRequired as an own property for exactly this reason.
RpLoginResult and RpMfaRequiredResult are both exported from the package root. Spend the
mfaToken with completeRpMfaChallenge(), below.
Completing the challenge — completeRpMfaChallenge()
rp.completeRpMfaChallenge(args) is POST /rp/mfa/challenge, the second half of the login
ceremony. It takes the mfaToken from the 202 plus exactly one second factor. On
success the token pair is written to the store through the same guard login() uses; every
failure leaves the store exactly as it was, including a session you already held.
import type { RpMfaChallengeResult } from "@verdify/auth-browser";
const r: RpMfaChallengeResult = await rp.completeRpMfaChallenge({
mfaToken: r202.mfaToken,
code: "123456", // or: recoveryCode: "abcd-efgh-jkmn"
});
if (r.ok) {
// Session issued and stored. r.data is RpSessionTokens.
} else if (r.failure === "rate_limited") {
show("Too many attempts — try again shortly.");
} else if (r.failure === "malformed") {
// A bug in the CALL, not a failed challenge: a missing grant, or neither/both factors.
} else {
// "refused" or "failed". r.error is the ErrorEnvelope.
show(r.error.error.message);
}RpMfaChallengeArgs, RpMfaChallengeResult and RpMfaChallengeFailure are exported from the
package root, so you can name the types instead of inferring them.
Unlike Tier-1, whose grant is an httpOnly vfy_mfa cookie, a cross-origin RP cannot use
cookies at all — so both the grant and the resulting token pair travel in the body. There
is no scopes parameter by design: the grant already carries the rpClientId and the scopes
chosen at the original login, and verdify-auth reads them from the grant. A caller cannot
widen its own session at the challenge step.
The four failure values
Every ok: false result carries failure as an own property.
| failure | When | What a UI should say |
|---|---|---|
| refused | 401 | "That code was not right." |
| malformed | 422 | Nothing — it is a client bug, not a user error. |
| rate_limited | 429 | "Too many attempts; wait and try again." |
| failed | transport failure, 5xx, or a 2xx carrying no usable session | "Something went wrong" — not "wrong code". |
refused is deliberately coarse — do not try to refine it. A wrong code, a consumed
grant, an expired grant, a reached attempt cap and an identity deactivated since the 202
are all the same generic VERDIFY-AUTH-401-105. The server makes them indistinguishable on
purpose: telling them apart is an oracle for exactly the account state a second factor
protects. failed is separate from refused for the opposite reason — when the network
dies the factor was never judged, so saying "wrong code" is both false and an invitation to
burn another attempt.
These four spellings, and the failure property name, are shared verbatim with
@verdify/sdk-ts's Tier-1 completeMfaChallenge() (ADR-0077), so a consumer funnelling both
into one handler needs one vocabulary, not two.
Exactly one factor, plus the grant — enforced twice
RpMfaChallengeArgs is a union whose arms use ?: never, so { code, recoveryCode } and
{ mfaToken } alone are compile errors. A runtime guard repeats the rule for JavaScript
callers and refuses without sending the request — the route is rate-limited per-IP, so a
request that cannot succeed still costs budget. The guard also requires mfaToken, for the
same reason, and classifies the two causes differently, matching what the server itself
says for each: neither/both code/recoveryCode supplied is failure: "malformed" with
status: 422 (the server's own answer for a malformed call); a missing, empty or non-string
mfaToken is failure: "refused" with status: 401 (MfaGrantBearer.TryParse fails closed
into the same generic refusal a wrong or consumed grant gets — not a 422, and this package's
own first attempt at this guard got that wrong).
An empty string is not a factor, matching verdify-auth's own !string.IsNullOrEmpty
check: { mfaToken, code: "", recoveryCode: "rc-…" } is one factor, not two, so a form that
initialises both fields to "" still works.
The guard never throws, whatever it is handed: undefined, null and non-objects all return
an ordinary ok: false result, because a form field is undefined before the first keystroke
and every method on this client promises a non-throwing Result.
A 2xx that claims mfaRequired: true is never treated as a session, even if it also carries
a full token set — the server said no session was issued, so nothing in it is stored.
Refresh (auto and manual)
refreshIfNeeded() returns the current tokens, refreshing first if the access token is
within 30 seconds of expiry. Call it before any authenticated request to ensure the
access token is valid.
// At app mount or before an authenticated fetch:
const tokens = await rp.refreshIfNeeded();
if (!tokens) {
// No stored session — redirect to login
}Single-flight guarantee: concurrent refreshIfNeeded() calls in a SPA (e.g. from
multiple components mounting simultaneously) share a single POST /rp/token request.
Without this, a second call could replay an already-rotated refresh token, which triggers
Verdify's reuse-detection and revokes the entire session family.
Use rp.refresh() to force an unconditional refresh regardless of expiry.
Logout
rp.logout(); // Clears the token store. Call your own navigation/state cleanup.Token storage
The default TokenStore is in-memory (most XSS-resistant). Tokens are held in a
plain heap object and are lost on page reload.
In-memory (default)
// Default — no configuration needed.
const rp = createRpAuthClient({ baseUrl: "...", clientId: "rp_spare" });WebStorage (opt-in, persists across reloads)
import { createRpAuthClient, WebStorageTokenStore } from "@verdify/auth-browser";
const rp = createRpAuthClient({
baseUrl: "https://<auth-host>/auth/v1",
clientId: "rp_spare",
store: new WebStorageTokenStore(localStorage, "vfy"),
});Warning:
WebStorageTokenStoreexposes tokens to XSS. Only opt in if you need persistence across reloads and you have a strict Content Security Policy in place. Thekeyargument ("vfy"above) is thelocalStoragekey; choose one that does not collide with other libraries.
Custom store
Implement the TokenStore interface to integrate with your own state management:
import type { TokenStore, StoredTokens } from "@verdify/auth-browser";
class ReduxTokenStore implements TokenStore {
get(): StoredTokens | null { return store.getState().auth.tokens; }
set(t: StoredTokens): void { store.dispatch(setTokens(t)); }
clear(): void { store.dispatch(clearTokens()); }
}All options
createRpAuthClient({
/** Auth host including the /auth/v1 path prefix. Required. */
baseUrl: "https://<auth-host>/auth/v1",
/** Your relying-party client ID (must be in verdify-auth's AllowedOrigins config). */
clientId: "rp_spare",
/**
* Custom fetch (e.g. for testing, or a runtime wrapper). Defaults to globalThis.fetch.
*
* CALLED AS `fetch(url, init)` SINCE 0.2.0 — not `fetch(request)`. `url` is a plain
* string, `init.headers` is a plain `Record<string, string>` (NOT a `Headers`, so
* `init.headers.get(...)` is not a function), and `init.body` is a `Uint8Array`.
* A wrapper written against 0.1.0 that read `input.url` or `input.headers.get(...)`
* off a `Request` now reads them off a string and gets `undefined`.
*/
fetch?: typeof fetch,
/**
* Max GET retries on transport failure / 502-503-504. Default: 2.
*
* CURRENTLY AFFECTS NOTHING. Every operation this client exposes is a POST
* (/rp/sign-up/email, /rp/verify-email, /rp/login, /rp/token) and POSTs are never
* retried — replaying a login or a token rotation after a gateway 5xx re-spends
* credential-attempt and rate-limit budget for a request the server may already have
* processed. Under 0.1.0 the method was invisible to the retry predicate, so POSTs were
* retried by accident; 0.2.0 ends that. Retry at your own layer if you need it.
*/
maxRetries?: number,
/** Token persistence. Default: InMemoryTokenStore. */
store?: TokenStore,
/** Override the clock for testing. Default: Date.now. */
now?: () => number,
/** Refresh this many ms before expiry. Default: 30000 (30s). */
refreshSkewMs?: number,
})Prerequisites
- verdify-auth must have your relying-party configured with
AllowedOriginsincluding your frontend's origin (https://your-app.example.com). Every/rp/*call is rejected with 401 if theOriginheader is not in the allowlist. - The public
/auth/v1/rp/*Gateway route is a Wave 4d deliverable. Until it ships, pointbaseUrlat a port-forwarded or local-compose auth service.
Status
Wave 4a-ii. Generated from verdify-contracts at ref 8888cf7 (auth.v1 RP surface) — the
pin lives in codegen.config.ts (CONTRACTS_REF) and CI diffs the vendored spec against it.
The browser token posture decision is documented in
docs/adr/0001-browser-token-posture.md, which
ships in this package.
Behaviour changes between releases — including the ones a consumer-supplied fetch and any
code that constructs a result must react to — are in
CHANGELOG.md, which also ships in this package.
