UNPKG

did-jwks

Version:

A DID method that enables JWKS endpoints to be used as DID identifiers

183 lines (177 loc) 5.64 kB
import * as v from "valibot"; import { DidDocumentSchema, JsonWebKeySetSchema, UriSchema, isDid, isDidWithMethod } from "web-identity-schemas/valibot"; //#region src/utils/jwk-thumbprint.ts /** * Generate an RFC 7638 JWK thumbprint for a given key. * This creates a stable, deterministic identifier for the key. * * @param jwk - The JSON Web Key * @returns The base64url-encoded SHA-256 thumbprint */ async function generateJwkThumbprint(jwk) { const requiredMembers = getRequiredMembers(jwk); const jwkJson = JSON.stringify(requiredMembers, Object.keys(requiredMembers).sort()); const data = new TextEncoder().encode(jwkJson); const hashBuffer = await crypto.subtle.digest("SHA-256", data); return base64urlEncode(new Uint8Array(hashBuffer)); } /** * Extract only the required members for JWK thumbprint according to RFC 7638. */ function getRequiredMembers(jwk) { switch (jwk.kty) { case "RSA": return { kty: jwk.kty, n: jwk.n, e: jwk.e }; case "EC": return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; case "OKP": return { kty: jwk.kty, crv: jwk.crv, x: jwk.x }; default: return jwk; } } /** * Convert ArrayBuffer to base64url encoding (RFC 4648). */ function base64urlEncode(buffer) { const base64 = btoa(String.fromCharCode(...buffer)); return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } //#endregion //#region src/did-jwks.ts /** * Helper type predicate for `did:jwks` URIs. * @param val * @returns */ const isDidJwks = (val) => isDidWithMethod("jwks", val); /** * A minimal DidDocument schema with an array for @context, and no service * endpoints or controller. */ const MinimalDidDocumentSchema = v.object({ ...v.omit(DidDocumentSchema, ["service", "controller"]).entries, "@context": v.array(UriSchema) }); /** * Create a DID document from a JWKS. * * @param didUri - The DID URI. * @param jwks - The JWKS. * @returns The DID document. */ async function createDidJwksDidDocument(did, jwks) { const keysWithThumbprints = await Promise.all(jwks.keys.map(async (key) => { const { use } = key; const thumbprint = await generateJwkThumbprint(key); return { use, publicKeyJwk: key, thumbprint }; })); const { verificationMethods, sigMethodIds, encMethodIds } = keysWithThumbprints.reduce((acc, { use, publicKeyJwk, thumbprint }) => { const id = `${did}#${thumbprint}`; acc.verificationMethods.push({ id, type: "JsonWebKey", controller: did, publicKeyJwk }); if (use === "enc") acc.encMethodIds.push(id); else acc.sigMethodIds.push(id); return acc; }, { verificationMethods: [], sigMethodIds: [], encMethodIds: [] }); return v.parse(MinimalDidDocumentSchema, { "@context": ["https://www.w3.org/ns/did/v1"], id: did, verificationMethod: verificationMethods, assertionMethod: sigMethodIds, authentication: sigMethodIds, ...encMethodIds.length > 0 && { keyAgreement: encMethodIds } }); } //#endregion //#region src/utils/schemas.ts const OpenIDConfigurationSchema = v.object({ jwks_uri: v.optional(v.pipe(v.string(), v.url())) }); //#endregion //#region src/utils/fetch-with-schema.ts /** * Fetches a JSON document from a URL and validates it against a Valibot schema. * * @param url - The URL to fetch the document from. * @param schema - The Valibot schema to validate the document against. * @param fetchImpl - The fetch implementation to use. * @returns The validated document or `null` if the document could not be fetched or validated. */ async function fetchWithSchema(url, schema, fetchImpl = globalThis.fetch) { const resp = await fetchImpl(url); if (!resp.ok) return null; const result = v.safeParse(schema, await resp.json()); if (result.success) return result.output; return null; } //#endregion //#region src/fetch.ts const jwksUrl = (base) => `${base}/.well-known/jwks.json`; const openidConfigurationUrl = (base) => `${base}/.well-known/openid-configuration`; async function fetchJwks(did, opts = {}) { const base = buildBaseUrl(did, opts.allowedHttpHosts); let jwks = await fetchWithSchema(jwksUrl(base), JsonWebKeySetSchema, opts.fetch); if (jwks) return jwks; const openidConfig = await fetchWithSchema(openidConfigurationUrl(base), OpenIDConfigurationSchema, opts.fetch); if (!openidConfig?.jwks_uri) return null; jwks = await fetchWithSchema(openidConfig.jwks_uri, JsonWebKeySetSchema, opts.fetch); return jwks; } /** * Fetches the DID document for a given DID with the "jwks" method. * * @param did - The DID to fetch the document for. * @param opts - The options for the fetch. * @returns The DID document or `null` if the document could not be fetched. */ async function fetchJwksDidDocument(did, opts = {}) { const jwks = await fetchJwks(did, opts); if (!jwks) return null; return await createDidJwksDidDocument(did, jwks); } /** * Build a base path from a full `did:jwks` URI. * * @example * ``` * const base = buildBasePath("did:jwks:accounts.google.com:matt"); * // base === "accounts.google.com/matt" * ``` * * @returns The base path */ function buildBaseUrl(did, allowedHttpHosts = []) { const basePath = did.replace(/^did:jwks:/, "").split(":").map(decodeURIComponent).join("/"); const protocol = getProtocol(basePath, allowedHttpHosts); return `${protocol}://${basePath}`; } function getProtocol(path, allowedHttpHosts = []) { const [host] = path.split("/"); if (host) { const [hostWithoutPort] = host.split(":"); return allowedHttpHosts.some((host$1) => host$1 === hostWithoutPort) ? "http" : "https"; } return "https"; } //#endregion export { createDidJwksDidDocument, fetchJwks, fetchJwksDidDocument, isDid, isDidJwks };