did-jwks
Version:
A DID method that enables JWKS endpoints to be used as DID identifiers
183 lines (177 loc) • 5.64 kB
JavaScript
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 };