Skip to content

Commit 72142fd

Browse files
committed
feat: JWT Response for OAuth Token Introspection
As defined by https://tools.ietf.org/html/draft-lodderstedt-oauth-jwt-introspection-response this allows for introspection response to be a JWT Enable by configuration = { features: { introspection: true, jwtIntrospection: true } } New client properties: - introspection_signed_response_alg if encryption is also enabled - introspection_encrypted_response_alg - introspection_encrypted_response_enc
1 parent 06373d7 commit 72142fd

23 files changed

+418
-57
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ The following drafts/experimental specifications are implemented by oidc-provide
5252
- [RFC7592 - OAuth 2.0 Dynamic Client Registration Management Protocol (Update and Delete)][registration-management]
5353
- [OAuth 2.0 Web Message Response Mode - draft 00][wmrm]
5454
- [OAuth 2.0 Device Flow for Browserless and Input Constrained Devices - draft 11][device-flow]
55+
- [JWT Response for OAuth Token Introspection - draft 01][jwt-introspection]
5556

5657
Updates to draft and experimental specification versions are released as MINOR library versions,
5758
if you utilize these specification implementations consider using the tilde `~` operator in your
@@ -171,3 +172,4 @@ See the list of available emitted [event names](/docs/events.md) and their descr
171172
[debug-link]: https://github.com/visionmedia/debug
172173
[wmrm]: https://tools.ietf.org/html/draft-sakimura-oauth-wmrm-00
173174
[device-flow]: https://tools.ietf.org/html/draft-ietf-oauth-device-flow-11
175+
[jwt-introspection]: https://tools.ietf.org/html/draft-lodderstedt-oauth-jwt-introspection-response-01

docs/configuration.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ deployment compact. The feature flags with their default values are
368368
| encryption | no |
369369
| frontchannelLogout | no |
370370
| introspection | no |
371+
| jwtIntrospection | no |
371372
| oauthNativeApps | yes (forces pkce on with forcedForNative) |
372373
| pkce | yes |
373374
| registration | no |
@@ -499,6 +500,7 @@ introspection_endpoint property of the discovery endpoint is published, otherwis
499500
is not sent. The use of this endpoint is covered by the same authz mechanism as the regular token
500501
endpoint or `introspection_endpoint_auth_method` and `introspection_endpoint_auth_signing_alg` if
501502
defined on a client.
503+
502504
```js
503505
const configuration = { features: { introspection: Boolean[false] } };
504506
```
@@ -508,6 +510,14 @@ the token endpoint access must be authorized it is recommended to setup a client
508510
use. This client should be unusable for standard authorization flow, to set up such a client provide
509511
grant_types, response_types and redirect_uris as empty arrays.
510512

513+
**JWT Response for OAuth Token Introspection**
514+
Enables additional JSON Web Token responses for OAuth 2.0 Token Introspection as defined by
515+
[JWT Response for OAuth Token Introspection - draft 01][jwt-introspection]
516+
517+
```js
518+
const configuration = { features: { introspection: true, jwtIntrospection: Boolean[false] } };
519+
```
520+
511521

512522
**Revocation endpoint**
513523
Enables the use of Revocation endpoint as described in [RFC7009][revocation] for tokens of
@@ -1187,6 +1197,7 @@ _**default value**_:
11871197
encryption: false,
11881198
frontchannelLogout: false,
11891199
introspection: false,
1200+
jwtIntrospection: false,
11901201
registration: false,
11911202
registrationManagement: false,
11921203
request: false,
@@ -1597,7 +1608,10 @@ _**default value**_:
15971608
revocationEndpointAuthSigningAlgValues: [],
15981609
userinfoEncryptionAlgValues: [],
15991610
userinfoEncryptionEncValues: [],
1600-
userinfoSigningAlgValues: [] }
1611+
userinfoSigningAlgValues: [],
1612+
introspectionEncryptionAlgValues: [],
1613+
introspectionEncryptionEncValues: [],
1614+
introspectionSigningAlgValues: [] }
16011615
```
16021616

16031617
### userCodeConfirmSource
@@ -1707,3 +1721,4 @@ async userCodeInputSource(ctx, form, out, err) {
17071721
[third-party-cookies-so]: https://stackoverflow.com/questions/3550790/check-if-third-party-cookies-are-enabled/7104048#7104048
17081722
[wmrm]: https://tools.ietf.org/html/draft-sakimura-oauth-wmrm-00
17091723
[device-flow]: https://tools.ietf.org/html/draft-ietf-oauth-device-flow-11
1724+
[jwt-introspection]: https://tools.ietf.org/html/draft-lodderstedt-oauth-jwt-introspection-response-01

example/settings.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ module.exports.config = {
3636
encryption: true, // defaults to false
3737
frontchannelLogout: true, // defaults to false
3838
introspection: true, // defaults to false
39+
jwtIntrospection: true, // defaults to false
3940
registration: true, // defaults to false
4041
request: true, // defaults to false
4142
revocation: true, // defaults to false

lib/actions/discovery.js

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable max-len */
2+
13
const { defaults } = require('lodash');
24

35
const instance = require('../helpers/weak_cache');
@@ -15,20 +17,12 @@ module.exports = function discoveryAction(provider) {
1517
id_token_signing_alg_values_supported: config.idTokenSigningAlgValues,
1618
issuer: provider.issuer,
1719
jwks_uri: ctx.oidc.urlFor('certificates'),
18-
registration_endpoint: config.features.registration
19-
? ctx.oidc.urlFor('registration') : undefined,
20-
request_object_signing_alg_values_supported:
21-
config.features.request || config.features.requestUri
22-
? config.requestObjectSigningAlgValues : undefined,
20+
registration_endpoint: config.features.registration ? ctx.oidc.urlFor('registration') : undefined,
21+
request_object_signing_alg_values_supported: config.features.request || config.features.requestUri ? config.requestObjectSigningAlgValues : undefined,
2322
request_parameter_supported: !!config.features.request,
2423
request_uri_parameter_supported: !!config.features.requestUri,
25-
require_request_uri_registration: config.features.requestUri
26-
&& config.features.requestUri.requireRequestUriRegistration ? true : undefined,
27-
response_modes_supported: [
28-
'form_post',
29-
'fragment',
30-
'query',
31-
],
24+
require_request_uri_registration: config.features.requestUri && config.features.requestUri.requireRequestUriRegistration ? true : undefined,
25+
response_modes_supported: ['form_post', 'fragment', 'query'],
3226
response_types_supported: config.responseTypes,
3327
scopes_supported: config.scopes,
3428
subject_types_supported: config.subjectTypes,
@@ -37,26 +31,27 @@ module.exports = function discoveryAction(provider) {
3731
token_endpoint_auth_signing_alg_values_supported: config.tokenEndpointAuthSigningAlgValues,
3832
userinfo_endpoint: ctx.oidc.urlFor('userinfo'),
3933
userinfo_signing_alg_values_supported: config.userinfoSigningAlgValues,
40-
code_challenge_methods_supported: config.features.pkce
41-
? config.features.pkce.supportedMethods : undefined,
34+
code_challenge_methods_supported: config.features.pkce ? config.features.pkce.supportedMethods : undefined,
4235
};
4336

4437
if (config.features.webMessageResponseMode) {
4538
ctx.body.response_modes_supported.push('web_message');
4639
}
4740

4841
if (config.features.introspection) {
49-
const prefix = 'introspection_endpoint';
5042
ctx.body.introspection_endpoint = ctx.oidc.urlFor('introspection');
51-
ctx.body[`${prefix}_auth_methods_supported`] = config.introspectionEndpointAuthMethods;
52-
ctx.body[`${prefix}_auth_signing_alg_values_supported`] = config.introspectionEndpointAuthSigningAlgValues;
43+
ctx.body.introspection_endpoint_auth_methods_supported = config.introspectionEndpointAuthMethods;
44+
ctx.body.introspection_endpoint_auth_signing_alg_values_supported = config.introspectionEndpointAuthSigningAlgValues;
45+
}
46+
47+
if (config.features.jwtIntrospection) {
48+
ctx.body.introspection_endpoint_signing_alg_values_supported = config.introspectionSigningAlgValues;
5349
}
5450

5551
if (config.features.revocation) {
56-
const prefix = 'revocation_endpoint';
5752
ctx.body.revocation_endpoint = ctx.oidc.urlFor('revocation');
58-
ctx.body[`${prefix}_auth_methods_supported`] = config.revocationEndpointAuthMethods;
59-
ctx.body[`${prefix}_auth_signing_alg_values_supported`] = config.revocationEndpointAuthSigningAlgValues;
53+
ctx.body.revocation_endpoint_auth_methods_supported = config.revocationEndpointAuthMethods;
54+
ctx.body.revocation_endpoint_auth_signing_alg_values_supported = config.revocationEndpointAuthSigningAlgValues;
6055
}
6156

6257
if (config.features.encryption) {
@@ -65,10 +60,14 @@ module.exports = function discoveryAction(provider) {
6560
ctx.body.userinfo_encryption_alg_values_supported = config.userinfoEncryptionAlgValues;
6661
ctx.body.userinfo_encryption_enc_values_supported = config.userinfoEncryptionEncValues;
6762

63+
if (config.features.jwtIntrospection) {
64+
ctx.body.introspection_encryption_alg_values_supported = config.introspectionEncryptionAlgValues;
65+
ctx.body.introspection_encryption_enc_values_supported = config.introspectionEncryptionEncValues;
66+
}
67+
6868
if (config.features.request || config.features.requestUri) {
69-
const prefix = 'request_object_encryption';
70-
ctx.body[`${prefix}_alg_values_supported`] = config.requestObjectEncryptionAlgValues;
71-
ctx.body[`${prefix}_enc_values_supported`] = config.requestObjectEncryptionEncValues;
69+
ctx.body.request_object_encryption_alg_values_supported = config.requestObjectEncryptionAlgValues;
70+
ctx.body.request_object_encryption_enc_values_supported = config.requestObjectEncryptionEncValues;
7271
}
7372
}
7473

lib/actions/introspection.js

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,37 @@ const instance = require('../helpers/weak_cache');
1111
const bodyParser = require('../shared/selective_body');
1212
const rejectDupes = require('../shared/reject_dupes');
1313
const getParams = require('../shared/assemble_params');
14+
const { InvalidRequest } = require('../helpers/errors');
1415

1516
const introspectable = new Set(['AccessToken', 'ClientCredentials', 'RefreshToken']);
1617
const PARAM_LIST = new Set(['token', 'token_type_hint', ...tokenAuth.AUTH_PARAMS]);
18+
const JWT = 'application/jwt';
1719

1820
module.exports = function introspectionAction(provider) {
19-
const Claims = mask(instance(provider).configuration());
21+
const configuration = instance(provider).configuration();
22+
const { features: { jwtIntrospection } } = configuration;
23+
const Claims = mask(configuration);
2024
const parseBody = bodyParser('application/x-www-form-urlencoded');
2125
const buildParams = getParams(PARAM_LIST);
2226
const { grantTypeHandlers } = instance(provider);
27+
const {
28+
IdToken, AccessToken, ClientCredentials, RefreshToken, Client,
29+
} = provider;
2330

2431
function getAccessToken(token) {
25-
return provider.AccessToken.find(token);
32+
return AccessToken.find(token);
2633
}
2734

2835
function getClientCredentials(token) {
2936
/* istanbul ignore if */
3037
if (!grantTypeHandlers.has('client_credentials')) return undefined;
31-
return provider.ClientCredentials.find(token);
38+
return ClientCredentials.find(token);
3239
}
3340

3441
function getRefreshToken(token) {
3542
/* istanbul ignore if */
3643
if (!grantTypeHandlers.has('refresh_token')) return undefined;
37-
return provider.RefreshToken.find(token);
44+
return RefreshToken.find(token);
3845
}
3946

4047
function findResult(results) {
@@ -63,6 +70,34 @@ module.exports = function introspectionAction(provider) {
6370
);
6471
},
6572

73+
async function jwtIntrospectionResponse(ctx, next) {
74+
if (jwtIntrospection) {
75+
const { client } = ctx.oidc;
76+
77+
const {
78+
introspectionEncryptedResponseAlg: encrypt,
79+
introspectionSignedResponseAlg: sign,
80+
introspectionEndpointAuthMethod: method,
81+
} = client;
82+
83+
if (encrypt && method === 'none' && ctx.accepts('json', JWT) !== JWT) {
84+
throw new InvalidRequest(`introspection must be requested with Accept: ${JWT} for this client`);
85+
}
86+
87+
await next();
88+
89+
if ((encrypt || sign) && ctx.accepts('json', JWT) === JWT) {
90+
const token = new IdToken({});
91+
token.extra = ctx.body;
92+
93+
ctx.body = await token.sign(client, { use: 'introspection' });
94+
ctx.type = 'application/jwt; charset=utf-8';
95+
}
96+
} else {
97+
await next();
98+
}
99+
},
100+
66101
async function renderTokenResponse(ctx, next) {
67102
const { params } = ctx.oidc;
68103

@@ -132,7 +167,7 @@ module.exports = function introspectionAction(provider) {
132167
if (token.clientId !== ctx.oidc.client.clientId) {
133168
ctx.body.sub = Claims.sub(
134169
token.accountId,
135-
(await provider.Client.find(token.clientId)).sectorIdentifier,
170+
(await Client.find(token.clientId)).sectorIdentifier,
136171
);
137172
} else {
138173
ctx.body.sub = Claims.sub(token.accountId, ctx.oidc.client.sectorIdentifier);

lib/consts/client_attributes.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const SECRET_LENGTH_REQUIRED = [
4242
'request_object_signing_alg',
4343
'token_endpoint_auth_signing_alg',
4444
'userinfo_signed_response_alg',
45+
'introspection_signed_response_alg',
4546

4647
// must be after token_endpoint_auth_signing_alg
4748
'introspection_endpoint_auth_signing_alg',
@@ -96,6 +97,9 @@ const STRING = [
9697
'userinfo_encrypted_response_alg',
9798
'userinfo_encrypted_response_enc',
9899
'userinfo_signed_response_alg',
100+
'introspection_encrypted_response_alg',
101+
'introspection_encrypted_response_enc',
102+
'introspection_signed_response_alg',
99103

100104
// must be after token_endpoint_auth_method
101105
'introspection_endpoint_auth_method',
@@ -116,6 +120,7 @@ const WHEN = {
116120
id_token_encrypted_response_enc: ['id_token_encrypted_response_alg', 'A128CBC-HS256'],
117121
request_object_encryption_enc: ['request_object_encryption_alg', 'A128CBC-HS256'],
118122
userinfo_encrypted_response_enc: ['userinfo_encrypted_response_alg', 'A128CBC-HS256'],
123+
introspection_encrypted_response_enc: ['introspection_encrypted_response_alg', 'A128CBC-HS256'],
119124
};
120125

121126
const WEB_URI = [

lib/helpers/client_schema.js

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ module.exports = function getSchema(provider) {
4444
if (features.introspection) {
4545
RECOGNIZED_METADATA.push('introspection_endpoint_auth_method');
4646
RECOGNIZED_METADATA.push('introspection_endpoint_auth_signing_alg');
47+
48+
if (features.jwtIntrospection) {
49+
RECOGNIZED_METADATA.push('introspection_signed_response_alg');
50+
51+
if (features.encryption) {
52+
RECOGNIZED_METADATA.push('introspection_encrypted_response_alg');
53+
RECOGNIZED_METADATA.push('introspection_encrypted_response_enc');
54+
}
55+
}
4756
}
4857

4958
if (features.revocation) {
@@ -121,6 +130,9 @@ module.exports = function getSchema(provider) {
121130
userinfo_encrypted_response_alg: () => configuration.userinfoEncryptionAlgValues,
122131
userinfo_encrypted_response_enc: () => configuration.userinfoEncryptionEncValues,
123132
userinfo_signed_response_alg: () => configuration.userinfoSigningAlgValues,
133+
introspection_encrypted_response_alg: () => configuration.introspectionEncryptionAlgValues,
134+
introspection_encrypted_response_enc: () => configuration.introspectionEncryptionEncValues,
135+
introspection_signed_response_alg: () => configuration.introspectionSigningAlgValues,
124136

125137
// must be after token_* specific
126138
introspection_endpoint_auth_method: () => configuration.introspectionEndpointAuthMethods,
@@ -131,6 +143,9 @@ module.exports = function getSchema(provider) {
131143
() => configuration.revocationEndpointAuthSigningAlgValues,
132144
};
133145

146+
const requestSignAlgRequiringJwks = /^(RS|ES)/;
147+
const encAlgRequiringJwks = /^(RSA|ECDH)/;
148+
134149
class Schema {
135150
constructor(metadata) {
136151
// unless explicitly provided use token_* values
@@ -251,9 +266,10 @@ module.exports = function getSchema(provider) {
251266
const requireJwks = this.token_endpoint_auth_method === 'private_key_jwt'
252267
|| this.introspection_endpoint_auth_method === 'private_key_jwt'
253268
|| this.revocation_endpoint_auth_method === 'private_key_jwt'
254-
|| (String(this.request_object_signing_alg).match(/^(RS|ES)/))
255-
|| (String(this.id_token_encrypted_response_alg).match(/^(RSA|ECDH)/))
256-
|| (String(this.userinfo_encrypted_response_alg).match(/^(RSA|ECDH)/));
269+
|| (requestSignAlgRequiringJwks.exec(this.request_object_signing_alg))
270+
|| (encAlgRequiringJwks.exec(this.id_token_encrypted_response_alg))
271+
|| (encAlgRequiringJwks.exec(this.userinfo_encrypted_response_alg))
272+
|| (encAlgRequiringJwks.exec(this.introspection_encrypted_response_alg));
257273

258274
if (requireJwks && !this.jwks && !this.jwks_uri) {
259275
invalidate('jwks or jwks_uri is mandatory for this client');

lib/helpers/configuration.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ class Configuration {
6666
}
6767
}
6868

69+
if (!this.features.introspection) {
70+
if (this.features.jwtIntrospection) {
71+
throw new Error('jwtIntrospection is only available in conjuction with introspection');
72+
}
73+
}
74+
6975
if (this.features.registrationManagement && !this.features.registration) {
7076
throw new Error('registrationManagement is only available in conjuction with registration');
7177
}

lib/helpers/configuration_schema.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ module.exports = class ConfigurationSchema {
213213
this.userinfoEncryptionAlgValues = fullEncAlg.slice();
214214
this.userinfoEncryptionEncValues = this.features.encryption ? encryptionEnc.slice() : [];
215215
this.userinfoSigningAlgValues = secretSig.slice();
216+
217+
this.introspectionEncryptionAlgValues = fullEncAlg.slice();
218+
this.introspectionEncryptionEncValues = this.features.encryption ? encryptionEnc.slice() : [];
219+
this.introspectionSigningAlgValues = secretSig.slice();
216220
}
217221

218222
endpointAuth(endpoint) {
@@ -247,5 +251,8 @@ module.exports = class ConfigurationSchema {
247251
this.omitUnsupported('userinfoEncryptionAlgValues');
248252
this.omitUnsupported('userinfoEncryptionEncValues');
249253
this.omitUnsupported('userinfoSigningAlgValues');
254+
this.omitUnsupported('introspectionEncryptionAlgValues');
255+
this.omitUnsupported('introspectionEncryptionEncValues');
256+
this.omitUnsupported('introspectionSigningAlgValues');
250257
}
251258
};

lib/helpers/defaults.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ const DEFAULTS = {
182182
encryption: false,
183183
frontchannelLogout: false,
184184
introspection: false,
185+
jwtIntrospection: false,
185186
registration: false,
186187
registrationManagement: false,
187188
request: false,
@@ -749,6 +750,9 @@ const DEFAULTS = {
749750
userinfoEncryptionAlgValues: [],
750751
userinfoEncryptionEncValues: [],
751752
userinfoSigningAlgValues: [],
753+
introspectionEncryptionAlgValues: [],
754+
introspectionEncryptionEncValues: [],
755+
introspectionSigningAlgValues: [],
752756
},
753757

754758

0 commit comments

Comments
 (0)