npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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:

  1. 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.
  2. Rotating refresh tokens — every POST /rp/token issues a new refreshToken and invalidates the previous one. Replaying a rotated refresh token trips Verdify's reuse-detection and revokes the entire session family.
  3. Prefer the in-memory store (the default). Only opt into WebStorageTokenStore where persistence across reloads is genuinely needed.
  4. Ship a strict script-src Content 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-browser

Quick 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 either null or whatever was already there — never a fabricated session — so const { accessToken } = rp.store.get() throws. Branch on ok first, or use rp.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.mfaRequirednot "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: WebStorageTokenStore exposes tokens to XSS. Only opt in if you need persistence across reloads and you have a strict Content Security Policy in place. The key argument ("vfy" above) is the localStorage key; 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 AllowedOrigins including your frontend's origin (https://your-app.example.com). Every /rp/* call is rejected with 401 if the Origin header is not in the allowlist.
  • The public /auth/v1/rp/* Gateway route is a Wave 4d deliverable. Until it ships, point baseUrl at 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.