Skip to content

Commit d655ebd

Browse files
committed
feat: Experimental support for Attestation-Based Client Authentication
1 parent 77e06eb commit d655ebd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1659
-274
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ The following draft specifications are implemented by oidc-provider:
5252
- [Financial-grade API: Client Initiated Backchannel Authentication Profile (`FAPI-CIBA`) - Implementers Draft 01][fapi-ciba]
5353
- [FAPI 2.0 Message Signing (`FAPI 2.0`) - Implementers Draft 01][fapi2ms-id1]
5454
- [OIDC Relying Party Metadata Choices 1.0 - Implementers Draft 01][rp-metadata-choices]
55+
- [OAuth 2.0 Attestation-Based Client Authentication][attestation-client-auth]
5556

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

docs/README.md

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,7 @@ location / {
470470
- [rpInitiatedLogout](#featuresrpinitiatedlogout)
471471
- [userinfo](#featuresuserinfo)
472472
- Experimental features:
473+
- [attestClientAuth](#featuresattestclientauth)
473474
- [externalSigningSupport (e.g. KMS)](#featuresexternalsigningsupport)
474475
- [richAuthorizationRequests](#featuresrichauthorizationrequests)
475476
- [rpMetadataChoices](#featuresrpmetadatachoices)
@@ -638,6 +639,83 @@ new oidc.Provider('http://localhost:3000', {
638639
```
639640
</details>
640641

642+
### features.attestClientAuth
643+
644+
[`draft-ietf-oauth-attestation-based-client-auth-06`](https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-06.html) - OAuth 2.0 Attestation-Based Client Authentication
645+
646+
> [!NOTE]
647+
> This is an experimental feature.
648+
649+
Enables the method `attest_jwt_client_auth` for use in the server's `clientAuthMethods` configuration.
650+
651+
652+
653+
_**default value**_:
654+
```js
655+
{
656+
ack: undefined,
657+
assertAttestationJwtAndPop: [AsyncFunction: assertAttestationJwtAndPop], // see expanded details below
658+
challengeSecret: undefined,
659+
enabled: false,
660+
getAttestationSignaturePublicKey: [AsyncFunction: getAttestationSignaturePublicKey] // see expanded details below
661+
}
662+
```
663+
664+
<details><summary>(Click to expand) features.attestClientAuth options details</summary><br>
665+
666+
667+
#### assertAttestationJwtAndPop
668+
669+
Helper function used to assert the Attestation JWT and Attestation JWT PoP beyond its specification definition, e.g. According to used extension profiles.
670+
At the point of this helper's invocation the Attestation JWT and Attestation JWT PoP have had their signatures and validity claims verified.
671+
672+
673+
_**default value**_:
674+
```js
675+
async function assertAttestationJwtAndPop(ctx, attestation, pop, client) {
676+
// @param ctx - koa request context
677+
// @param attestation - verified and parsed Attestation JWT
678+
// attestation.protectedHeader - parsed protected header object
679+
// attestation.payload - parsed protected header object
680+
// attestation.key - CryptoKey that verified the Attestation JWT signature
681+
// @param pop - verified and parsed Attestation JWT PoP
682+
// pop.protectedHeader - parsed protected header object
683+
// pop.payload - parsed protected header object
684+
// pop.key - CryptoKey that verified the Attestation JWT PoP signature
685+
// @param client - client making the request
686+
}
687+
```
688+
689+
#### challengeSecret
690+
691+
A secret value used for generating server-provided Client Attestation PoP JWT challenges. Must be a 32-byte length Buffer instance.
692+
693+
694+
_**default value**_:
695+
```js
696+
undefined
697+
```
698+
699+
#### getAttestationSignaturePublicKey
700+
701+
Helper function used to verify the issuer identifier of a Client Attestation JWT and to retrieve a public key with which the Client Attestation JWT signature will be verified.
702+
At the point of this helper's invocation nothing about the Attestation JWT has been verified, only that its format is a JWT.
703+
The key may be returned as CryptoKey, KeyObject, or a JWK.
704+
705+
706+
_**default value**_:
707+
```js
708+
async function getAttestationSignaturePublicKey(ctx, iss, header, client) {
709+
// @param ctx - koa request context
710+
// @param iss - Issuer Identifier from the Client Attestation JWT
711+
// @param header - Protected Header of the Client Attestation JWT
712+
// @param client - client making the request
713+
throw new Error('features.attestClientAuth.getAttestationSignaturePublicKey not implemented');
714+
}
715+
```
716+
717+
</details>
718+
641719
### features.backchannelLogout
642720

643721
[`OIDC Back-Channel Logout 1.0`](https://openid.net/specs/openid-connect-backchannel-1_0-final.html)
@@ -2233,7 +2311,7 @@ true
22332311
22342312
### assertJwtClientAuthClaimsAndHeader
22352313
2236-
Helper function used to validate the JWT Client Authentication Assertion Claims Set and Header beyond what its specification mandates.
2314+
Helper function used to validate the JWT Client Authentication (`private_key_jwt` and `client_secret_jwt`) Assertion Claims Set and Header beyond what its specification mandates.
22372315
22382316
22392317
_**default value**_:
@@ -3212,6 +3290,7 @@ _**default value**_:
32123290
{
32133291
authorization: '/auth',
32143292
backchannel_authentication: '/backchannel',
3293+
challenge: '/challenge',
32153294
code_verification: '/device',
32163295
device_authorization: '/device/auth',
32173296
end_session: '/session/end',
@@ -3337,6 +3416,33 @@ Configure `ttl` for a given token type with a function like so, this must return
33373416
Fine-tune the algorithms the authorization server supports by declaring algorithm values for each respective JWA use
33383417
33393418
3419+
### enabledJWA.attestSigningAlgValues
3420+
3421+
JWS "alg" Algorithm values the authorization server supports to verify signed Client Attestation and Client Attestation PoP JWTs with
3422+
3423+
3424+
3425+
_**default value**_:
3426+
```js
3427+
[
3428+
'ES256',
3429+
'Ed25519',
3430+
'EdDSA'
3431+
]
3432+
```
3433+
<a id="enabled-jwa-attest-signing-alg-values-supported-values-list"></a><details><summary>(Click to expand) Supported values list
3434+
</summary><br>
3435+
3436+
```js
3437+
[
3438+
'RS256', 'RS384', 'RS512',
3439+
'PS256', 'PS384', 'PS512',
3440+
'ES256', 'ES384', 'ES512',
3441+
'Ed25519', 'EdDSA',
3442+
]
3443+
```
3444+
</details>
3445+
33403446
### enabledJWA.authorizationEncryptionAlgValues
33413447
33423448
JWE "alg" Algorithm values the authorization server supports for JWT Authorization response (`JARM`) encryption
@@ -3428,7 +3534,7 @@ _**default value**_:
34283534
34293535
### enabledJWA.clientAuthSigningAlgValues
34303536
3431-
JWS "alg" Algorithm values the authorization server supports for signed JWT Client Authentication
3537+
JWS "alg" Algorithm values the authorization server supports for signed JWT Client Authentication (`private_key_jwt` and `client_secret_jwt`)
34323538
34333539
34343540

example/my_adapter.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ class MyAdapter {
6969
* refresh token
7070
* - 'jkt' {string} - JWK SHA-256 Thumbprint (according to [RFC7638]) of a DPoP bound
7171
* access or refresh token
72+
* - 'attestationJkt' {string} - [BackchannelAuthenticationRequest, DeviceCode, RefreshToken, PushedAuthorizationRequest only]
73+
* JWK SHA-256 Thumbprint (according to [RFC7638]) of an attest_jwt_client_auth client instance
7274
* - gty {string} - [AccessToken, RefreshToken only] space delimited grant values, indicating
7375
* the grant type(s) they originate from (implicit, authorization_code, refresh_token or
7476
* device_code) the original one is always first, second is refresh_token if refreshed

lib/actions/authorization/backchannel_request_response.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ export default async function backchannelRequestResponse(ctx) {
1414
scope: [...ctx.oidc.requestParamScopes].join(' '),
1515
});
1616

17+
if (ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth') {
18+
await request.setAttestBinding(ctx);
19+
}
20+
1721
// eslint-disable-next-line default-case
1822
switch (request.resource.length) {
1923
case 0:

lib/actions/authorization/device_authorization_response.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export default async function deviceAuthorizationResponse(ctx) {
1212
userCode: normalize(userCode),
1313
});
1414

15+
if (ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth') {
16+
await dc.setAttestBinding(ctx);
17+
}
18+
1519
ctx.oidc.entity('DeviceCode', dc);
1620
ctx.body = {
1721
device_code: await dc.save(),

lib/actions/authorization/pushed_authorization_request_response.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ export default async function pushedAuthorizationRequestResponse(ctx) {
4848
trusted: ctx.oidc.client.clientAuthMethod !== 'none' || !!ctx.oidc.trusted?.length,
4949
});
5050

51+
if (ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth') {
52+
await requestObject.setAttestBinding(ctx);
53+
}
54+
5155
const id = await requestObject.save(ttl);
5256

5357
ctx.oidc.entity('PushedAuthorizationRequest', requestObject);

lib/actions/challenge.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import instance from '../helpers/weak_cache.js';
2+
import noCache from '../shared/no_cache.js';
3+
4+
export default [
5+
noCache,
6+
function challenge(ctx) {
7+
const { DPoPNonces, AttestChallenges } = instance(ctx.oidc.provider);
8+
9+
ctx.body = {};
10+
11+
const nextNonce = DPoPNonces?.nextNonce();
12+
if (nextNonce) {
13+
ctx.set('dpop-nonce', nextNonce);
14+
}
15+
16+
const nextChallenge = AttestChallenges?.nextChallenge();
17+
if (nextChallenge) {
18+
ctx.set('oauth-client-attestation-challenge', nextChallenge);
19+
ctx.body.attestation_challenge = nextChallenge;
20+
}
21+
},
22+
];

lib/actions/discovery.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,5 +139,9 @@ export default function discovery(ctx) {
139139
ctx.body.authorization_details_types_supported = Object.keys(richAuthorizationRequests.types);
140140
}
141141

142+
if (features.attestClientAuth.enabled) {
143+
ctx.body.challenge_endpoint = ctx.oidc.urlFor('challenge');
144+
}
145+
142146
defaults(ctx.body, configuration.discovery);
143147
}

lib/actions/grants/authorization_code.js

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import resolveResource from '../../helpers/resolve_resource.js';
99
import epochTime from '../../helpers/epoch_time.js';
1010
import checkRar from '../../shared/check_rar.js';
1111
import getCtxAccountClaims from '../../helpers/account_claims.js';
12+
import { setRefreshTokenBindings } from '../../helpers/set_rt_bindings.js';
13+
import { checkAttestBinding } from '../../helpers/check_attest_binding.js';
1214

1315
const gty = 'authorization_code';
1416

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

94+
if (ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth' && code.attestationJkt) {
95+
await checkAttestBinding(ctx, code);
96+
}
97+
9298
if (code.consumed) {
9399
await revoke(ctx, code.grantId);
94100
throw new InvalidGrant('authorization code already consumed');
@@ -192,15 +198,7 @@ export const handler = async function authorizationCodeHandler(ctx) {
192198
rar: code.rar,
193199
});
194200

195-
if (ctx.oidc.client.clientAuthMethod === 'none') {
196-
if (at.jkt) {
197-
rt.jkt = at.jkt;
198-
}
199-
200-
if (at['x5t#S256']) {
201-
rt['x5t#S256'] = at['x5t#S256'];
202-
}
203-
}
201+
await setRefreshTokenBindings(ctx, at, rt);
204202

205203
ctx.oidc.entity('RefreshToken', rt);
206204
refreshToken = await rt.save();

lib/actions/grants/ciba.js

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import dpopValidate, { DPOP_OK_WINDOW } from '../../helpers/validate_dpop.js';
99
import resolveResource from '../../helpers/resolve_resource.js';
1010
import epochTime from '../../helpers/epoch_time.js';
1111
import getCtxAccountClaims from '../../helpers/account_claims.js';
12+
import { setRefreshTokenBindings } from '../../helpers/set_rt_bindings.js';
13+
import { checkAttestBinding } from '../../helpers/check_attest_binding.js';
1214

1315
const {
1416
AuthorizationPending,
@@ -72,6 +74,10 @@ export const handler = async function cibaHandler(ctx) {
7274
throw new AuthorizationPending();
7375
}
7476

77+
if (ctx.oidc.client.clientAuthMethod === 'attest_jwt_client_auth') {
78+
await checkAttestBinding(ctx, request);
79+
}
80+
7581
if (request.consumed) {
7682
await revoke(ctx, request.grantId);
7783
throw new InvalidGrant('backchannel authentication request already consumed');
@@ -185,15 +191,7 @@ export const handler = async function cibaHandler(ctx) {
185191
sid: request.sid,
186192
});
187193

188-
if (ctx.oidc.client.clientAuthMethod === 'none') {
189-
if (at.jkt) {
190-
rt.jkt = at.jkt;
191-
}
192-
193-
if (at['x5t#S256']) {
194-
rt['x5t#S256'] = at['x5t#S256'];
195-
}
196-
}
194+
await setRefreshTokenBindings(ctx, at, rt);
197195

198196
ctx.oidc.entity('RefreshToken', rt);
199197
refreshToken = await rt.save();

0 commit comments

Comments
 (0)