Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ The following draft specifications are implemented by oidc-provider:
- [Financial-grade API: Client Initiated Backchannel Authentication Profile (`FAPI-CIBA`) - Implementers Draft 01][fapi-ciba]
- [FAPI 2.0 Message Signing (`FAPI 2.0`) - Implementers Draft 01][fapi2ms-id1]
- [OIDC Relying Party Metadata Choices 1.0 - Implementers Draft 01][rp-metadata-choices]
- [OAuth 2.0 Attestation-Based Client Authentication][attestation-client-auth]

Updates to draft specification versions are released as MINOR library versions,
if you utilize these specification implementations consider using the tilde `~` operator in your
Expand Down Expand Up @@ -168,3 +169,4 @@ actions and i.e. emit metrics that react to specific triggers. See the list of a
[Security Policy]: https://github.com/panva/node-oidc-provider/security/policy
[rp-metadata-choices]: https://openid.net/specs/openid-connect-rp-metadata-choices-1_0-ID1.html
[rfc8414]: https://www.rfc-editor.org/rfc/rfc8414.html
[attestation-client-auth]: https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-06.html
550 changes: 337 additions & 213 deletions docs/README.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions example/my_adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ class MyAdapter {
* refresh token
* - 'jkt' {string} - JWK SHA-256 Thumbprint (according to [RFC7638]) of a DPoP bound
* access or refresh token
* - 'attestationJkt' {string} - [BackchannelAuthenticationRequest, DeviceCode, RefreshToken, PushedAuthorizationRequest only]
* JWK SHA-256 Thumbprint (according to [RFC7638]) of an attest_jwt_client_auth client instance
* - gty {string} - [AccessToken, RefreshToken only] space delimited grant values, indicating
* the grant type(s) they originate from (implicit, authorization_code, refresh_token or
* device_code) the original one is always first, second is refresh_token if refreshed
Expand Down
4 changes: 4 additions & 0 deletions lib/actions/authorization/backchannel_request_response.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export default async function backchannelRequestResponse(ctx) {
scope: [...ctx.oidc.requestParamScopes].join(' '),
});

if (ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth') {
await request.setAttestBinding(ctx);
}

// eslint-disable-next-line default-case
switch (request.resource.length) {
case 0:
Expand Down
4 changes: 2 additions & 2 deletions lib/actions/authorization/check_dpop_jkt.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { InvalidRequest } from '../../helpers/errors.js';
import dpopValidate, { DPOP_OK_WINDOW } from '../../helpers/validate_dpop.js';
import dpopValidate, { CHALLENGE_OK_WINDOW } from '../../helpers/validate_dpop.js';
import epochTime from '../../helpers/epoch_time.js';
import instance from '../../helpers/weak_cache.js';

Expand All @@ -18,7 +18,7 @@ export default async function checkDpopJkt(ctx, next) {
const unique = await ReplayDetection.unique(
ctx.oidc.client.clientId,
dPoP.jti,
epochTime() + DPOP_OK_WINDOW,
epochTime() + CHALLENGE_OK_WINDOW,
);

ctx.assert(unique, new InvalidRequest('DPoP proof JWT Replay detected'));
Expand Down
4 changes: 4 additions & 0 deletions lib/actions/authorization/device_authorization_response.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export default async function deviceAuthorizationResponse(ctx) {
userCode: normalize(userCode),
});

if (ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth') {
await dc.setAttestBinding(ctx);
}

ctx.oidc.entity('DeviceCode', dc);
ctx.body = {
device_code: await dc.save(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export default async function pushedAuthorizationRequestResponse(ctx) {
trusted: ctx.oidc.client.clientAuthMethod !== 'none' || !!ctx.oidc.trusted?.length,
});

if (ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth') {
await requestObject.setAttestBinding(ctx);
}

const id = await requestObject.save(ttl);

ctx.oidc.entity('PushedAuthorizationRequest', requestObject);
Expand Down
22 changes: 22 additions & 0 deletions lib/actions/challenge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import instance from '../helpers/weak_cache.js';
import noCache from '../shared/no_cache.js';

export default [
noCache,
function challenge(ctx) {
const { DPoPNonces, AttestChallenges } = instance(ctx.oidc.provider);

ctx.body = {};

const nextNonce = DPoPNonces?.nextChallenge();
if (nextNonce) {
ctx.set('dpop-nonce', nextNonce);
}

const nextChallenge = AttestChallenges?.nextChallenge();
if (nextChallenge) {
ctx.set('oauth-client-attestation-challenge', nextChallenge);
ctx.body.attestation_challenge = nextChallenge;
}
},
];
4 changes: 4 additions & 0 deletions lib/actions/discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,5 +139,9 @@ export default function discovery(ctx) {
ctx.body.authorization_details_types_supported = Object.keys(richAuthorizationRequests.types);
}

if (features.attestClientAuth.enabled) {
ctx.body.challenge_endpoint = ctx.oidc.urlFor('challenge');
}

defaults(ctx.body, configuration.discovery);
}
20 changes: 9 additions & 11 deletions lib/actions/grants/authorization_code.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import instance from '../../helpers/weak_cache.js';
import checkPKCE from '../../helpers/pkce.js';
import revoke from '../../helpers/revoke.js';
import filterClaims from '../../helpers/filter_claims.js';
import dpopValidate, { DPOP_OK_WINDOW } from '../../helpers/validate_dpop.js';
import dpopValidate, { CHALLENGE_OK_WINDOW } from '../../helpers/validate_dpop.js';
import resolveResource from '../../helpers/resolve_resource.js';
import epochTime from '../../helpers/epoch_time.js';
import checkRar from '../../shared/check_rar.js';
import getCtxAccountClaims from '../../helpers/account_claims.js';
import { setRefreshTokenBindings } from '../../helpers/set_rt_bindings.js';
import { checkAttestBinding } from '../../helpers/check_attest_binding.js';

const gty = 'authorization_code';

Expand Down Expand Up @@ -89,6 +91,10 @@ export const handler = async function authorizationCodeHandler(ctx) {
throw new InvalidGrant('authorization code redirect_uri mismatch');
}

if (ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth' && code.attestationJkt) {
await checkAttestBinding(ctx, code);
}

if (code.consumed) {
await revoke(ctx, code.grantId);
throw new InvalidGrant('authorization code already consumed');
Expand Down Expand Up @@ -138,7 +144,7 @@ export const handler = async function authorizationCodeHandler(ctx) {
const unique = await ReplayDetection.unique(
ctx.oidc.client.clientId,
dPoP.jti,
epochTime() + DPOP_OK_WINDOW,
epochTime() + CHALLENGE_OK_WINDOW,
);

ctx.assert(unique, new InvalidGrant('DPoP proof JWT Replay detected'));
Expand Down Expand Up @@ -192,15 +198,7 @@ export const handler = async function authorizationCodeHandler(ctx) {
rar: code.rar,
});

if (ctx.oidc.client.clientAuthMethod === 'none') {
if (at.jkt) {
rt.jkt = at.jkt;
}

if (at['x5t#S256']) {
rt['x5t#S256'] = at['x5t#S256'];
}
}
await setRefreshTokenBindings(ctx, at, rt);

ctx.oidc.entity('RefreshToken', rt);
refreshToken = await rt.save();
Expand Down
20 changes: 9 additions & 11 deletions lib/actions/grants/ciba.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import presence from '../../helpers/validate_presence.js';
import instance from '../../helpers/weak_cache.js';
import filterClaims from '../../helpers/filter_claims.js';
import revoke from '../../helpers/revoke.js';
import dpopValidate, { DPOP_OK_WINDOW } from '../../helpers/validate_dpop.js';
import dpopValidate, { CHALLENGE_OK_WINDOW } from '../../helpers/validate_dpop.js';
import resolveResource from '../../helpers/resolve_resource.js';
import epochTime from '../../helpers/epoch_time.js';
import getCtxAccountClaims from '../../helpers/account_claims.js';
import { setRefreshTokenBindings } from '../../helpers/set_rt_bindings.js';
import { checkAttestBinding } from '../../helpers/check_attest_binding.js';

const {
AuthorizationPending,
Expand Down Expand Up @@ -72,6 +74,10 @@ export const handler = async function cibaHandler(ctx) {
throw new AuthorizationPending();
}

if (ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth') {
await checkAttestBinding(ctx, request);
}

if (request.consumed) {
await revoke(ctx, request.grantId);
throw new InvalidGrant('backchannel authentication request already consumed');
Expand Down Expand Up @@ -141,7 +147,7 @@ export const handler = async function cibaHandler(ctx) {
const unique = await ReplayDetection.unique(
ctx.oidc.client.clientId,
dPoP.jti,
epochTime() + DPOP_OK_WINDOW,
epochTime() + CHALLENGE_OK_WINDOW,
);

ctx.assert(unique, new InvalidGrant('DPoP proof JWT Replay detected'));
Expand Down Expand Up @@ -185,15 +191,7 @@ export const handler = async function cibaHandler(ctx) {
sid: request.sid,
});

if (ctx.oidc.client.clientAuthMethod === 'none') {
if (at.jkt) {
rt.jkt = at.jkt;
}

if (at['x5t#S256']) {
rt['x5t#S256'] = at['x5t#S256'];
}
}
await setRefreshTokenBindings(ctx, at, rt);

ctx.oidc.entity('RefreshToken', rt);
refreshToken = await rt.save();
Expand Down
4 changes: 2 additions & 2 deletions lib/actions/grants/client_credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import instance from '../../helpers/weak_cache.js';
import {
InvalidGrant, InvalidTarget, InvalidScope, InvalidRequest,
} from '../../helpers/errors.js';
import dpopValidate, { DPOP_OK_WINDOW } from '../../helpers/validate_dpop.js';
import dpopValidate, { CHALLENGE_OK_WINDOW } from '../../helpers/validate_dpop.js';
import checkResource from '../../shared/check_resource.js';
import epochTime from '../../helpers/epoch_time.js';

Expand Down Expand Up @@ -65,7 +65,7 @@ export const handler = async function clientCredentialsHandler(ctx) {
const unique = await ReplayDetection.unique(
client.clientId,
dPoP.jti,
epochTime() + DPOP_OK_WINDOW,
epochTime() + CHALLENGE_OK_WINDOW,
);

ctx.assert(unique, new InvalidGrant('DPoP proof JWT Replay detected'));
Expand Down
20 changes: 9 additions & 11 deletions lib/actions/grants/device_code.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import presence from '../../helpers/validate_presence.js';
import instance from '../../helpers/weak_cache.js';
import filterClaims from '../../helpers/filter_claims.js';
import revoke from '../../helpers/revoke.js';
import dpopValidate, { DPOP_OK_WINDOW } from '../../helpers/validate_dpop.js';
import dpopValidate, { CHALLENGE_OK_WINDOW } from '../../helpers/validate_dpop.js';
import resolveResource from '../../helpers/resolve_resource.js';
import epochTime from '../../helpers/epoch_time.js';
import getCtxAccountClaims from '../../helpers/account_claims.js';
import { setRefreshTokenBindings } from '../../helpers/set_rt_bindings.js';
import { checkAttestBinding } from '../../helpers/check_attest_binding.js';

const {
AuthorizationPending,
Expand Down Expand Up @@ -51,6 +53,10 @@ export const handler = async function deviceCodeHandler(ctx) {
throw new InvalidGrant('client mismatch');
}

if (ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth') {
await checkAttestBinding(ctx, code);
}

let cert;
if (ctx.oidc.client.tlsClientCertificateBoundAccessTokens) {
cert = getCertificate(ctx);
Expand Down Expand Up @@ -140,7 +146,7 @@ export const handler = async function deviceCodeHandler(ctx) {
const unique = await ReplayDetection.unique(
ctx.oidc.client.clientId,
dPoP.jti,
epochTime() + DPOP_OK_WINDOW,
epochTime() + CHALLENGE_OK_WINDOW,
);

ctx.assert(unique, new InvalidGrant('DPoP proof JWT Replay detected'));
Expand Down Expand Up @@ -184,15 +190,7 @@ export const handler = async function deviceCodeHandler(ctx) {
sid: code.sid,
});

if (ctx.oidc.client.clientAuthMethod === 'none') {
if (at.jkt) {
rt.jkt = at.jkt;
}

if (at['x5t#S256']) {
rt['x5t#S256'] = at['x5t#S256'];
}
}
await setRefreshTokenBindings(ctx, at, rt);

ctx.oidc.entity('RefreshToken', rt);
refreshToken = await rt.save();
Expand Down
10 changes: 8 additions & 2 deletions lib/actions/grants/refresh_token.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import revoke from '../../helpers/revoke.js';
import certificateThumbprint from '../../helpers/certificate_thumbprint.js';
import * as formatters from '../../helpers/formatters.js';
import filterClaims from '../../helpers/filter_claims.js';
import dpopValidate, { DPOP_OK_WINDOW } from '../../helpers/validate_dpop.js';
import dpopValidate, { CHALLENGE_OK_WINDOW } from '../../helpers/validate_dpop.js';
import resolveResource from '../../helpers/resolve_resource.js';
import epochTime from '../../helpers/epoch_time.js';
import checkRar from '../../shared/check_rar.js';
import getCtxAccountClaims from '../../helpers/account_claims.js';
import { checkAttestBinding } from '../../helpers/check_attest_binding.js';

import { gty as cibaGty } from './ciba.js';
import { gty as deviceCodeGty } from './device_code.js';
Expand Down Expand Up @@ -104,7 +105,7 @@ export const handler = async function refreshTokenHandler(ctx) {
const unique = await ReplayDetection.unique(
client.clientId,
dPoP.jti,
epochTime() + DPOP_OK_WINDOW,
epochTime() + CHALLENGE_OK_WINDOW,
);

ctx.assert(unique, new InvalidGrant('DPoP proof JWT Replay detected'));
Expand All @@ -114,6 +115,10 @@ export const handler = async function refreshTokenHandler(ctx) {
throw new InvalidGrant('failed jkt verification');
}

if (ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth') {
await checkAttestBinding(ctx, refreshToken);
}

ctx.oidc.entity('RefreshToken', refreshToken);
ctx.oidc.entity('Grant', grant);

Expand Down Expand Up @@ -168,6 +173,7 @@ export const handler = async function refreshTokenHandler(ctx) {
rar: refreshToken.rar,
'x5t#S256': refreshToken['x5t#S256'],
jkt: refreshToken.jkt,
attestationJkt: refreshToken.attestationJkt,
});

if (refreshToken.gty && !refreshToken.gty.endsWith(gty)) {
Expand Down
2 changes: 2 additions & 0 deletions lib/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as registration from './registration.js';
import getRevocation from './revocation.js';
import getIntrospection from './introspection.js';
import discovery from './discovery.js';
import challenge from './challenge.js';
import * as endSession from './end_session.js';
import * as codeVerification from './code_verification.js';

Expand All @@ -20,4 +21,5 @@ export {
discovery,
endSession,
codeVerification,
challenge,
};
13 changes: 13 additions & 0 deletions lib/actions/introspection.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import rejectDupes from '../shared/reject_dupes.js';
import paramsMiddleware from '../shared/assemble_params.js';
import { InvalidRequest } from '../helpers/errors.js';
import rejectStructuredTokens from '../shared/reject_structured_tokens.js';
import { checkAttestBinding } from '../helpers/check_attest_binding.js';

const introspectable = new Set(['AccessToken', 'ClientCredentials', 'RefreshToken']);
const JWT = 'application/token-introspection+jwt';
Expand Down Expand Up @@ -150,6 +151,18 @@ export default function introspectionAction(provider) {
return;
}

if (
token.kind === 'RefreshToken'
&& ctx.oidc.client.clientId === token.clientId
&& ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth'
) {
try {
await checkAttestBinding(ctx, token);
} catch {
return;
}
}

if (!(await allowedPolicy(ctx, ctx.oidc.client, token))) {
return;
}
Expand Down
13 changes: 13 additions & 0 deletions lib/actions/revocation.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import rejectDupes from '../shared/reject_dupes.js';
import paramsMiddleware from '../shared/assemble_params.js';
import rejectStructuredTokens from '../shared/reject_structured_tokens.js';
import revoke from '../helpers/revoke.js';
import { checkAttestBinding } from '../helpers/check_attest_binding.js';

const revokeable = new Set(['AccessToken', 'ClientCredentials', 'RefreshToken']);

Expand Down Expand Up @@ -98,6 +99,18 @@ export default function revocationAction(provider) {
return;
}

if (
token.kind === 'RefreshToken'
&& ctx.oidc.client.clientId === token.clientId
&& ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth'
) {
try {
await checkAttestBinding(ctx, token);
} catch {
return;
}
}

if (!(await allowedPolicy(ctx, ctx.oidc.client, token))) {
return;
}
Expand Down
Loading