getKeyset

@atiproto/edge-oauth-client

Builds a Keyset from a private JWK string and caches the resulting Promise in module scope. Pass the result as the keyset option when constructing a confidential EdgeOAuthClient.

Signature

typescript
function getKeyset(jwk: string | undefined): Promise<Keyset | null>

Accepts the raw JSON string of a single private JWK (typically read from an environment variable). Returns null when the input is empty or undefined, which is the correct shape for a public client.

Example

typescript
import { EdgeOAuthClient, getKeyset, patchGlobalRequestObject } from "@atiproto/edge-oauth-client";
import { KvStateStore, KvSessionStore } from "@atiproto/kv-oauth-state-store";
patchGlobalRequestObject();
interface Env {
OAUTH_STATE_KV: KVNamespace;
OAUTH_SESSION_KV: KVNamespace;
OAUTH_PRIVATE_JWK?: string;
}
async function createOAuthClient(env: Env) {
return new EdgeOAuthClient({
clientMetadata: {
client_id: "https://your-app.example.com/oauth/client-metadata.json",
// ... other metadata, with
// token_endpoint_auth_method: "private_key_jwt"
// token_endpoint_auth_signing_alg: "ES256"
},
keyset: (await getKeyset(env.OAUTH_PRIVATE_JWK)) ?? undefined,
stateStore: new KvStateStore(env.OAUTH_STATE_KV),
sessionStore: new KvSessionStore(env.OAUTH_SESSION_KV),
});
}

Caching Behavior

The first call memoizes the Promise in a module-level variable; every subsequent call returns the same Promise without re-parsing the JWK. This matches the lifetime of a Worker isolate, which should only ever serve one client identity. Because the cache is keyed on module identity rather than input, the argument passed to later calls is ignored.

Generating a Key

The JWK is a private key that signs client assertions at the token endpoint. Treat it like any other secret: keep it out of source control, store it in Wrangler secrets (wrangler secret put OAUTH_PRIVATE_JWK), and rotate it only when necessary — rotating invalidates every currently-issued session because refresh tokens can no longer be signed.

The following script prints a fresh ES256 private JWK on stdout. Pipe it directly into wrangler secret put to avoid writing the key to disk:

bash
node scripts/generate-jwk.mjs | wrangler secret put OAUTH_PRIVATE_JWK

scripts/generate-jwk.mjs:

javascript
#!/usr/bin/env node
// Generates an ES256 private JWK suitable for OAUTH_PRIVATE_JWK.
import { subtle, randomUUID } from "node:crypto";
export async function generateJwk() {
const { privateKey } = await subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
true,
["sign", "verify"],
);
const jwk = await subtle.exportKey("jwk", privateKey);
jwk.alg = "ES256";
jwk.key_ops = ["sign"];
jwk.kid = randomUUID();
return jwk;
}
if (import.meta.url === `file://${process.argv[1]}`) {
process.stdout.write(JSON.stringify(await generateJwk()) + "\n");
}

The exported generateJwk() function can also be imported from tests or setup scripts when a fresh key is needed programmatically.