From f43d82066deffdb52a1b27a3e91922566e0369df Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sun, 23 Sep 2018 22:38:51 +0200 Subject: [PATCH] feat: enable Certificate Bound Access Tokens Defined in https://tools.ietf.org/html/draft-ietf-oauth-mtls-11 with this feature the provider is able to bind issued access tokens to a client certificate. Such a binding is accomplished by associating the certificate with the token in a way that can be accessed by the protected resource, such as embedding the certificate hash in an issued JWT access token directly or through additional token introspection response claims. It relies on your TLS-offloading proxy to process and send metadata about the client X.509 certificate via a header to the upstream node.js application. See the configuration doc section for more details. --- README.md | 1 + docs/configuration.md | 65 +++- lib/actions/discovery.js | 4 + lib/actions/grants/authorization_code.js | 9 + lib/actions/grants/client_credentials.js | 10 + lib/actions/grants/device_code.js | 9 + lib/actions/grants/refresh_token.js | 9 + lib/actions/introspection.js | 4 + lib/actions/userinfo.js | 8 + lib/consts/client_attributes.js | 1 + lib/helpers/calculate_thumbprint.js | 8 + lib/helpers/client_schema.js | 5 + lib/helpers/defaults.js | 85 +++++- lib/models/access_token.js | 2 + lib/models/client_credentials.js | 2 + lib/models/formats/jwt.js | 11 +- lib/models/mixins/is_cert_bound.js | 14 + .../certificate_bound_access_tokens.config.js | 29 ++ .../certificate_bound_access_tokens.test.js | 284 ++++++++++++++++++ test/client.crt | 21 ++ test/configuration/client_metadata.test.js | 14 + test/dynamic_scopes/dynamic_scopes.config.js | 1 - test/storage/jwt.test.js | 10 + test/storage/legacy.test.js | 4 + test/storage/opaque.test.js | 4 + 25 files changed, 601 insertions(+), 13 deletions(-) create mode 100644 lib/helpers/calculate_thumbprint.js create mode 100644 lib/models/mixins/is_cert_bound.js create mode 100644 test/certificate_bound_access_tokens/certificate_bound_access_tokens.config.js create mode 100644 test/certificate_bound_access_tokens/certificate_bound_access_tokens.test.js create mode 100644 test/client.crt diff --git a/README.md b/README.md index 2ff899f1f..bc7c045c5 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ The following drafts/experimental specifications are implemented by oidc-provide - [RFC7592 - OAuth 2.0 Dynamic Client Registration Management Protocol (Update and Delete)][registration-management] - [OAuth 2.0 Web Message Response Mode - draft 00][wmrm] - [OAuth 2.0 Device Flow for Browserless and Input Constrained Devices - draft 12][device-flow] +- [OAuth 2.0 Mutual TLS Client Authentication and Certificate Bound Access Tokens - draft 11][mtls] - [JWT Response for OAuth Token Introspection - draft 01][jwt-introspection] Updates to draft and experimental specification versions are released as MINOR library versions, diff --git a/docs/configuration.md b/docs/configuration.md index fe6369969..469722ac3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -730,6 +730,7 @@ Enable/disable features. pkce: true, alwaysIssueRefresh: false, backchannelLogout: false, + certificateBoundAccessTokens: false, claimsParameter: false, clientCredentials: false, conformIdTokenClaims: true, @@ -763,6 +764,49 @@ _**default value**_: false ``` +### features.certificateBoundAccessTokens + +[draft-ietf-oauth-mtls-11](https://tools.ietf.org/html/draft-ietf-oauth-mtls-11) - OAuth 2.0 Mutual TLS Client Authentication and Certificate Bound Access Tokens + +Enables Certificate Bound Access Tokens. Clients may be registered with `tls_client_certificate_bound_access_tokens` to indicate intention to receive mutual TLS client certificate bound access tokens. + + + +_**default value**_: +```js +false +``` +
+ (Click to expand) Setting up the environment for Certificate Bound Access Tokens +
+ + +To enable Certificate Bound Access Tokens the provider expects `x-ssl-client-cert` header to be presented by your TLS-offloading proxy with the variable value set by this proxy. An important aspect is to sanitize the inbound request header at the proxy.

The most common openssl based proxies are Apache and NGINX, with those you're looking to use

__`SSLVerifyClient` (Apache) / `ssl_verify_client` (NGINX)__ with the appropriate configuration value that matches your setup requirements.

Set the proxy request header with variable set as a result of enabling MTLS + + +```nginx +# NGINX +proxy_set_header x-ssl-client-cert $ssl_client_cert; +``` +```apache +# Apache +RequestHeader set x-ssl-client-cert "" +RequestHeader set x-ssl-client-cert "%{SSL_CLIENT_CERT}s" +``` +You should also consider hosting the token and userinfo endpoints on a separate host name or port in order to prevent unintended impact on the TLS behaviour of your other endpoints, e.g. Discovery or the authorization endpoint and changing the discovery values for these with a post-middleware since you need MTLS to issue tokens (token_endpoint) and userinfo will now act as a Resource Server supporting Certificate Bound Access Tokens and therefore needs to handle client certificates. + + +```js +provider.use(async (ctx, next) => { + await next(); + if (ctx.oidc.route === 'discovery' && ctx.method === 'GET' && ctx.status === 200) { + ctx.body.userinfo_endpoint = '...'; + ctx.body.token_endpoint = '...'; + } +}); +``` +
+ ### features.claimsParameter [Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.5.5) - Requesting Claims using the "claims" Request Parameter @@ -1608,7 +1652,7 @@ async interactionUrl(ctx, interaction) { ### introspectionEndpointAuthMethods -Array of Client Authentication methods supported by this OP's Introspection Endpoint. If no configuration value is provided the same values as for tokenEndpointAuthMethods will be used. Supported values list is the same as for tokenEndpointAuthMethods +Array of Client Authentication methods supported by this OP's Introspection Endpoint. If no configuration value is provided the same values as for tokenEndpointAuthMethods will be used. Supported values list is the same as for tokenEndpointAuthMethods. _**affects**_: discovery, client authentication for introspection, registration and registration management @@ -1784,7 +1828,7 @@ _**affects**_: authorization, discovery, registration, registration management ### revocationEndpointAuthMethods -Array of Client Authentication methods supported by this OP's Revocation Endpoint. If no configuration value is provided the same values as for tokenEndpointAuthMethods will be used. Supported values list is the same as for tokenEndpointAuthMethods +Array of Client Authentication methods supported by this OP's Revocation Endpoint. If no configuration value is provided the same values as for tokenEndpointAuthMethods will be used. Supported values list is the same as for tokenEndpointAuthMethods. _**affects**_: discovery, client authentication for revocation, registration and registration management @@ -1876,11 +1920,11 @@ _**default value**_: ```
- (Click to expand) Setting up tls_client_auth + (Click to expand) Setting up the environment for tls_client_auth
-To enable `tls_client_auth` the provider expects `x-ssl-client-verify` and `x-ssl-client-s-dn` headers to be presented by your TLS-offloading proxy with the variable values set by these proxies. An important aspect is to sanitize the inbound request headers at the proxy.

The most common openssl based proxies are Apache and NGINX, with those you're looking to use

__`SSLVerifyClient` (Apache) / `ssl_verify_client` (NGINX)__ `require` - if you only support tls_client_auth, `optional` if you also support additional non-MTLS based authentication methods, `optional_no_ca` - if you also support additional non-MTLS based authentication methods AND self_signed_tls_client_auth (not implemented yet)

__`SSLCACertificateFile` or `SSLCACertificatePath` (Apache) / `ssl_client_certificate` (NGINX)__ with the values pointing to your accepted CA Certificates

Set the proxy request headers with variables set as a result of enabling MTLS +To enable `tls_client_auth` the provider expects `x-ssl-client-verify` and `x-ssl-client-s-dn` headers to be presented by your TLS-offloading proxy with the variable values set by these proxies. An important aspect is to sanitize the inbound request headers at the proxy.

The most common openssl based proxies are Apache and NGINX, with those you're looking to use

__`SSLVerifyClient` (Apache) / `ssl_verify_client` (NGINX)__ with the appropriate configuration value that matches your setup requirements.

__`SSLCACertificateFile` or `SSLCACertificatePath` (Apache) / `ssl_client_certificate` (NGINX)__ with the values pointing to your accepted CA Certificates.

Set the proxy request headers with variables set as a result of enabling MTLS ```nginx @@ -1895,6 +1939,19 @@ RequestHeader set x-ssl-client-verify "%{SSL_CLIENT_VERIFY}s" RequestHeader set x-ssl-client-s-dn "" RequestHeader set x-ssl-client-s-dn "%{SSL_CLIENT_S_DN}s" ``` +You should also consider hosting the endpoints supporting client authentication, on a separate host name or port in order to prevent unintended impact on the TLS behaviour of your other endpoints, e.g. Discovery or the authorization endpoint and changing the discovery values for them with a post-middleware. + + +```js +provider.use(async (ctx, next) => { + await next(); + if (ctx.oidc.route === 'discovery' && ctx.method === 'GET' && ctx.status === 200) { + ctx.body.token_endpoint = '...'; + ctx.body.introspection_endpoint = '...'; + ctx.body.revocation_endpoint = '...'; + } +}); +```
### ttl diff --git a/lib/actions/discovery.js b/lib/actions/discovery.js index 5c7ffe99e..9dba33fe2 100644 --- a/lib/actions/discovery.js +++ b/lib/actions/discovery.js @@ -116,6 +116,10 @@ module.exports = function discoveryAction(provider) { ctx.body.device_authorization_endpoint = ctx.oidc.urlFor('device_authorization'); } + if (config.features.certificateBoundAccessTokens) { + ctx.body.tls_client_certificate_bound_access_tokens = true; + } + defaults(ctx.body, config.discovery); await next(); diff --git a/lib/actions/grants/authorization_code.js b/lib/actions/grants/authorization_code.js index 16dcd4b29..9cf02e4d9 100644 --- a/lib/actions/grants/authorization_code.js +++ b/lib/actions/grants/authorization_code.js @@ -72,6 +72,15 @@ module.exports.handler = function getAuthorizationCodeHandler(provider) { sid: code.sid, }); + if (ctx.oidc.client.tlsClientCertificateBoundAccessTokens) { + const cert = ctx.get('x-ssl-client-cert'); + + if (!cert) { + throw new InvalidGrant('MTLS client certificate missing'); + } + at.setS256Thumbprint(cert); + } + at.setAudiences(await audiences(ctx, account.accountId, at, 'access_token')); const accessToken = await at.save(); diff --git a/lib/actions/grants/client_credentials.js b/lib/actions/grants/client_credentials.js index 6a01c31e5..8b5eb5e27 100644 --- a/lib/actions/grants/client_credentials.js +++ b/lib/actions/grants/client_credentials.js @@ -1,4 +1,5 @@ const instance = require('../../helpers/weak_cache'); +const { InvalidGrant } = require('../../helpers/errors'); module.exports.handler = function getClientCredentialsHandler(provider) { return async function clientCredentialsResponse(ctx, next) { @@ -26,6 +27,15 @@ module.exports.handler = function getClientCredentialsHandler(provider) { scope: scopes.join(' ') || undefined, }); + if (ctx.oidc.client.tlsClientCertificateBoundAccessTokens) { + const cert = ctx.get('x-ssl-client-cert'); + + if (!cert) { + throw new InvalidGrant('MTLS client certificate missing'); + } + token.setS256Thumbprint(cert); + } + token.setAudiences(await audiences(ctx, undefined, token, 'client_credentials')); const value = await token.save(); diff --git a/lib/actions/grants/device_code.js b/lib/actions/grants/device_code.js index b3d993951..3db8becb6 100644 --- a/lib/actions/grants/device_code.js +++ b/lib/actions/grants/device_code.js @@ -88,6 +88,15 @@ module.exports.handler = function getDeviceCodeHandler(provider) { sid: code.sid, }); + if (ctx.oidc.client.tlsClientCertificateBoundAccessTokens) { + const cert = ctx.get('x-ssl-client-cert'); + + if (!cert) { + throw new InvalidGrant('MTLS client certificate missing'); + } + at.setS256Thumbprint(cert); + } + at.setAudiences(await audiences(ctx, account.accountId, at, 'access_token')); const accessToken = await at.save(); diff --git a/lib/actions/grants/refresh_token.js b/lib/actions/grants/refresh_token.js index 434811bee..1d1eb0c6b 100644 --- a/lib/actions/grants/refresh_token.js +++ b/lib/actions/grants/refresh_token.js @@ -106,6 +106,15 @@ module.exports.handler = function getRefreshTokenHandler(provider) { gty: refreshToken.gty, }); + if (ctx.oidc.client.tlsClientCertificateBoundAccessTokens) { + const cert = ctx.get('x-ssl-client-cert'); + + if (!cert) { + throw new InvalidGrant('MTLS client certificate missing'); + } + at.setS256Thumbprint(cert); + } + if (!at.gty.endsWith(gty)) { at.gty = `${at.gty} ${gty}`; } diff --git a/lib/actions/introspection.js b/lib/actions/introspection.js index e8617d140..5feec2cf4 100644 --- a/lib/actions/introspection.js +++ b/lib/actions/introspection.js @@ -185,6 +185,10 @@ module.exports = function introspectionAction(provider) { scope: token.scope, }); + if (token['x5t#S256']) { + ctx.body.cnf = { 'x5t#S256': token['x5t#S256'] }; + } + await next(); }, ]; diff --git a/lib/actions/userinfo.js b/lib/actions/userinfo.js index ecb852475..767634dde 100644 --- a/lib/actions/userinfo.js +++ b/lib/actions/userinfo.js @@ -11,6 +11,7 @@ const bodyParser = require('../shared/conditional_body'); const rejectDupes = require('../shared/reject_dupes'); const params = require('../shared/assemble_params'); const noCache = require('../shared/no_cache'); +const getS256Thumbprint = require('../helpers/calculate_thumbprint'); const PARAM_LIST = [ 'scope', @@ -52,6 +53,13 @@ module.exports = function userinfoAction(provider) { const accessToken = await provider.AccessToken.find(ctx.oidc.bearer); ctx.assert(accessToken, new InvalidToken()); + if (accessToken['x5t#S256']) { + const cert = ctx.get('x-ssl-client-cert'); + if (!cert || accessToken['x5t#S256'] !== getS256Thumbprint(cert)) { + throw new InvalidToken(); + } + } + uuidToGrantId('switched from uuid=%s to value of grantId=%s', ctx.oidc.uuid, accessToken.grantId); ctx.oidc.uuid = accessToken.grantId; diff --git a/lib/consts/client_attributes.js b/lib/consts/client_attributes.js index 1982c57ea..23ffa05bd 100644 --- a/lib/consts/client_attributes.js +++ b/lib/consts/client_attributes.js @@ -65,6 +65,7 @@ const BOOL = [ 'backchannel_logout_session_required', 'frontchannel_logout_session_required', 'require_auth_time', + 'tls_client_certificate_bound_access_tokens', ]; const ARYS = [ diff --git a/lib/helpers/calculate_thumbprint.js b/lib/helpers/calculate_thumbprint.js new file mode 100644 index 000000000..38ea87136 --- /dev/null +++ b/lib/helpers/calculate_thumbprint.js @@ -0,0 +1,8 @@ +const { createHash } = require('crypto'); + +const base64url = require('base64url'); + +module.exports = function getS256Thumbprint(cert) { + const normalized = cert.replace(/(?:-----(?:BEGIN|END) CERTIFICATE-----|\s|=)/g, ''); + return base64url(createHash('sha256').update(Buffer.from(normalized, 'base64')).digest()); +}; diff --git a/lib/helpers/client_schema.js b/lib/helpers/client_schema.js index c9aefd42b..470f03a12 100644 --- a/lib/helpers/client_schema.js +++ b/lib/helpers/client_schema.js @@ -117,6 +117,11 @@ module.exports = function getSchema(provider) { DEFAULT.web_message_uris = []; } + if (features.certificateBoundAccessTokens) { + RECOGNIZED_METADATA.push('tls_client_certificate_bound_access_tokens'); + DEFAULT.tls_client_certificate_bound_access_tokens = false; + } + instance(provider).RECOGNIZED_METADATA = RECOGNIZED_METADATA; const ENUM = { diff --git a/lib/helpers/defaults.js b/lib/helpers/defaults.js index e95e918ee..aee4cdda8 100644 --- a/lib/helpers/defaults.js +++ b/lib/helpers/defaults.js @@ -267,6 +267,64 @@ const DEFAULTS = { */ backchannelLogout: false, + /* + * features.certificateBoundAccessTokens + * + * title: [draft-ietf-oauth-mtls-11](https://tools.ietf.org/html/draft-ietf-oauth-mtls-11) - OAuth 2.0 Mutual TLS Client Authentication and Certificate Bound Access Tokens + * + * description: Enables Certificate Bound Access Tokens. Clients may be registered with + * `tls_client_certificate_bound_access_tokens` to indicate intention to receive mutual TLS client + * certificate bound access tokens. + * + * example: Setting up the environment for Certificate Bound Access Tokens + * To enable Certificate Bound Access Tokens the provider expects `x-ssl-client-cert` + * header to be presented by your TLS-offloading proxy with the variable value set by this + * proxy. An important aspect is to sanitize the inbound request header at the proxy. + * + *

+ * + * The most common openssl based proxies are Apache and NGINX, with those you're looking to use + * + *

+ * + * __`SSLVerifyClient` (Apache) / `ssl_verify_client` (NGINX)__ with the appropriate configuration + * value that matches your setup requirements. + * + *

+ * + * Set the proxy request header with variable set as a result of enabling MTLS + * + * ```nginx + * # NGINX + * proxy_set_header x-ssl-client-cert $ssl_client_cert; + * ``` + * + * ```apache + * # Apache + * RequestHeader set x-ssl-client-cert "" + * RequestHeader set x-ssl-client-cert "%{SSL_CLIENT_CERT}s" + * ``` + * + * You should also consider hosting the token and userinfo endpoints on a separate host name + * or port in order to prevent unintended impact on the TLS behaviour of your other + * endpoints, e.g. discovery or the authorization endpoint and changing the discovery values + * for these with a post-middleware since you need MTLS to issue tokens (token_endpoint) and + * userinfo will now act as a Resource Server supporting Certificate Bound Access Tokens and + * therefore needs to handle client certificates. + * + * ```js + * provider.use(async (ctx, next) => { + * await next(); + * if (ctx.oidc.route === 'discovery' && ctx.method === 'GET' && ctx.status === 200) { + * ctx.body.userinfo_endpoint = '...'; + * ctx.body.token_endpoint = '...'; + * } + * }); + * ``` + * + */ + certificateBoundAccessTokens: false, + /* * features.claimsParameter * @@ -676,7 +734,7 @@ const DEFAULTS = { * 'tls_client_auth', * ] * ``` - * example: Setting up tls_client_auth + * example: Setting up the environment for tls_client_auth * To enable `tls_client_auth` the provider expects `x-ssl-client-verify` and `x-ssl-client-s-dn` * headers to be presented by your TLS-offloading proxy with the variable values set by these * proxies. An important aspect is to sanitize the inbound request headers at the proxy. @@ -687,14 +745,13 @@ const DEFAULTS = { * *

* - * __`SSLVerifyClient` (Apache) / `ssl_verify_client` (NGINX)__ - * `require` - if you only support tls_client_auth, - * `optional` if you also support additional non-MTLS based authentication methods, - * `optional_no_ca` - if you also support additional non-MTLS based authentication methods AND self_signed_tls_client_auth (not implemented yet) + * __`SSLVerifyClient` (Apache) / `ssl_verify_client` (NGINX)__ with the appropriate configuration + * value that matches your setup requirements. * *

* - * __`SSLCACertificateFile` or `SSLCACertificatePath` (Apache) / `ssl_client_certificate` (NGINX)__ with the values pointing to your accepted CA Certificates + * __`SSLCACertificateFile` or `SSLCACertificatePath` (Apache) / `ssl_client_certificate` (NGINX)__ + * with the values pointing to your accepted CA Certificates. * *

* @@ -714,6 +771,22 @@ const DEFAULTS = { * RequestHeader set x-ssl-client-s-dn "%{SSL_CLIENT_S_DN}s" * ``` * + * You should also consider hosting the endpoints supporting client authentication, on a separate + * host name or port in order to prevent unintended impact on the TLS behaviour of your other + * endpoints, e.g. discovery or the authorization endpoint and changing the discovery values + * for them with a post-middleware. + * + * ```js + * provider.use(async (ctx, next) => { + * await next(); + * if (ctx.oidc.route === 'discovery' && ctx.method === 'GET' && ctx.status === 200) { + * ctx.body.token_endpoint = '...'; + * ctx.body.introspection_endpoint = '...'; + * ctx.body.revocation_endpoint = '...'; + * } + * }); + * ``` + * */ tokenEndpointAuthMethods: [ 'none', diff --git a/lib/models/access_token.js b/lib/models/access_token.js index 5add5c50a..76f74cf2e 100644 --- a/lib/models/access_token.js +++ b/lib/models/access_token.js @@ -1,10 +1,12 @@ const setAudiences = require('./mixins/set_audiences'); const hasFormat = require('./mixins/has_format'); const hasGrantType = require('./mixins/has_grant_type'); +const isCertBound = require('./mixins/is_cert_bound'); const apply = require('./mixins/apply'); module.exports = provider => class AccessToken extends apply([ setAudiences, + isCertBound, hasGrantType, hasFormat(provider, 'AccessToken', provider.BaseToken), ]) { diff --git a/lib/models/client_credentials.js b/lib/models/client_credentials.js index 6e15d81b1..5f41e4cf8 100644 --- a/lib/models/client_credentials.js +++ b/lib/models/client_credentials.js @@ -1,9 +1,11 @@ const setAudiences = require('./mixins/set_audiences'); const hasFormat = require('./mixins/has_format'); +const isCertBound = require('./mixins/is_cert_bound'); const apply = require('./mixins/apply'); module.exports = provider => class ClientCredentials extends apply([ setAudiences, + isCertBound, hasFormat(provider, 'ClientCredentials', provider.BaseToken), ]) { static get IN_PAYLOAD() { diff --git a/lib/models/formats/jwt.js b/lib/models/formats/jwt.js index 4941e916d..eb04f8195 100644 --- a/lib/models/formats/jwt.js +++ b/lib/models/formats/jwt.js @@ -42,7 +42,7 @@ module.exports = (provider) => { async getValueAndPayload() { const [, payload] = await opaque.getValueAndPayload.call(this); const { - jti, accountId: sub, iss, iat, exp, scope, aud, clientId: azp, + jti, accountId: sub, iss, iat, exp, scope, aud, clientId: azp, 'x5t#S256': S256, } = payload; let value; @@ -51,7 +51,14 @@ module.exports = (provider) => { } else { const { key, alg } = await getSigningAlgAndKey(azp); value = await JWT.sign({ - jti, sub, iss, iat, exp, scope, ...(aud ? { aud, azp } : { aud: azp }), + jti, + sub, + iss, + iat, + exp, + scope, + ...(S256 ? { cnf: { 'x5t#S256': S256 } } : undefined), + ...(aud ? { aud, azp } : { aud: azp }), }, key, alg); } payload.jwt = value; diff --git a/lib/models/mixins/is_cert_bound.js b/lib/models/mixins/is_cert_bound.js new file mode 100644 index 000000000..c29fe9ef6 --- /dev/null +++ b/lib/models/mixins/is_cert_bound.js @@ -0,0 +1,14 @@ +const digest = require('../../helpers/calculate_thumbprint'); + +module.exports = superclass => class extends superclass { + static get IN_PAYLOAD() { + return [ + ...super.IN_PAYLOAD, + 'x5t#S256', + ]; + } + + setS256Thumbprint(cert) { + this['x5t#S256'] = digest(cert); + } +}; diff --git a/test/certificate_bound_access_tokens/certificate_bound_access_tokens.config.js b/test/certificate_bound_access_tokens/certificate_bound_access_tokens.config.js new file mode 100644 index 000000000..db8c153b9 --- /dev/null +++ b/test/certificate_bound_access_tokens/certificate_bound_access_tokens.config.js @@ -0,0 +1,29 @@ +const { clone } = require('lodash'); + +const config = clone(require('../default.config')); + +config.features = { + certificateBoundAccessTokens: true, + clientCredentials: true, + introspection: true, + alwaysIssueRefresh: true, + revocation: true, + deviceFlow: true, +}; + +module.exports = { + config, + client: { + client_id: 'client', + grant_types: [ + 'authorization_code', + 'refresh_token', + 'urn:ietf:params:oauth:grant-type:device_code', + 'client_credentials', + ], + response_types: ['code'], + redirect_uris: ['https://client.example.com/cb'], + token_endpoint_auth_method: 'none', + tls_client_certificate_bound_access_tokens: true, + }, +}; diff --git a/test/certificate_bound_access_tokens/certificate_bound_access_tokens.test.js b/test/certificate_bound_access_tokens/certificate_bound_access_tokens.test.js new file mode 100644 index 000000000..a9f17de7c --- /dev/null +++ b/test/certificate_bound_access_tokens/certificate_bound_access_tokens.test.js @@ -0,0 +1,284 @@ +const { readFileSync } = require('fs'); +const url = require('url'); + +const sinon = require('sinon'); +const { expect } = require('chai'); + +const bootstrap = require('../test_helper'); + +const crt = readFileSync('./test/client.crt').toString(); +const expectedS256 = 'eXvgMeO-8uLw0FGYkJefOXSFHOnbbcfv95rIYCPsbpo'; + +describe('features.certificateBoundAccessTokens', () => { + before(bootstrap(__dirname)); + + describe('discovery', () => { + it('extends discovery', function () { + return this.agent.get('/.well-known/openid-configuration') + .expect(200) + .expect((response) => { + expect(response.body).to.have.property('tls_client_certificate_bound_access_tokens', true); + }); + }); + }); + + describe('userinfo', () => { + it('acts like an RS checking the thumbprint now', async function () { + const at = new this.provider.AccessToken({ + accountId: 'account', + clientId: 'client', + scope: 'openid', + }); + at.setS256Thumbprint(crt); + + const bearer = await at.save(); + + await this.agent.get('/me') + .auth(bearer, { type: 'bearer' }) + .expect(401); + + await this.agent.get('/me') + .auth(bearer, { type: 'bearer' }) + .set('x-ssl-client-cert', 'foobar') + .expect(401); + + await this.agent.get('/me') + .auth(bearer, { type: 'bearer' }) + .set('x-ssl-client-cert', crt.replace(/\n/g, '')) + .expect(200); + }); + }); + + describe('introspection', () => { + it('exposes cnf now', async function () { + const at = new this.provider.AccessToken({ + accountId: 'account', + clientId: 'client', + scope: 'openid', + }); + at.setS256Thumbprint(crt); + + const token = await at.save(); + + await this.agent.post('/token/introspection') + .send({ + token, + client_id: 'client', + }) + .type('form') + .expect(200) + .expect(({ body }) => { + expect(body).to.have.property('cnf'); + expect(body.cnf).to.have.property('x5t#S256'); + }); + }); + }); + + describe('urn:ietf:params:oauth:grant-type:device_code', () => { + beforeEach(async function () { + await this.agent.post('/device/auth') + .send({ + client_id: 'client', + scope: 'openid', + }) + .type('form') + .expect(200) + .expect(({ body: { device_code: dc } }) => { + this.dc = dc; + }); + + this.TestAdapter.for('DeviceCode').syncUpdate(this.getTokenJti(this.dc), { + scope: 'openid', + accountId: 'account', + }); + }); + + it('binds the access token to the certificate', async function () { + const spy = sinon.spy(); + this.provider.once('grant.success', spy); + + await this.agent.post('/token') + .send({ + client_id: 'client', + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: this.dc, + }) + .type('form') + .set('x-ssl-client-cert', crt.replace(/\n/g, '')) + .expect(200); + + expect(spy).to.have.property('calledOnce', true); + const { oidc: { entities: { AccessToken } } } = spy.args[0][0]; + expect(AccessToken).to.have.property('x5t#S256', expectedS256); + }); + + it('verifies the request made over MTLS', async function () { + const spy = sinon.spy(); + this.provider.once('grant.error', spy); + + await this.agent.post('/token') + .send({ + client_id: 'client', + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: this.dc, + }) + .type('form') + .expect(400) + .expect({ error: 'invalid_grant', error_description: 'grant request is invalid' }); + + expect(spy).to.have.property('calledOnce', true); + expect(spy.args[0][0]).to.have.property('error_detail', 'MTLS client certificate missing'); + }); + }); + + describe('authorization flow', () => { + before(function () { return this.login(); }); + + beforeEach(async function () { + const auth = new this.AuthorizationRequest({ + response_type: 'code', + scope: 'openid', + }); + + await this.wrap({ route: '/auth', verb: 'get', auth }) + .expect(302) + .expect(auth.validateClientLocation) + .expect(({ headers: { location } }) => { + const { query: { code } } = url.parse(location, true); + this.code = code; + }); + }); + + describe('authorization_code', () => { + it('binds the access token to the certificate', async function () { + const spy = sinon.spy(); + this.provider.once('grant.success', spy); + + await this.agent.post('/token') + .send({ + client_id: 'client', + grant_type: 'authorization_code', + code: this.code, + redirect_uri: 'https://client.example.com/cb', + }) + .type('form') + .set('x-ssl-client-cert', crt.replace(/\n/g, '')) + .expect(200); + + expect(spy).to.have.property('calledOnce', true); + const { oidc: { entities: { AccessToken } } } = spy.args[0][0]; + expect(AccessToken).to.have.property('x5t#S256', expectedS256); + }); + + it('verifies the request made over MTLS', async function () { + const spy = sinon.spy(); + this.provider.once('grant.error', spy); + + await this.agent.post('/token') + .send({ + client_id: 'client', + grant_type: 'authorization_code', + code: this.code, + redirect_uri: 'https://client.example.com/cb', + }) + .type('form') + .expect(400) + .expect({ error: 'invalid_grant', error_description: 'grant request is invalid' }); + + expect(spy).to.have.property('calledOnce', true); + expect(spy.args[0][0]).to.have.property('error_detail', 'MTLS client certificate missing'); + }); + }); + + describe('refresh_token', () => { + beforeEach(async function () { + await this.agent.post('/token') + .send({ + client_id: 'client', + grant_type: 'authorization_code', + code: this.code, + redirect_uri: 'https://client.example.com/cb', + }) + .type('form') + .set('x-ssl-client-cert', crt.replace(/\n/g, '')) + .expect(({ body }) => { + this.rt = body.refresh_token; + }); + }); + + it('binds the access token to the certificate', async function () { + const spy = sinon.spy(); + this.provider.once('grant.success', spy); + + await this.agent.post('/token') + .send({ + client_id: 'client', + grant_type: 'refresh_token', + refresh_token: this.rt, + }) + .type('form') + .set('x-ssl-client-cert', crt.replace(/\n/g, '')) + .expect(200); + + expect(spy).to.have.property('calledOnce', true); + const { oidc: { entities: { AccessToken } } } = spy.args[0][0]; + expect(AccessToken).to.have.property('x5t#S256', expectedS256); + }); + + it('verifies the request made over MTLS', async function () { + const spy = sinon.spy(); + this.provider.once('grant.error', spy); + + await this.agent.post('/token') + .send({ + client_id: 'client', + grant_type: 'refresh_token', + refresh_token: this.rt, + }) + .type('form') + .expect(400) + .expect({ error: 'invalid_grant', error_description: 'grant request is invalid' }); + + expect(spy).to.have.property('calledOnce', true); + expect(spy.args[0][0]).to.have.property('error_detail', 'MTLS client certificate missing'); + }); + }); + }); + + describe('client_credentials', () => { + it('binds the access token to the certificate', async function () { + const spy = sinon.spy(); + this.provider.once('grant.success', spy); + + await this.agent.post('/token') + .send({ + grant_type: 'client_credentials', + client_id: 'client', + }) + .set('x-ssl-client-cert', crt.replace(/\n/g, '')) + .type('form') + .expect(200); + + expect(spy).to.have.property('calledOnce', true); + const { oidc: { entities: { ClientCredentials } } } = spy.args[0][0]; + expect(ClientCredentials).to.have.property('x5t#S256', expectedS256); + }); + + it('verifies the request was made over MTLS', async function () { + const spy = sinon.spy(); + this.provider.once('grant.error', spy); + + await this.agent.post('/token') + .send({ + grant_type: 'client_credentials', + client_id: 'client', + }) + .type('form') + .expect(400) + .expect({ error: 'invalid_grant', error_description: 'grant request is invalid' }); + + expect(spy).to.have.property('calledOnce', true); + expect(spy.args[0][0]).to.have.property('error_detail', 'MTLS client certificate missing'); + }); + }); +}); diff --git a/test/client.crt b/test/client.crt new file mode 100644 index 000000000..413533a17 --- /dev/null +++ b/test/client.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDZjCCAk4CCQCn6c+ccGjlIzANBgkqhkiG9w0BAQsFADB1MQswCQYDVQQGEwJD +WjELMAkGA1UECAwCUHIxCzAJBgNVBAcMAkNaMQowCAYDVQQKDAFGMQowCAYDVQQL +DAFBMRQwEgYDVQQDDAtjb21tb24gbmFtZTEeMBwGCSqGSIb3DQEJARYPZmlsaXBA +c2tva2FuLmV1MB4XDTE4MDkyMTE0NDA0OVoXDTE5MDkyMTE0NDA0OVowdTELMAkG +A1UEBhMCQ1oxCzAJBgNVBAgMAlByMQswCQYDVQQHDAJDWjEKMAgGA1UECgwBRjEK +MAgGA1UECwwBQTEUMBIGA1UEAwwLY29tbW9uIG5hbWUxHjAcBgkqhkiG9w0BCQEW +D2ZpbGlwQHNrb2thbi5ldTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ALlGyiTCxAqmJPrZfe2LFO4yz0H3mOfB+o1CjQdRO3x++cOvMLfiTjmxKaVfyAsF +7oJkjb/pOSDlCYGQZe38hX5tUJRXvs4B9a8eRnUatViKS3aD0X2V01cMK1KLLJK3 +G3ANPpW5JLg9WL5Bu7zdScfdkXYmHszUWOGVTyt43HOoYVj/++7npoOt2JVarcRV +Pc9BVBFjxmAamdevRIuaORt3agQAJo6HJ2m/fVaQWXvcdRT3ss3Hsz8wTFg+HPtN +qZol+IqolePc92U6pOZxIhp0RgELfHnw1KszyQkUckOCpM22intG02Zu6jVBoBxZ +Elu4VVkK/PWEPL6HErP37CsCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAJD8H0vPx +KPNZ1tmMea/QDmAcg4vGvOFCew2lGloFaPI87WfS9YRsvdyNWucQGB2u8sctTpF5 +MBSscoDEJ0cDzQGfE0FfI/pNU+23KFoV0as1mrlZs07lteiEoDFslxgit7Iac9iJ +12QnnUpgKzw3E7f+iadVIDQLZKZdexmMJlu0ICWsODzOKPRpLaovxN7GZnXhaqgE +PM6sK8XzJ28csn3iYSW02BHdUUQrPMzZOHwkrNUtQosk6qqcikSU3oQYTPxCS2bG +w+Gr8F1+Qwzzg5b1Giakc744YSGBksmu4DL5CCPXuNuX2hLrNi+3J3trj6m6RoEf +NsIzdYYAwiq7hw== +-----END CERTIFICATE----- diff --git a/test/configuration/client_metadata.test.js b/test/configuration/client_metadata.test.js index d89d8ba7c..0f0fd5c9a 100644 --- a/test/configuration/client_metadata.test.js +++ b/test/configuration/client_metadata.test.js @@ -833,6 +833,20 @@ describe('Client metadata validation', () => { }); }); + context('features.certificateBoundAccessTokens', () => { + context('tls_client_certificate_bound_access_tokens', function () { + const configuration = { + features: { + certificateBoundAccessTokens: true, + }, + }; + + defaultsTo(this.title, false, undefined, configuration); + defaultsTo(this.title, undefined); + mustBeBoolean(this.title, undefined, configuration); + }); + }); + context('features.sessionManagement', () => { context('post_logout_redirect_uris', function () { const configuration = { diff --git a/test/dynamic_scopes/dynamic_scopes.config.js b/test/dynamic_scopes/dynamic_scopes.config.js index 2dd51434d..6cc18be48 100644 --- a/test/dynamic_scopes/dynamic_scopes.config.js +++ b/test/dynamic_scopes/dynamic_scopes.config.js @@ -3,7 +3,6 @@ const { clone } = require('lodash'); const config = clone(require('../default.config')); config.features = { - deviceFlow: true, clientCredentials: true, }; diff --git a/test/storage/jwt.test.js b/test/storage/jwt.test.js index f9a80f7f5..2233e0022 100644 --- a/test/storage/jwt.test.js +++ b/test/storage/jwt.test.js @@ -34,12 +34,14 @@ if (FORMAT === 'jwt') { const params = { foo: 'bar' }; const userCode = '1384-3217'; const deviceInfo = { foo: 'bar' }; + const s256 = '_gPMqAT8BELhXwBa2nIT0OvdWtQCiF_g09nAyHhgCe0'; /* eslint-disable object-property-newline */ const fullPayload = { accountId, claims, clientId, grantId, scope, sid, consumed, acr, amr, authTime, nonce, redirectUri, codeChallenge, codeChallengeMethod, aud, error, errorDescription, params, userCode, deviceInfo, gty, + 'x5t#S256': s256, }; /* eslint-enable object-property-newline */ @@ -74,6 +76,7 @@ if (FORMAT === 'jwt') { kind, scope, sid, + 'x5t#S256': s256, }); const { iat, jti, exp } = upsert.getCall(0).args[1]; @@ -87,6 +90,9 @@ if (FORMAT === 'jwt') { jti, scope, sub: accountId, + cnf: { + 'x5t#S256': s256, + }, }); }); @@ -235,6 +241,7 @@ if (FORMAT === 'jwt') { jwt: string, kind, scope, + 'x5t#S256': s256, }); const { iat, jti, exp } = upsert.getCall(0).args[1]; @@ -247,6 +254,9 @@ if (FORMAT === 'jwt') { iss: this.provider.issuer, jti, scope, + cnf: { + 'x5t#S256': s256, + }, }); }); diff --git a/test/storage/legacy.test.js b/test/storage/legacy.test.js index efb22366c..86cbc52a8 100644 --- a/test/storage/legacy.test.js +++ b/test/storage/legacy.test.js @@ -34,12 +34,14 @@ if (FORMAT === 'legacy') { const params = { foo: 'bar' }; const userCode = '1384-3217'; const deviceInfo = { foo: 'bar' }; + const s256 = '_gPMqAT8BELhXwBa2nIT0OvdWtQCiF_g09nAyHhgCe0'; /* eslint-disable object-property-newline */ const fullPayload = { accountId, claims, clientId, grantId, scope, sid, consumed, acr, amr, authTime, nonce, redirectUri, codeChallenge, codeChallengeMethod, aud, error, errorDescription, params, userCode, deviceInfo, gty, + 'x5t#S256': s256, }; /* eslint-enable object-property-newline */ @@ -81,6 +83,7 @@ if (FORMAT === 'legacy') { kind, scope, sid, + 'x5t#S256': s256, }); }); @@ -221,6 +224,7 @@ if (FORMAT === 'legacy') { jti: upsert.getCall(0).args[0], kind, scope, + 'x5t#S256': s256, }); }); diff --git a/test/storage/opaque.test.js b/test/storage/opaque.test.js index b915288d6..81e7fc92b 100644 --- a/test/storage/opaque.test.js +++ b/test/storage/opaque.test.js @@ -28,12 +28,14 @@ if (FORMAT === 'opaque') { const params = { foo: 'bar' }; const userCode = '1384-3217'; const deviceInfo = { foo: 'bar' }; + const s256 = '_gPMqAT8BELhXwBa2nIT0OvdWtQCiF_g09nAyHhgCe0'; /* eslint-disable object-property-newline */ const fullPayload = { accountId, claims, clientId, grantId, scope, sid, consumed, acr, amr, authTime, nonce, redirectUri, codeChallenge, codeChallengeMethod, aud, error, errorDescription, params, userCode, deviceInfo, gty, + 'x5t#S256': s256, }; /* eslint-enable object-property-newline */ @@ -67,6 +69,7 @@ if (FORMAT === 'opaque') { kind, scope, sid, + 'x5t#S256': s256, }); }); @@ -175,6 +178,7 @@ if (FORMAT === 'opaque') { jti: upsert.getCall(0).args[0], kind, scope, + 'x5t#S256': s256, }); });