@altimist/did-web-client
v0.7.2
Published
Server-side + browser helpers for verifying Altimist users (did:web) and team Verifiable Credentials, tier-2 read helpers for inline identity-status rendering, the SE-signed JWT bridge for MCP, and the F-016 release-vcs client for scoped VC release. F-020
Downloads
779
Readme
@altimist/did-web-client
Drop this library into any app — yours or third-party — to authenticate Altimist users without redirecting them anywhere or running a central session. Verification happens locally; the user signs a challenge with their device, you check the signature against their public DID document, done.
npm install @altimist/did-web-clientWhat this is
- A verifier library. Your app uses it to check that someone holds a particular Altimist DID, holds a particular team credential, or hasn't had their credentials revoked.
- Server + browser, ESM, no runtime dependencies on altimist-id. Server-side functions for verifying signatures and credentials; a small browser-side helper for the WebAuthn ceremony.
- The protocol implementation of F-010. Counterpart to
@altimist/did-publisher(the publisher-side library used by the altimist-com-router Cloudflare Worker that hosts DIDs at*.altimist.com).
What this is not
- Not an OAuth provider, OIDC client, or session library. No redirect flow. No central session store. No bearer tokens issued by an Altimist server. You generate challenges, the user signs them locally, you verify locally. Period.
- Not a place to send users to "log in." Users authenticate against your app, not against altimist.id. altimist.id is where users went once, to create their identity — like signing up for a Gmail account. Subsequent sign-ins to your app happen between your app and the user's device, mediated by this library.
- Not a profile or marketing surface. Identity profiles live at
<handle>.altimist.com, owned by the corporate website. This library doesn't render anything.
Trust model
This library is designed so altimist.id is never on the request path between your app and your users. The diagram is short:
your app ──── WebAuthn challenge ────► user's browser ────► user's Secure Enclave (signs)
▲ │ │
│ ▼ │
│ ◄──── signed assertion ─────────── ──────────────────────────── │
│
└──── fetch did.json ──────────────► patrick.altimist.com (Cloudflare-cached, public)altimist-id wrote the user's did.json to patrick.altimist.com once (when the user enrolled their first device) and updates it when the user adds/revokes a device. After that, your app reads it the same way any verifier in the world does — over HTTPS, with no token, no rate limit, no API key.
Implications:
- Your app's auth latency is dominated by Cloudflare cache. Sub-50ms typical.
- altimist-id can be down without breaking auth on your app.
- altimist-id can't see who's signing in to your app — no telemetry pipe.
- The only "shared secret" between your app and altimist is the user's published public key in their DID doc. That's it.
Prerequisites
Before this library is useful for a given user, the user needs to already have an Altimist DID. They get one by signing up at altimist.id (the operator's onboarding surface), where they pick a handle and enrol a device using their browser's passkey support. After that, did:web:<handle>.altimist.com/.well-known/did.json exists and is publicly resolvable, and your app can authenticate them.
You don't need to know about altimist.id beyond pointing your users at it. If a user lands in your sign-in flow without an Altimist DID yet, send them to https://altimist.id/signup.
Quick start: add Altimist sign-in to a Next.js app
Two server routes plus a browser-side button. Total: ~50 lines of glue.
1. Server: issue the challenge
// app/api/auth/altimist/challenge/route.ts
import { issueChallenge } from "@altimist/did-web-client";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { handle } = await req.json();
const options = await issueChallenge({
handle,
rpID: "yourapp.com", // your app's domain
});
// Persist options.challenge alongside the user's session — you'll
// re-check it on /verify. iron-session, cookies, or a short-lived
// server-side cache all work.
await persistChallenge(handle, options.challenge);
return NextResponse.json(options);
}2. Server: verify the assertion
// app/api/auth/altimist/verify/route.ts
import { verifyChallenge } from "@altimist/did-web-client";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { handle, response } = await req.json();
const expectedChallenge = await readPersistedChallenge(handle);
const result = await verifyChallenge({
handle,
expectedChallenge,
expectedOrigin: "https://yourapp.com",
expectedRPID: "yourapp.com",
response, // AuthenticationResponseJSON from browser
});
if (!result.ok) {
return NextResponse.json({ error: result.reason }, { status: 401 });
}
// result.kid = the device key id that signed this challenge.
// Issue your own session cookie / JWT here.
return NextResponse.json({ handle, kid: result.kid });
}3. Browser: drive the WebAuthn ceremony
// app/components/sign-in-button.tsx
"use client";
import { startAuthentication } from "@simplewebauthn/browser";
export function SignInWithAltimist({ handle }: { handle: string }) {
async function handleClick() {
const optionsRes = await fetch("/api/auth/altimist/challenge", {
method: "POST",
body: JSON.stringify({ handle }),
});
const options = await optionsRes.json();
const response = await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch("/api/auth/altimist/verify", {
method: "POST",
body: JSON.stringify({ handle, response }),
});
if (verifyRes.ok) window.location.href = "/dashboard";
}
return <button onClick={handleClick}>Sign in with Altimist</button>;
}That's the whole sign-in flow. The user clicks, their device prompts for biometric (Touch ID, Windows Hello, 1Password), and your app gets their authenticated handle.
You install @simplewebauthn/browser separately (npm install @simplewebauthn/browser); this library doesn't bundle it.
Authorisation: checking team membership
Sign-in proves "this is patrick." Authorisation answers "what is patrick allowed to do?" — answered by checking a Verifiable Credential issued by an Altimist team-hub.
import { fetchTeamIssuer, verifyMembershipVC, isRevoked } from "@altimist/did-web-client";
const { jwk } = await fetchTeamIssuer("altimist"); // public key
const result = await verifyMembershipVC({
vcJwt: presentedVC,
issuerPublicJwk: jwk,
subjectDid: `did:web:${handle}.altimist.com`,
});
if (!result.ok) return forbid("invalid VC");
if (await isRevoked(result.vcHash)) return forbid("revoked");
if (!result.scopes.includes("capital.trader")) return forbid("missing scope");The VC is presented to your app by the user (typically in a header or query param after sign-in). Your app verifies the signature locally — no network call to altimist-id, just fetchTeamIssuer to get the public key from altimist.com/.well-known/team-issuers/altimist.json, which is heavily cached.
MCP / agent-to-server bridge (v0.2+)
For machine-to-machine calls where the user's authenticated agent needs to authenticate to a downstream server (e.g. an MCP server) without a browser in the loop:
Mint (browser, after the user is signed in):
import { mintBridgeJwt } from "@altimist/did-web-client/browser";
const token = await mintBridgeJwt({
handle: "patrick",
kid: "<cred id from did.json>",
rpID: "yourapp.com",
});
// Use as: Authorization: Bearer <token>Verify (server-side, on the receiving end of the bridge):
import { verifyBridgeJwt } from "@altimist/did-web-client";
const result = await verifyBridgeJwt({
token: bearerToken,
expectedOrigin: "https://yourapp.com",
expectedRPID: "yourapp.com",
});
if (!result.ok) return res.status(401).json({ reason: result.reason });
// result.handle, result.kid, result.vc?, result.jti, result.iat, result.expThe bridge JWT carries a 60-second lifetime by default and a jti so the receiver can implement replay protection if needed. It's not a vanilla JWT — the signature uses Secure Enclave WebAuthn rather than a server-side key, so off-the-shelf JWT libraries won't verify it. Always use verifyBridgeJwt.
Full API
| Function | Purpose |
|---|---|
| resolveDid(handle, opts?) | Fetch + parse <handle>.altimist.com/.well-known/did.json (subdomain form) |
| parseDid(did) | Parse a did:web: identifier into { form, host, handle, url }. Recognises both subdomain (did:web:<handle>.<host>) and F-011 path form (did:web:<host>:users:<handle>) |
| getDidDocument(did) | Same as resolveDid but takes a full did:web: identifier (either form) — uses parseDid to derive the URL. Validates the round-trip (doc.id === input did) |
| getDevices(handle, opts?) | Tier-2 helper: returns the user's active devices as { kid, publicKeyJwk }[] for inline display |
| getAlsoKnownAs(handle, opts?) | Tier-2 helper: returns the user's bound alternative DIDs (display-only) |
| getMemberships(handle, vcs, opts?) | Tier-2 helper: filters presented JWT-VCs to only those that verify against the user's identity AND aren't revoked |
| issueChallenge({ handle, rpID }) | WebAuthn auth options for navigator.credentials.get() — allowCredentials is drawn from the user's did.json |
| verifyChallenge({ ... }) | Verify a WebAuthn assertion against the JWK published in did.json |
| fetchTeamIssuer(team, opts?) | Fetch the team-hub public JWK from altimist.com/.well-known/team-issuers/<team>.json |
| verifyMembershipVC({ ... }) | Verify a JWT-VC (W3C VC 2.0) signed by a team-hub. Pure function — no network |
| isRevoked(hash, opts?) | Check altimist.com/.well-known/revocations.json |
| mintBridgeJwt({ ... }) (browser) | Mint an SE-signed JWT for machine-to-machine auth |
| verifyBridgeJwt({ ... }) | Verify a bridge JWT |
All fetchers accept an optional { baseUrl } (template with {handle} / {team} substitution) so staging environments can override the apex (e.g. altimist.dev for testing).
Tier-2 helpers — inline identity-status rendering
For the auth-vs-display split: tier-2 helpers let consumer apps render identity state inline without redirecting users to altimist.id. Use them on your own account/profile pages.
import { getDevices, getMemberships, getAlsoKnownAs } from "@altimist/did-web-client";
// "Patrick · 3 devices" inline
const devices = await getDevices("patrick");
// "Patrick holds: staff.admin, capital.trader"
const memberships = await getMemberships("patrick", presentedVcs);
// "Also known as: did:web:patrickjv.com"
const akas = await getAlsoKnownAs("patrick");All three are server-only, fail-closed (resolver errors throw ResolverError), and accept the same { baseUrl } template option for staging overrides. getMemberships additionally accepts teamIssuerBaseUrl and revocationsUrl for fully custom staging.
For sensitive identity mutations (add device, revoke device, bind alt-DID, account recovery), don't use this library — those happen at altimist.id via deep-link from your app. See F-012 spec for the tier-3 deep-link pattern.
Common gotchas
expectedOriginandexpectedRPIDmust match what the user's browser saw. WebAuthn binds credentials to (origin, RP_ID) pairs. If your app isyourapp.com, set both. If you have a staging host, keep separate values for that environment. Mismatches surface asresult.ok === falsewithreason: "origin"orreason: "rpid".- Persist the challenge between issue and verify. The challenge is single-use, short-lived (~60s), and stateless from this library's perspective. Use a session cookie, iron-session, Redis, or a short-lived DB row.
- Don't put the kid in your session cookie if you can avoid it. Use it transiently for revocation checks at sign-in time, then issue your app's normal session token. Storing per-device kids in long-lived cookies leaks user-device-fingerprint info if your cookies leak.
- Revocation is fail-closed by default. If the apex revocations endpoint is unreachable,
isRevokedreturnstrue(treats credentials as revoked) rather than passing through. Pass{ failOpen: true }if you'd rather degrade open during an outage. Discuss with your security stakeholders before flipping. @simplewebauthn/browserversions matter. This library was tested against@simplewebauthn/browserv10+. Earlier versions have different API shapes.
Versioning
This library follows semver. Pin to a specific version ("@altimist/did-web-client": "0.3.x") until 1.0 — the API surface is stable but internal types may move.
Phase 2a status
- v0.1 — initial publish. Identity verification (
resolveDid,issueChallenge,verifyChallenge), VC verification (fetchTeamIssuer,verifyMembershipVC), revocation check (isRevoked). - v0.2 — bridge JWT mint + verify (M8). VC revocation cross-check at verify time (M9).
- v0.3 (F-012) — tier-2 read helpers (
getDevices,getMemberships,getAlsoKnownAs) for inline rendering of identity status in consumer apps without redirects. Foundation for the E-002 consumer-app onboarding epic. - v0.6 (F-011) — dual-form did:web parsing.
parseDid(did)+getDidDocument(did)recognise bothdid:web:<handle>.<host>(subdomain) anddid:web:<host>:users:<handle>(path) and fetch from the right URL transparently.resolveDid(handle, opts?)is unchanged for callers that already have the handle string.
See F-010 milestones for the broader plan and F-012 spec for the tier-2 + tier-3 design.
