Appearance
Authentication & Sessions
v5 does not use plain bearer tokens. Every request is signed with a key pair held by your client (DPoP, RFC 9449), and the tokens issued at login are bound to that key — a token copied off the wire is useless without the private key.
Three rules govern the whole session:
- One key pair per session. Generate it once and reuse it for login, every request, and every refresh. The server enforces key continuity.
- A fresh proof per request. The
DPoPheader carries a short-lived JWT with the method, the URL, a unique id, a timestamp — and, once authenticated, a hash of the access token (ath). - A nonce handshake on the first call. The server requires a server-issued nonce inside each proof. A client that doesn't have one yet gets
400 use_dpop_nonceplus aDPoP-Nonceresponse header; retry with that value in the proof'snonceclaim. Any later response may rotate the nonce — always adopt the latest.
The DPoP client
This is less work than it sounds — the whole client fits in one small class (src/dpop.js in the example project):
js
import { randomUUID } from "node:crypto";
const b64url = (input) => Buffer.from(input).toString("base64url");
export class DpopClient {
#keyPair; #jwk; #nonce;
static async create() {
const client = new DpopClient();
client.#keyPair = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
const { kty, crv, x, y } = await crypto.subtle.exportKey("jwk", client.#keyPair.publicKey);
client.#jwk = { kty, crv, x, y }; // public parts only
return client;
}
/** One single-use proof JWT. `accessToken` adds the `ath` claim; omit it for login. */
async proof(method, url, accessToken) {
const { origin, pathname } = new URL(url);
const header = { alg: "ES256", typ: "dpop+jwt", jwk: this.#jwk };
const payload = {
jti: randomUUID(),
htm: method.toUpperCase(),
htu: `${origin}${pathname}`, // no query, no fragment
iat: Math.floor(Date.now() / 1000),
};
if (this.#nonce) payload.nonce = this.#nonce;
if (accessToken) {
const digest = await crypto.subtle.digest("SHA-256", Buffer.from(accessToken, "utf8"));
payload.ath = b64url(Buffer.from(digest));
}
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const signature = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" }, this.#keyPair.privateKey,
Buffer.from(signingInput, "utf8"));
return `${signingInput}.${b64url(Buffer.from(signature))}`;
}
/** Sends a signed request and absorbs the nonce handshake transparently. */
async fetch(method, url, { accessToken, headers, ...rest } = {}) {
const send = async () => {
const requestHeaders = new Headers(headers);
requestHeaders.set("DPoP", await this.proof(method, url, accessToken));
// The scheme is DPoP, not Bearer — a Bearer-prefixed token is rejected.
if (accessToken) requestHeaders.set("Authorization", `DPoP ${accessToken}`);
return fetch(url, { ...rest, method, headers: requestHeaders });
};
let response = await send();
this.#adoptNonce(response);
if (response.status === 400) {
const body = await response.clone().json().catch(() => null);
if (body?.error === "use_dpop_nonce" && this.#nonce) {
response = await send();
this.#adoptNonce(response);
}
}
return response;
}
#adoptNonce(response) {
const issued = response.headers.get("DPoP-Nonce");
if (issued) this.#nonce = issued;
}
}Logging in
With the client in place, logging in is one call to POST /auth/login:
js
const client = await DpopClient.create();
const response = await client.fetch("POST", "https://<<URL>>/api/v1/auth/login", {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "<<EMAIL>>", password: "<<PASSWORD>>" }),
});
const { accessToken, refreshToken } = await response.json();Things the login can answer besides a token pair:
409 TENANT_CONFLICT_ERROR— your email exists in more than one tenant reachable under this host and the password matches in more than one of them (a password matching a single tenant picks that account on its own). The response lists the candidates; retry withtenantIdentifier(the bare tenant id or thecrn#tenant:…form from the list) in the body.200withstatus: "2fa_required"— the tenant enforces two-factor authentication. The response carries a short-lived token to complete the second factor at the 2FA endpoints (TOTP or WebAuthn) before you get a full session.
Making authenticated requests
Every authenticated call needs the access token and a fresh proof with the ath claim — the DpopClient above handles both when you pass accessToken:
js
const devices = await client.fetch("GET", "https://<<URL>>/api/v1/devices?depth=2", {
accessToken,
});Token lifecycle
Token lifetimes are short by design:
- Access tokens live 10 minutes. On a
401, refresh and retry once. - Refresh tokens live 8 hours and are rotated on every use —
POST /auth/tokenwith{ "refreshToken": "…" }returns a new pair; always replace both. - To end a session server-side, call
POST /auth/logout(add?all=trueto sign out every session of the account).
The example project wraps this pattern — refresh on 401, retry once — in src/session.js, so application code never deals with expiry.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
401 on every request | The Authorization scheme must be DPoP, not Bearer — and the proof must be signed with the key used at login. |
400 use_dpop_nonce never resolves | Copy the DPoP-Nonce response header into the next proof's nonce claim. Every response may rotate it — always adopt the latest. |
401 "DPoP ath does not match access token" | Authenticated calls need the ath claim: base64url(SHA-256(accessToken)). |
401 "DPoP proof already used (replay)" | Every proof needs a fresh jti — never reuse one. |
401 behind a reverse proxy | The proof's htu is checked against origin and path as the server sees them. Forward the real host and scheme. |
409 at login | The email and password match accounts in several tenants on this host — pass tenantIdentifier in the login body. |
401 with proofs that look right | Client clock skew: the proof's iat is checked with ±60 s tolerance. The server exposes its Date response header — use it to correct your clock offset. |