Sign an assertion
A copy-paste TypeScript reference implementation — sign a client assertion and exchange it for a member token.
This is the whole client side of the standard: sign a JWT, post it, get a member token
back. It uses jose and runs unmodified on
Node ≥ 18, Bun, Deno and Workers.
The snippet below is not a transcription — it is the file this page imports, and it is typechecked and unit-tested on every build against the rules the exchange applies. If it renders here, it compiles and it produces an assertion we accept.
// Sign a client assertion and exchange it for a Doshi member token. Backend only: the// private key and the assertion never reach a browser.import { importPKCS8, SignJWT } from "jose";
/** * Assertion lifetime. 60 s is the recommendation; the exchange enforces a 300 s * ceiling and allows ±60 s of clock skew either way. */export const ASSERTION_TTL_SECONDS = 60;
/** RFC 7523 §2.1. The only grant type the exchange accepts. */const JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
export type ClientConfig = { /** Your issuer, such as `urn:doshi:<slug>:live`, exactly as we assigned it. `DOSHI_ISS`. */ issuer: string; /** The exchange endpoint, one for Live and Test alike. Becomes the assertion's `aud`. `DOSHI_AUD`. */ audience: string; /** `kid` of the key you are signing with, the one we gave it. `DOSHI_KID`. */ keyId: string; /** ES256 or RS256. Must match the key. */ algorithm: "ES256" | "RS256"; /** PKCS#8 PEM private key, read from your secret store. Never ship it to a browser. `DOSHI_PRIVATE_KEY`. */ privateKeyPkcs8: string;};
/** * Your config from the environment variables your Doshi console gives you when it makes your * key, read from your secret store into the process. It shows them as a block like this one: * * DOSHI_ISS="urn:doshi:acme-bank:test" * DOSHI_AUD="https://gateway.doshi.app/oauth/token" * DOSHI_KID="prd_7f3a91c2" * DOSHI_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMG...\n-----END PRIVATE KEY-----\n" * * The private key's newlines are escaped as `\n` so it fits on one line; whether or not your * loader turns them back into newlines, this does. Pass `process.env` on Node and Bun. * `algorithm` is for a key you made yourself: a key the console made is ES256. */export function configFromEnv( env: Record<string, string | undefined>, algorithm: ClientConfig["algorithm"] = "ES256",): ClientConfig { const read = (name: string) => { const value = env[name]; if (value === undefined || value === "") throw new Error(`${name} is not set`); return value; }; return { issuer: read("DOSHI_ISS"), audience: read("DOSHI_AUD"), keyId: read("DOSHI_KID"), algorithm, privateKeyPkcs8: read("DOSHI_PRIVATE_KEY").replaceAll("\\n", "\n"), };}
/** * What you tell Doshi about the member besides who they are, none of it an identifier — `sub` * is the identity. `name` is what Doshi shows the member; `email` is optional and unused. */export type MemberClaims = { email?: string; name?: string; /** The date this person became your customer, as `YYYY-MM-DD`, such as `2009-04-17`. */ customer_since?: string;};
/** RFC 6749 §5.1. `expires_in` is seconds; there is no refresh token by design. */export type MemberToken = { access_token: string; token_type: "Bearer"; expires_in: number };
/** A rejection from the exchange, carrying the OAuth code from the error table. */export class ExchangeError extends Error { constructor( /** * The OAuth code from the error table. **Empty for a response the exchange did not * shape itself**, such as a proxy's error page. Nothing is invented here: a code you * could branch on must be one the table lists. */ readonly code: string, readonly description: string, readonly status: number, /** Seconds to wait, from the `Retry-After` header. Set on a 429. */ readonly retryAfter?: string, ) { super(code ? `${code}: ${description}` : `HTTP ${status}: ${description}`); this.name = "ExchangeError"; }}
/** * Sign a client assertion for one of your members. * * `externalId` is your own stable customer id. It must not change when the person * changes their email or name — it is the handle their coach history hangs off. */export async function signAssertion( config: ClientConfig, externalId: string, display: MemberClaims = {},): Promise<string> { const key = await importPKCS8(config.privateKeyPkcs8, config.algorithm); const now = Math.floor(Date.now() / 1000);
return new SignJWT({ ...display }) .setProtectedHeader({ alg: config.algorithm, kid: config.keyId, typ: "JWT" }) .setIssuer(config.issuer) .setSubject(externalId) .setAudience(config.audience) .setIssuedAt(now) .setExpirationTime(now + ASSERTION_TTL_SECONDS) .setJti(crypto.randomUUID()) .sign(key);}
/** * Sign an assertion and exchange it for a member token. Call this from your backend * — never from a browser — and hand only the returned token to your page. */export async function fetchMemberToken( config: ClientConfig, externalId: string, options: { display?: MemberClaims; fetchImpl?: (url: string, init: RequestInit) => Promise<Response>; } = {},): Promise<MemberToken> { // `fetchImpl` is the seam for a backend that has to route egress through a proxy. const doFetch = options.fetchImpl ?? fetch; const assertion = await signAssertion(config, externalId, options.display);
const response = await doFetch(config.audience, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: JWT_BEARER_GRANT, assertion }), });
// `.catch(() => ({}))`, not a bare `await response.json()`: every rejection the exchange // makes is the RFC 6749 §5.2 JSON body, but a response something else shaped, such as // an egress proxy on your side (the `fetchImpl` seam above), may not be JSON. Parsing // eagerly would turn that response into a `SyntaxError` and lose its status. const body = (await response.json().catch(() => ({}))) as Partial<MemberToken> & { error?: string; error_description?: string; };
if (!response.ok) { // Branch on `code`, not on the status, which is 400 for every client-side rejection. // The exception is a 429: back off for `retryAfter` seconds, whatever the body says. // An empty code means a body the exchange did not shape; it is not a code to code // against. throw new ExchangeError( body.error ?? "", body.error_description ?? response.statusText, response.status, response.headers.get("retry-after") ?? undefined, ); }
return body as MemberToken;}Using it
Section titled “Using it”import { configFromEnv, fetchMemberToken } from "./sign-assertion";
// DOSHI_ISS, DOSHI_AUD, DOSHI_KID and DOSHI_PRIVATE_KEY, from your secret store.const config = configFromEnv(process.env);
// In your "give my page a coach token" endpoint, for the customer that endpoint// has already authenticated:const token = await fetchMemberToken(config, customer.id, { display: { name: customer.name, customer_since: customer.since },});// -> { access_token: "...", token_type: "Bearer", expires_in: 900 }Return access_token and expires_in to your page and nothing else. Your page passes
the token to Doshi when Doshi asks for one; see Hand over the token.
Generating a key pair
Section titled “Generating a key pair”ES256 (recommended — smaller assertions, faster to sign):
openssl ecparam -name prime256v1 -genkey -noout -out doshi-signing.keyopenssl pkcs8 -topk8 -nocrypt -in doshi-signing.key -out doshi-signing.pkcs8.pemopenssl ec -in doshi-signing.key -pubout -out doshi-signing.pubRS256, if your key management requires it:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out doshi-signing.pkcs8.pemopenssl rsa -in doshi-signing.pkcs8.pem -pubout -out doshi-signing.pubThe PKCS#8 PEM is what DOSHI_PRIVATE_KEY holds, and your console’s Generate a key
makes one for you in your browser. To bring your own key,
paste the public half into your console as it is, or convert it to a JWK and publish it in
your JWKS under the kid we gave it, with "use": "sig":
import { exportJWK, importSPKI } from "jose";
const alg = "ES256"; // or "RS256" for an RSA keyconst pem = await Bun.file("doshi-signing.pub").text(); // fs.readFile on Nodeconst jwk = await exportJWK(await importSPKI(pem, alg, { extractable: true }));console.log(JSON.stringify({ keys: [{ ...jwk, alg, use: "sig", kid: "prd_7f3a91c2" }] }, null, 2));Only alg changes between the two key types.
Other languages
Section titled “Other languages”There is no Doshi library to install on your backend, and nothing here is
Doshi-specific: any JWT library that can sign ES256 or RS256 with a kid header will
do. The libraries we see most often are jose (JavaScript), PyJWT (Python), jjwt or Nimbus JOSE+JWT (Java) and
golang-jwt (Go). Match the claim set and you are done.