Skip to content

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-assertion.ts
// 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;
}
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.

ES256 (recommended — smaller assertions, faster to sign):

openssl ecparam -name prime256v1 -genkey -noout -out doshi-signing.key
openssl pkcs8 -topk8 -nocrypt -in doshi-signing.key -out doshi-signing.pkcs8.pem
openssl ec -in doshi-signing.key -pubout -out doshi-signing.pub

RS256, if your key management requires it:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out doshi-signing.pkcs8.pem
openssl rsa -in doshi-signing.pkcs8.pem -pubout -out doshi-signing.pub

The 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 key
const pem = await Bun.file("doshi-signing.pub").text(); // fs.readFile on Node
const 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.

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.