did-jwt
Version:
Library for Signing and Verifying JWTs that use DIDs as issuers and JWEs that use DIDs as recipients
480 lines • 22.1 kB
JavaScript
import canonicalizeData from 'canonicalize';
import { parse } from 'did-resolver';
import SignerAlg from './SignerAlgorithm.js';
import { decodeBase64url, encodeBase64url, SUPPORTED_PUBLIC_KEY_TYPES } from './util.js';
import VerifierAlgorithm from './VerifierAlgorithm.js';
import { JWT_ERROR } from './Errors.js';
import { verifyProof } from './ConditionalAlgorithm.js';
export const SELF_ISSUED_V2 = 'https://self-issued.me/v2';
const SELF_ISSUED_V2_VC_INTEROP = 'https://self-issued.me/v2/openid-vc'; // https://identity.foundation/jwt-vc-presentation-profile/#id-token-validation
export const SELF_ISSUED_V0_1 = 'https://self-issued.me';
const defaultAlg = 'ES256K';
const DID_JSON = 'application/did+json';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function encodeSection(data, shouldCanonicalize = false) {
if (shouldCanonicalize) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore error TS2349: This expression is not callable.
return encodeBase64url(canonicalizeData(data));
}
else {
return encodeBase64url(JSON.stringify(data));
}
}
export const NBF_SKEW = 300;
function isNumericDate(value) {
return typeof value === 'number' && Number.isFinite(value);
}
function hasTimeClaim(value) {
return value !== undefined && value !== null;
}
function decodeJWS(jws) {
const parts = jws.match(/^([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)$/);
if (parts) {
return {
header: JSON.parse(decodeBase64url(parts[1])),
payload: parts[2],
signature: parts[3],
data: `${parts[1]}.${parts[2]}`,
};
}
throw new Error('invalid_argument: Incorrect format JWS');
}
/**
* Decodes a JWT and returns an object representing the payload
*
* @example
* decodeJWT('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpYXQiOjE1...')
*
* @param {String} jwt a JSON Web Token to verify
* @param {Object} [recurse] whether to recurse into the payload to decode any nested JWTs
* @return {Object} a JS object representing the decoded JWT
*/
export function decodeJWT(jwt, recurse = true) {
if (!jwt)
throw new Error('invalid_argument: no JWT passed into decodeJWT');
try {
const jws = decodeJWS(jwt);
const decodedJwt = Object.assign(jws, { payload: JSON.parse(decodeBase64url(jws.payload)) });
const iss = decodedJwt.payload.iss;
if (decodedJwt.header.cty === 'JWT' && recurse) {
const innerDecodedJwt = decodeJWT(decodedJwt.payload.jwt);
if (innerDecodedJwt.payload.iss !== iss)
throw new Error(`${JWT_ERROR.INVALID_JWT}: multiple issuers`);
return innerDecodedJwt;
}
return decodedJwt;
}
catch (e) {
throw new Error(`invalid_argument: ${JWT_ERROR.INVALID_JWT}: ${e}`);
}
}
/**
* Creates a signed JWS given a payload, a signer, and an optional header.
*
* @example
* const signer = ES256KSigner(process.env.PRIVATE_KEY)
* const jws = await createJWS({ my: 'payload' }, signer)
*
* @param {Object} payload payload object
* @param {Signer} signer a signer, see `ES256KSigner or `EdDSASigner`
* @param {Object} header optional object to specify or customize the JWS header
* @param {Object} options can be used to trigger automatic canonicalization of header and
* payload properties
* @return {Promise<string>} a Promise which resolves to a JWS string or rejects with an error
*/
export async function createJWS(payload, signer, header = {}, options = {}) {
if (!header.alg)
header.alg = defaultAlg;
const encodedPayload = typeof payload === 'string' ? payload : encodeSection(payload, options.canonicalize);
const signingInput = [encodeSection(header, options.canonicalize), encodedPayload].join('.');
const jwtSigner = SignerAlg(header.alg);
const signature = await jwtSigner(signingInput, signer);
// JWS Compact Serialization
// https://www.rfc-editor.org/rfc/rfc7515#section-7.1
return [signingInput, signature].join('.');
}
/**
* Creates a signed JWT given an address which becomes the issuer, a signer, and a payload for which the signature is
* over.
*
* @example
* const signer = ES256KSigner(process.env.PRIVATE_KEY)
* createJWT({address: '5A8bRWU3F7j3REx3vkJ...', signer}, {key1: 'value', key2: ..., ... }).then(jwt => {
* ...
* })
*
* @param {Object} payload payload object
* @param {Object} [options] an unsigned credential object
* @param {String} options.issuer The DID of the issuer (signer) of JWT
* @param {String} options.alg [DEPRECATED] The JWT signing algorithm to use. Supports:
* [ES256K, ES256K-R, Ed25519, EdDSA], Defaults to: ES256K. Please use `header.alg` to specify the algorithm
* @param {Signer} options.signer a `Signer` function, Please see `ES256KSigner` or `EdDSASigner`
* @param {boolean} options.canonicalize optional flag to canonicalize header and payload before signing
* @param {Object} header optional object to specify or customize the JWT header
* @return {Promise<Object, Error>} a promise which resolves with a signed JSON Web Token or
* rejects with an error
*/
export async function createJWT(payload, { issuer, signer, alg, expiresIn, canonicalize }, header = {}) {
if (!signer)
throw new Error('missing_signer: No Signer functionality has been configured');
if (!issuer)
throw new Error('missing_issuer: No issuing DID has been configured');
if (!header.typ)
header.typ = 'JWT';
if (!header.alg)
header.alg = alg;
const timestamps = {
iat: Math.floor(Date.now() / 1000),
exp: undefined,
};
if (expiresIn) {
if (typeof expiresIn === 'number') {
timestamps.exp = (payload.nbf || timestamps.iat) + Math.floor(expiresIn);
}
else {
throw new Error('invalid_argument: JWT expiresIn is not a number');
}
}
const fullPayload = { ...timestamps, ...payload, iss: issuer };
return createJWS(fullPayload, signer, header, { canonicalize });
}
/**
* Creates a multi-signature signed JWT given multiple issuers and their corresponding signers, and a payload for
* which the signature is over.
*
* @example
* const signer = ES256KSigner(process.env.PRIVATE_KEY)
* createJWT({address: '5A8bRWU3F7j3REx3vkJ...', signer}, {key1: 'value', key2: ..., ... }).then(jwt => {
* ...
* })
*
* @param {Object} payload payload object
* @param {Object} [options] an unsigned credential object
* @param {boolean} options.expiresIn optional flag to denote the expiration time
* @param {boolean} options.canonicalize optional flag to canonicalize header and payload before signing
* @param {Object[]} issuers array of the issuers, their signers and algorithms
* @param {string} issuers[].issuer The DID of the issuer (signer) of JWT
* @param {Signer} issuers[].signer a `Signer` function, Please see `ES256KSigner` or `EdDSASigner`
* @param {String} issuers[].alg [DEPRECATED] The JWT signing algorithm to use. Supports:
* [ES256K, ES256K-R, Ed25519, EdDSA], Defaults to: ES256K. Please use `header.alg` to specify the algorithm
* @return {Promise<Object, Error>} a promise which resolves with a signed JSON Web Token or
* rejects with an error
*/
export async function createMultisignatureJWT(payload, { expiresIn, canonicalize }, issuers) {
if (issuers.length === 0)
throw new Error('invalid_argument: must provide one or more issuers');
let payloadResult = payload;
let jwt = '';
for (let i = 0; i < issuers.length; i++) {
const issuer = issuers[i];
const header = {
typ: 'JWT',
alg: issuer.alg,
};
// Create nested JWT
// See Point 5 of https://www.rfc-editor.org/rfc/rfc7519#section-7.1
// After the first JWT is created (the first JWS), the next JWT is created by inputting the previous JWT as the
// payload
if (i !== 0) {
header.cty = 'JWT';
}
jwt = await createJWT(payloadResult, { ...issuer, canonicalize, expiresIn }, header);
payloadResult = { jwt };
}
return jwt;
}
export function verifyJWTDecoded({ header, payload, data, signature }, pubKeys) {
if (!Array.isArray(pubKeys))
pubKeys = [pubKeys];
const iss = payload.iss;
let recurse = true;
do {
if (iss !== payload.iss)
throw new Error(`${JWT_ERROR.INVALID_JWT}: multiple issuers`);
try {
const result = VerifierAlgorithm(header.alg)(data, signature, pubKeys);
return result;
}
catch (e) {
if (!e.message.startsWith(JWT_ERROR.INVALID_SIGNATURE))
throw e;
}
// TODO probably best to create copy objects than replace reference objects
if (header.cty !== 'JWT') {
recurse = false;
}
else {
;
({ payload, header, signature, data } = decodeJWT(payload.jwt, false));
}
} while (recurse);
throw new Error(`${JWT_ERROR.INVALID_SIGNATURE}: no matching public key found`);
}
function verifyJWSDecoded({ header, data, signature }, pubKeys) {
if (!Array.isArray(pubKeys))
pubKeys = [pubKeys];
const signer = VerifierAlgorithm(header.alg)(data, signature, pubKeys);
return signer;
}
/**
* Verifies given JWS. If the JWS is valid, returns the public key that was
* used to sign the JWS, or throws an `Error` if none of the `pubKeys` match.
*
* @example
* const pubKey = verifyJWS('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJyZXF1Z....', { publicKeyHex: '0x12341...' })
*
* @param {String} jws A JWS string to verify
* @param {Array<VerificationMethod> | VerificationMethod} pubKeys The public keys used to verify the JWS
* @return {VerificationMethod} The public key used to sign the JWS
*/
export function verifyJWS(jws, pubKeys) {
const jwsDecoded = decodeJWS(jws);
return verifyJWSDecoded(jwsDecoded, pubKeys);
}
/**
* Verifies given JWT. If the JWT is valid, the promise returns an object including the JWT, the payload of the JWT,
* and the DID document of the issuer of the JWT.
*
* @example
* ```ts
* verifyJWT(
* 'did:uport:eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJyZXF1Z....',
* {audience: '5A8bRWU3F7j3REx3vkJ...', callbackUrl: 'https://...'}
* ).then(obj => {
* const did = obj.did // DID of signer
* const payload = obj.payload
* const doc = obj.didResolutionResult.didDocument // DID Document of issuer
* const jwt = obj.jwt
* const signerKeyId = obj.signer.id // ID of key in DID document that signed JWT
* ...
* })
* ```
*
* @param {String} jwt a JSON Web Token to verify
* @param {Object} [options] an unsigned credential object
* @param {Boolean} options.auth Require signer to be listed in the authentication section of the
* DID document (for Authentication purposes)
* @param {String} options.audience DID of the recipient of the JWT
* @param {String} options.callbackUrl callback url in JWT
* @return {Promise<Object, Error>} a promise which resolves with a response object or rejects with an
* error
*/
export async function verifyJWT(jwt, options = {
resolver: undefined,
auth: undefined,
audience: undefined,
callbackUrl: undefined,
skewTime: undefined,
proofPurpose: undefined,
policies: {},
didAuthenticator: undefined,
}) {
if (!options.resolver)
throw new Error('missing_resolver: No DID resolver has been configured');
const { payload, header, signature, data } = decodeJWT(jwt, false);
const proofPurpose = Object.prototype.hasOwnProperty.call(options, 'auth')
? options.auth
? 'authentication'
: undefined
: options.proofPurpose;
let didUrl;
if (!payload.iss && !payload.client_id) {
throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT iss or client_id are required`);
}
if (options.didAuthenticator) {
didUrl = options.didAuthenticator.issuer;
}
else if (payload.iss === SELF_ISSUED_V2 || payload.iss === SELF_ISSUED_V2_VC_INTEROP) {
if (!payload.sub) {
throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT sub is required`);
}
if (typeof payload.sub_jwk === 'undefined') {
didUrl = payload.sub;
}
else {
didUrl = (header.kid || '').split('#')[0];
}
}
else if (payload.iss === SELF_ISSUED_V0_1) {
if (!payload.did) {
throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT did is required`);
}
didUrl = payload.did;
}
else if (!payload.iss && payload.scope === 'openid' && payload.redirect_uri) {
// SIOP Request payload
// https://identity.foundation/jwt-vc-presentation-profile/#self-issued-op-request-object
if (!payload.client_id) {
throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT client_id is required`);
}
didUrl = payload.client_id;
}
else {
didUrl = payload.iss;
}
if (!didUrl) {
throw new Error(`${JWT_ERROR.INVALID_JWT}: No DID has been found in the JWT`);
}
let authenticators;
let issuer;
let didResolutionResult;
if (options.didAuthenticator) {
;
({ didResolutionResult, authenticators, issuer } = options.didAuthenticator);
}
else {
;
({ didResolutionResult, authenticators, issuer } = await resolveAuthenticator(options.resolver, header.alg, didUrl, proofPurpose));
// Add to options object for recursive reference
options.didAuthenticator = { didResolutionResult, authenticators, issuer };
}
const { did } = parse(didUrl);
let signer = null;
if (did !== didUrl) {
const authenticator = authenticators.find((auth) => auth.id === didUrl);
if (!authenticator) {
throw new Error(`${JWT_ERROR.INVALID_JWT}: No authenticator found for did URL ${didUrl}`);
}
signer = await verifyProof(jwt, { payload, header, signature, data }, authenticator, options);
}
else {
let i = 0;
while (!signer && i < authenticators.length) {
const authenticator = authenticators[i];
try {
signer = await verifyProof(jwt, { payload, header, signature, data }, authenticator, options);
}
catch (e) {
if (!e.message.includes(JWT_ERROR.INVALID_SIGNATURE) || i === authenticators.length - 1)
throw e;
}
i++;
}
}
if (signer) {
const now = typeof options.policies?.now === 'number' ? options.policies.now : Math.floor(Date.now() / 1000);
const skewTime = typeof options.skewTime !== 'undefined' && options.skewTime >= 0 ? options.skewTime : NBF_SKEW;
const nowSkewed = now + skewTime;
if (options.policies?.nbf !== false && hasTimeClaim(payload.nbf)) {
if (!isNumericDate(payload.nbf)) {
throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT nbf is not a NumericDate: ${payload.nbf}`);
}
if (payload.nbf > nowSkewed) {
throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT not valid before nbf: ${payload.nbf}`);
}
}
else if (options.policies?.iat !== false && hasTimeClaim(payload.iat)) {
if (!isNumericDate(payload.iat)) {
throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT iat is not a NumericDate: ${payload.iat}`);
}
if (payload.iat > nowSkewed) {
throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT not valid yet (issued in the future) iat: ${payload.iat}`);
}
}
if (options.policies?.exp !== false && hasTimeClaim(payload.exp)) {
if (!isNumericDate(payload.exp)) {
throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT exp is not a NumericDate: ${payload.exp}`);
}
if (payload.exp <= now - skewTime) {
throw new Error(`${JWT_ERROR.INVALID_JWT}: JWT has expired: exp: ${payload.exp} < now: ${now}`);
}
}
if (options.policies?.aud !== false && payload.aud) {
if (!options.audience && !options.callbackUrl) {
throw new Error(`${JWT_ERROR.INVALID_AUDIENCE}: JWT audience is required but your app address has not been configured`);
}
const audArray = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
const matchedAudience = audArray.find((item) => options.audience === item || options.callbackUrl === item);
if (typeof matchedAudience === 'undefined') {
throw new Error(`${JWT_ERROR.INVALID_AUDIENCE}: JWT audience does not match your DID or callback url`);
}
}
return { verified: true, payload, didResolutionResult, issuer, signer, jwt, policies: options.policies };
}
throw new Error(`${JWT_ERROR.INVALID_SIGNATURE}: JWT not valid. issuer DID document does not contain a verificationMethod that matches the signature.`);
}
/**
* Resolves relevant public keys or other authenticating material used to verify signature from the DID document of
* provided DID
*
* @example
* ```ts
* resolveAuthenticator(resolver, 'ES256K', 'did:uport:2nQtiQG6Cgm1GYTBaaKAgr76uY7iSexUkqX').then(obj => {
* const payload = obj.payload
* const profile = obj.profile
* const jwt = obj.jwt
* // ...
* })
* ```
*
* @param resolver - {Resolvable} a DID resolver function that can obtain the `DIDDocument` for the `issuer`
* @param alg - {String} a JWT algorithm
* @param issuer - {String} a Decentralized Identifier (DID) to lookup
* @param proofPurpose - {ProofPurposeTypes} *Optional* Use the verificationMethod linked in that section of the
* issuer DID document
* @return {Promise<DIDAuthenticator>} a promise which resolves with an object containing an array of authenticators
* or rejects with an error if none exist
*/
export async function resolveAuthenticator(resolver, alg, issuer, proofPurpose) {
const types = SUPPORTED_PUBLIC_KEY_TYPES[alg];
if (!types || types.length === 0) {
throw new Error(`${JWT_ERROR.NOT_SUPPORTED}: No supported signature types for algorithm ${alg}`);
}
let didResult;
const result = (await resolver.resolve(issuer, { accept: DID_JSON }));
// support legacy resolvers that do not produce DIDResolutionResult
if (Object.getOwnPropertyNames(result).indexOf('didDocument') === -1) {
didResult = {
didDocument: result,
didDocumentMetadata: {},
didResolutionMetadata: { contentType: DID_JSON },
};
}
else {
didResult = result;
}
if (didResult.didResolutionMetadata?.error || didResult.didDocument == null) {
const { error, message } = didResult.didResolutionMetadata;
throw new Error(`${JWT_ERROR.RESOLVER_ERROR}: Unable to resolve DID document for ${issuer}: ${error}, ${message || ''}`);
}
const getPublicKeyById = (verificationMethods, pubid) => {
const filtered = verificationMethods.filter(({ id }) => pubid === id);
return filtered.length > 0 ? filtered[0] : null;
};
let publicKeysToCheck = [
...(didResult?.didDocument?.verificationMethod || []),
...(didResult?.didDocument?.publicKey || []),
];
if (typeof proofPurpose === 'string') {
// support legacy DID Documents that do not list assertionMethod
if (proofPurpose.startsWith('assertion') &&
!Object.getOwnPropertyNames(didResult?.didDocument).includes('assertionMethod')) {
didResult.didDocument = { ...didResult.didDocument };
didResult.didDocument.assertionMethod = [...publicKeysToCheck.map((pk) => pk.id)];
}
publicKeysToCheck = (didResult.didDocument[proofPurpose] || [])
.map((verificationMethod) => {
if (typeof verificationMethod === 'string') {
return getPublicKeyById(publicKeysToCheck, verificationMethod);
}
else if (typeof verificationMethod.publicKey === 'string') {
// this is a legacy format
return getPublicKeyById(publicKeysToCheck, verificationMethod.publicKey);
}
else {
return verificationMethod;
}
})
.filter((key) => key != null);
}
const authenticators = publicKeysToCheck.filter(({ type }) => types.find((supported) => supported === type));
if (typeof proofPurpose === 'string' && (!authenticators || authenticators.length === 0)) {
throw new Error(`${JWT_ERROR.NO_SUITABLE_KEYS}: DID document for ${issuer} does not have public keys suitable for ${alg} with ${proofPurpose} purpose`);
}
if (!authenticators || authenticators.length === 0) {
throw new Error(`${JWT_ERROR.NO_SUITABLE_KEYS}: DID document for ${issuer} does not have public keys for ${alg}`);
}
return { authenticators, issuer, didResolutionResult: didResult };
}
//# sourceMappingURL=JWT.js.map