oidc-provider
Version:
OAuth 2.0 Authorization Server implementation for Node.js with OpenID Connect
130 lines (103 loc) • 3.68 kB
JavaScript
import * as crypto from 'node:crypto';
import {
jwtVerify,
EmbeddedJWK,
calculateJwkThumbprint,
} from 'jose';
import { InvalidDpopProof, UseDpopNonce } from './errors.js';
import instance from './weak_cache.js';
import epochTime from './epoch_time.js';
import { CHALLENGE_OK_WINDOW } from './challenge.js';
export { CHALLENGE_OK_WINDOW };
const weakMap = new WeakMap();
export default async (ctx, accessToken) => {
if (weakMap.has(ctx)) {
return weakMap.get(ctx);
}
const {
features: { dPoP: dPoPConfig },
dPoPSigningAlgValues,
} = instance(ctx.oidc.provider).configuration;
if (!dPoPConfig.enabled) {
return undefined;
}
const proof = ctx.get('DPoP');
if (!proof) {
return undefined;
}
const { DPoPNonces } = instance(ctx.oidc.provider);
const requireNonce = dPoPConfig.requireNonce(ctx);
if (typeof requireNonce !== 'boolean') {
throw new Error('features.dPoP.requireNonce must return a boolean');
}
if (requireNonce && !DPoPNonces) {
throw new Error('features.dPoP.nonceSecret configuration is missing');
}
const nextNonce = DPoPNonces?.nextChallenge();
let payload;
let protectedHeader;
try {
({ protectedHeader, payload } = await jwtVerify(proof, EmbeddedJWK, { algorithms: dPoPSigningAlgValues, typ: 'dpop+jwt' }));
if (typeof payload.iat !== 'number' || !payload.iat) {
throw new InvalidDpopProof('DPoP proof must have a iat number property');
}
if (typeof payload.jti !== 'string' || !payload.jti) {
throw new InvalidDpopProof('DPoP proof must have a jti string property');
}
if (payload.nonce !== undefined && typeof payload.nonce !== 'string') {
throw new InvalidDpopProof('DPoP proof nonce must be a string');
}
if (!payload.nonce) {
const now = epochTime();
const diff = Math.abs(now - payload.iat);
if (diff > CHALLENGE_OK_WINDOW) {
if (nextNonce) {
ctx.set('dpop-nonce', nextNonce);
throw new UseDpopNonce('DPoP proof iat is not recent enough, use a DPoP nonce instead');
}
throw new InvalidDpopProof('DPoP proof iat is not recent enough');
}
} else if (!DPoPNonces) {
throw new InvalidDpopProof('DPoP nonces are not supported');
}
if (payload.htm !== ctx.method) {
throw new InvalidDpopProof('DPoP proof htm mismatch');
}
{
const expected = new URL(ctx.oidc.urlFor(ctx.oidc.route)).href;
const actual = URL.parse(payload.htu);
if (!actual) return false;
actual.hash = '';
actual.search = '';
if (actual?.href !== expected) {
throw new InvalidDpopProof('DPoP proof htu mismatch');
}
}
if (accessToken) {
const ath = crypto.hash('sha256', accessToken, 'base64url');
if (payload.ath !== ath) {
throw new InvalidDpopProof('DPoP proof ath mismatch');
}
}
} catch (err) {
if (err instanceof InvalidDpopProof || err instanceof UseDpopNonce) {
throw err;
}
throw new InvalidDpopProof('invalid DPoP key binding', err.message);
}
if (!payload.nonce && requireNonce) {
ctx.set('dpop-nonce', nextNonce);
throw new UseDpopNonce('nonce is required in the DPoP proof');
}
if (payload.nonce && !DPoPNonces.checkChallenge(payload.nonce)) {
ctx.set('dpop-nonce', nextNonce);
throw new UseDpopNonce('invalid nonce in DPoP proof');
}
if (payload.nonce !== nextNonce) {
ctx.set('dpop-nonce', nextNonce);
}
const thumbprint = await calculateJwkThumbprint(protectedHeader.jwk);
const result = { thumbprint, jti: payload.jti, iat: payload.iat };
weakMap.set(ctx, result);
return result;
};