diff --git a/docs/api/handlers/token-handler.md b/docs/api/handlers/token-handler.md index d0817a0d..bde9fd54 100644 --- a/docs/api/handlers/token-handler.md +++ b/docs/api/handlers/token-handler.md @@ -32,6 +32,7 @@ Constructor. * [.updateSuccessResponse()](#TokenHandler+updateSuccessResponse) * [.updateErrorResponse()](#TokenHandler+updateErrorResponse) * [.isClientAuthenticationRequired()](#TokenHandler+isClientAuthenticationRequired) + * [.isClientSecretOptional()](#TokenHandler+isClientSecretOptional) @@ -94,7 +95,19 @@ Update response when an error is thrown. ### tokenHandler.isClientAuthenticationRequired() -Given a grant type, check if client authentication is required +Given a grant type, client and pkce flag, check if client authentication is required. + +An explicit `requireClientAuthentication` entry for the grant type always wins. +Otherwise the client type decides: +- `public`: no secret for `refresh_token`; `authorization_code` requires PKCE instead +- `confidential` or no type: secret always required, PKCE never waives it + +**Kind**: instance method of [TokenHandler](#TokenHandler) + + +### tokenHandler.isClientSecretOptional() +Given a grant type and pkce flag, check if a request without a client secret could be accepted +for any client type. **Kind**: instance method of [TokenHandler](#TokenHandler) diff --git a/docs/api/model.md b/docs/api/model.md index c5d420d8..7cd41ed6 100644 --- a/docs/api/model.md +++ b/docs/api/model.md @@ -85,12 +85,13 @@ This model function is **required** for all grant types. - `password` grant **Remarks:** -`clientSecret` is `null`/absent for public clients, and also for -`authorization_code` requests that authenticate with PKCE (a `code_verifier`) -instead of a secret — even if the client *has* a `client_secret`. PKCE is -**not** a substitute for client authentication: your implementation must -reject (return a falsy value) a confidential client that should have -presented its `client_secret` but did not. +`clientSecret` is `null` when the request does not contain a secret. The +library decides afterwards whether a secret was required, based on the +returned client's `type` and the `requireClientAuthentication` option: +a `confidential` client, or a client without `type`, is rejected without +a secret, even with PKCE. When a `clientSecret` is provided, your implementation must +always verify it, also for public clients, and return a falsy value if it +does not match. **Kind**: instance method of [Model](#Model) **Fulfil**: [ClientData](#ClientData) - An `Object` representing the client and associated data, or a falsy value if no such client could be found. @@ -687,4 +688,5 @@ An `Object` representing the client and associated data. | grants | Array.<string> | Grant types allowed for the client. | | accessTokenLifetime | number | Client-specific lifetime of generated access tokens in seconds. | | refreshTokenLifetime | number | Client-specific lifetime of generated refresh tokens in seconds. | +| type | string | The client type: `public` or `confidential`. Optional. When not set (`undefined` or `null`), the client is treated as confidential. Any other value throws a `ServerError`. | diff --git a/docs/api/server.md b/docs/api/server.md index 29f0333e..086c6e38 100644 --- a/docs/api/server.md +++ b/docs/api/server.md @@ -40,7 +40,7 @@ Instantiates `OAuth2Server` using the supplied model. | [options.accessTokenLifetime] | number | 3600 | Lifetime of generated access tokens in seconds (default = 1 hour). | | [options.refreshTokenLifetime] | number | 1209600 | Lifetime of generated refresh tokens in seconds (default = 2 weeks). | | [options.allowExtendedTokenAttributes] | boolean | false | Allow extended attributes to be set on the returned token (see remarks section). | -| [options.requireClientAuthentication] | object \| boolean | object | Require a client secret for grant types (names as keys). Defaults to `true` for all grant types. | +| [options.requireClientAuthentication] | object \| boolean | object | Require a client secret for grant types (names as keys). An entry takes precedence over the client `type`; grants without an entry follow the client `type` (see remarks section). | | [options.alwaysIssueNewRefreshToken] | boolean | true | Always revoke the used refresh token and issue a new one for the `refresh_token` grant. | | [options.extendedGrantTypes] | object | object | Additional supported grant types. | | [options.enablePlainPKCE] | boolean | false | Allow the use of the `plain` code challenge method for PKCE. This is not recommended for production environments. | @@ -159,7 +159,13 @@ function authorizeHandler(options) { Retrieves a new token for an authorized token request. **Remarks:** If `options.allowExtendedTokenAttributes` is `true` any additional properties set on the object returned from `Model#saveToken() ` are copied to the token response sent to the client. -By default, all grant types require the client to send it's `client_secret` with the token request. `options.requireClientAuthentication` can be used to disable this check for selected grants. If used, this server option must be an object containing properties set to `true` or `false`. Possible keys for the object include all supported values for the token request's `grant_type` field (`authorization_code`, `client_credentials`, `password` and `refresh_token`). Grants that are not specified default to `true` which enables verification of the `client_secret`. Note: setting a grant to `false` disables only the *presence* check of the `client_secret` for that grant, and does so for **all** clients (not just public ones); it does not validate a secret that is sent. The same applies to `authorization_code` requests that use PKCE (a `code_verifier`), where the presence check is skipped. Per-client (public vs confidential) authentication must therefore be enforced in your `Model#getClient() ` implementation, which should reject a confidential client that fails to present its `client_secret`. +By default, all grant types require the client to send its `client_secret` with the token request. `options.requireClientAuthentication` overrides this per grant type. +It must be an object with `grant_type` values as keys (`authorization_code`, `client_credentials`, `password` and `refresh_token`) set to `true` or `false`. +An entry takes precedence over the client `type` returned by `Model#getClient() ` and applies to all clients. +`false` disables only the *presence* check of the `client_secret`; a secret that is sent is still passed to `Model#getClient()`, which must verify it. +Grants without an entry follow the client `type`: `confidential` clients and clients without `type` always require a secret, even with PKCE. `public` clients may use `refresh_token` without a secret. +Public clients must use PKCE for `authorization_code`. The `client_credentials` grant type cannot be used with public clients. +Any other `type` value throws a `ServerError`. See the PKCE guide for details. ```js let options = { // ... @@ -194,7 +200,7 @@ For information on how to implement a handler for a custom grant type see the ex | [options.accessTokenLifetime] | number | 3600 | Lifetime of generated access tokens in seconds (default = 1 hour). | | [options.refreshTokenLifetime] | number | 1209600 | Lifetime of generated refresh tokens in seconds (default = 2 weeks). | | [options.allowExtendedTokenAttributes] | boolean | false | Allow extended attributes to be set on the returned token (see remarks section). | -| [options.requireClientAuthentication] | object \| boolean | object | Require a client secret for grant types (names as keys). Defaults to `true` for all grant types. | +| [options.requireClientAuthentication] | object \| boolean | object | Require a client secret for grant types (names as keys). An entry takes precedence over the client `type`; grants without an entry follow the client `type` (see remarks section). | | [options.alwaysIssueNewRefreshToken] | boolean | true | Always revoke the used refresh token and issue a new one for the `refresh_token` grant. | | [options.extendedGrantTypes] | object | object | Additional supported grant types. | diff --git a/docs/guide/pkce.md b/docs/guide/pkce.md index 206b59c2..6d5702d5 100644 --- a/docs/guide/pkce.md +++ b/docs/guide/pkce.md @@ -173,26 +173,51 @@ there. A client that obtained its tokens via PKCE refreshes them like any other client — by presenting its `refresh_token` (and, if it is a confidential client, its `client_secret`). -PKCE is **not** client authentication, and never a substitute for a `client_secret`: - -- A **confidential** client (one issued a `client_secret`) must authenticate with - its secret on **every** token request, including `refresh_token`. PKCE is - additive. Your `model.getClient(clientId, clientSecret)` is responsible for - rejecting (returning a falsy value) a confidential client that fails to present - its secret. -- A **public** client has no secret. If you choose to issue refresh tokens to - public clients (weigh the security implications first — see - [RFC 9700](https://www.rfc-editor.org/rfc/rfc9700)), relax client authentication - for that grant: - - const server = new OAuth2Server({ - model, - requireClientAuthentication: { refresh_token: false } // allow refresh without a client_secret - }) - - `requireClientAuthentication: { refresh_token: false }` disables the - `client_secret` **presence** check for the `refresh_token` grant for **all** - clients, not just public ones, so per-client (public vs confidential) - enforcement must be done in your `model.getClient`. The library does not yet - model the public/confidential distinction itself (tracked in - [#81](https://github.com/node-oauth/node-oauth2-server/issues/81)). +PKCE is **not** client authentication, and never a substitute for a `client_secret`. +Declare the client type ([RFC 6749 §2.1](https://datatracker.ietf.org/doc/html/rfc6749#section-2.1)) +by returning a `type` property from `model.getClient()`: + +```js +async getClient(clientId, clientSecret) { + const client = await db.findClient(clientId); + if (!client) return null; + // a provided secret must always match, also for public clients + if (clientSecret !== null && client.secret !== clientSecret) return null; + return { id: client.id, grants: client.grants, type: client.type }; // 'public' or 'confidential' +} +``` + +The token endpoint then decides whether a `client_secret` is required: + +- A **confidential** client (`type: 'confidential'`, or no `type` at all) must + authenticate with its secret on **every** token request, including + `refresh_token`. PKCE is additive and does not waive the secret. +- A **public** client (`type: 'public'`) has no secret. It must use PKCE for the + `authorization_code` grant + ([RFC 9700 §2.1.1](https://www.rfc-editor.org/rfc/rfc9700#section-2.1.1)), even + if it sends a `client_secret` or `requireClientAuthentication` waives it: a code + issued without a `code_challenge` is rejected with `invalid_grant`. Unless + `requireClientAuthentication.refresh_token` is set, it may use `refresh_token` + without a secret (weigh the security implications first, see + [RFC 9700](https://www.rfc-editor.org/rfc/rfc9700)). The `client_credentials` + grant type cannot be used with public clients + ([RFC 6749 §4.4](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)). +- Any other `type` value (e.g. `'Confidential'` or `''`) throws a `ServerError`. + `undefined` and `null` count as no type. + +An explicit `requireClientAuthentication` entry for a grant type always takes +precedence over the client type, for **all** clients: + + const server = new OAuth2Server({ + model, + requireClientAuthentication: { refresh_token: false } // allow refresh without a client_secret + }) + +An explicit `true` requires a secret for every client of that grant type, +including public clients. + +A request without a `client_secret` is rejected before `model.getClient()` is +called whenever no client type could accept it: the grant has no explicit +`false` entry, and the request is neither `authorization_code` with a +`code_verifier` nor `refresh_token`. In the remaining cases `model.getClient()` +receives `null` as `clientSecret`, so it must handle a missing secret. diff --git a/index.d.ts b/index.d.ts index 34209981..44c5a0f1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -232,10 +232,10 @@ declare namespace OAuth2Server { allowExtendedTokenAttributes?: boolean; /** - * Require a client secret. Defaults to true for all grant types. - * Setting a grant to `false` disables the client_secret presence check for - * that grant for ALL clients (not just public ones); per-client - * (public vs confidential) enforcement must be done in model.getClient. + * Require a client secret per grant type. An entry takes precedence over + * the client `type` and applies to ALL clients. `false` disables only the + * client_secret presence check; a sent secret must still be verified in + * model.getClient. Grants without an entry follow the client `type`. */ requireClientAuthentication?: Record; @@ -416,6 +416,7 @@ declare namespace OAuth2Server { grants: string | string[]; accessTokenLifetime?: number; refreshTokenLifetime?: number; + type?: 'public' | 'confidential'; [key: string]: any; } diff --git a/lib/handlers/token-handler.js b/lib/handlers/token-handler.js index 895ef9c8..eaa435df 100644 --- a/lib/handlers/token-handler.js +++ b/lib/handlers/token-handler.js @@ -127,7 +127,7 @@ class TokenHandler { throw new InvalidRequestError('Missing parameter: `client_id`'); } - if (this.isClientAuthenticationRequired(grantType) && !credentials.clientSecret && !isPkce) { + if (!credentials.clientSecret && !this.isClientSecretOptional(grantType, isPkce)) { throw new InvalidRequestError('Missing parameter: `client_secret`'); } @@ -140,7 +140,7 @@ class TokenHandler { } try { - const client = await this.model.getClient(credentials.clientId, credentials.clientSecret); + const client = await this.model.getClient(credentials.clientId, credentials.clientSecret || null); if (!client) { throw new InvalidClientError('Invalid client: client is invalid'); @@ -154,6 +154,14 @@ class TokenHandler { throw new ServerError('Server error: `grants` must be an array'); } + if (client.type !== undefined && client.type !== null && !['public', 'confidential'].includes(client.type)) { + throw new ServerError('Server error: `type` must be "public" or "confidential"'); + } + + if (!credentials.clientSecret && this.isClientAuthenticationRequired(grantType, client, isPkce)) { + throw new InvalidClientError('Invalid client: client is invalid'); + } + return client; } catch (e) { // Include the "WWW-Authenticate" response header field if the client @@ -182,6 +190,7 @@ class TokenHandler { const credentials = auth(request); const grantType = request.body.grant_type; const codeVerifier = request.body.code_verifier; + const isPkce = pkce.isPKCERequest({ grantType, codeVerifier }); if (credentials) { return { clientId: credentials.name, clientSecret: credentials.pass }; @@ -194,16 +203,8 @@ class TokenHandler { }; } - if (pkce.isPKCERequest({ grantType, codeVerifier })) { - if (request.body.client_id) { - return { clientId: request.body.client_id }; - } - } - - if (!this.isClientAuthenticationRequired(grantType)) { - if (request.body.client_id) { - return { clientId: request.body.client_id }; - } + if (request.body.client_id && this.isClientSecretOptional(grantType, isPkce)) { + return { clientId: request.body.client_id }; } throw new InvalidClientError('Invalid client: cannot retrieve client credentials'); @@ -232,6 +233,10 @@ class TokenHandler { throw new UnauthorizedClientError('Unauthorized client: `grant_type` is invalid'); } + if (client.type === 'public' && grantType === 'client_credentials') { + throw new UnauthorizedClientError('Unauthorized client: `grant_type` is invalid'); + } + const accessTokenLifetime = this.getAccessTokenLifetime(client); const refreshTokenLifetime = this.getRefreshTokenLifetime(client); const Type = this.grantTypes[grantType]; @@ -242,7 +247,7 @@ class TokenHandler { refreshTokenLifetime: refreshTokenLifetime, alwaysIssueNewRefreshToken: this.alwaysIssueNewRefreshToken, enablePlainPKCE: this.enablePlainPKCE === true, - requirePKCE: this.requirePKCE === true, + requirePKCE: this.requirePKCE === true || client.type === 'public', }; return new Type(options).handle(request, client); @@ -309,16 +314,40 @@ class TokenHandler { } /** - * Given a grant type, check if client authentication is required + * Given a grant type, client and pkce flag, check if client authentication is required. + * + * An explicit `requireClientAuthentication` entry for the grant type always wins. + * Otherwise the client type decides: + * - `public`: no secret for `refresh_token`; `authorization_code` requires PKCE instead + * - `confidential` or no type: secret always required, PKCE never waives it + */ + isClientAuthenticationRequired(grantType, client, isPkce) { + const explicit = this.requireClientAuthentication[grantType]; + + if (typeof explicit !== 'undefined') { + return explicit; + } + + if (client && client.type === 'public') { + if (grantType === 'authorization_code') return !isPkce; + if (grantType === 'refresh_token') return false; + } + + return true; + } + + /** + * Given a grant type and pkce flag, check if a request without a client secret could be accepted + * for any client type. */ - isClientAuthenticationRequired(grantType) { - if (Object.keys(this.requireClientAuthentication).length > 0) { - return typeof this.requireClientAuthentication[grantType] !== 'undefined' - ? this.requireClientAuthentication[grantType] - : true; - } else { - return true; + isClientSecretOptional(grantType, isPkce) { + const explicit = this.requireClientAuthentication[grantType]; + + if (typeof explicit !== 'undefined') { + return !explicit; } + + return isPkce || grantType === 'refresh_token'; } } diff --git a/lib/model.js b/lib/model.js index cbf4e843..8fb06205 100644 --- a/lib/model.js +++ b/lib/model.js @@ -46,6 +46,8 @@ const ServerError = require('./errors/server-error'); * @property grants {string[]} Grant types allowed for the client. * @property accessTokenLifetime {number} Client-specific lifetime of generated access tokens in seconds. * @property refreshTokenLifetime {number} Client-specific lifetime of generated refresh tokens in seconds. + * @property type {string} The client type: `public` or `confidential`. Optional. When not set (`undefined` or `null`), + * the client is treated as confidential. Any other value throws a `ServerError`. */ /** @@ -97,12 +99,13 @@ class Model { * - `password` grant * * **Remarks:** - * `clientSecret` is `null`/absent for public clients, and also for - * `authorization_code` requests that authenticate with PKCE (a `code_verifier`) - * instead of a secret — even if the client *has* a `client_secret`. PKCE is - * **not** a substitute for client authentication: your implementation must - * reject (return a falsy value) a confidential client that should have - * presented its `client_secret` but did not. + * `clientSecret` is `null` when the request does not contain a secret. The + * library decides afterwards whether a secret was required, based on the + * returned client's `type` and the `requireClientAuthentication` option: + * a `confidential` client, or a client without `type`, is rejected without + * a secret, even with PKCE. When a `clientSecret` is provided, your implementation must + * always verify it, also for public clients, and return a falsy value if it + * does not match. * * @async * @param clientId {string} The client id of the client to retrieve. diff --git a/lib/server.js b/lib/server.js index 5fd70b65..04100be0 100644 --- a/lib/server.js +++ b/lib/server.js @@ -40,7 +40,7 @@ class OAuth2Server { * @param [options.accessTokenLifetime=3600] {number=} Lifetime of generated access tokens in seconds (default = 1 hour). * @param [options.refreshTokenLifetime=1209600] {number=} Lifetime of generated refresh tokens in seconds (default = 2 weeks). * @param [options.allowExtendedTokenAttributes=false] {boolean=} Allow extended attributes to be set on the returned token (see remarks section). - * @param [options.requireClientAuthentication=object] {object|boolean} Require a client secret for grant types (names as keys). Defaults to `true` for all grant types. + * @param [options.requireClientAuthentication=object] {object|boolean} Require a client secret for grant types (names as keys). An entry takes precedence over the client `type`; grants without an entry follow the client `type` (see remarks section). * @param [options.alwaysIssueNewRefreshToken=true] {boolean=} Always revoke the used refresh token and issue a new one for the `refresh_token` grant. * @param [options.extendedGrantTypes=object] {object} Additional supported grant types. * @param [options.enablePlainPKCE=false] {boolean} Allow the use of the `plain` code challenge method for PKCE. This is not recommended for production environments. @@ -184,7 +184,13 @@ class OAuth2Server { * Retrieves a new token for an authorized token request. * **Remarks:** * If `options.allowExtendedTokenAttributes` is `true` any additional properties set on the object returned from `Model#saveToken() ` are copied to the token response sent to the client. - * By default, all grant types require the client to send it's `client_secret` with the token request. `options.requireClientAuthentication` can be used to disable this check for selected grants. If used, this server option must be an object containing properties set to `true` or `false`. Possible keys for the object include all supported values for the token request's `grant_type` field (`authorization_code`, `client_credentials`, `password` and `refresh_token`). Grants that are not specified default to `true` which enables verification of the `client_secret`. Note: setting a grant to `false` disables only the *presence* check of the `client_secret` for that grant, and does so for **all** clients (not just public ones); it does not validate a secret that is sent. The same applies to `authorization_code` requests that use PKCE (a `code_verifier`), where the presence check is skipped. Per-client (public vs confidential) authentication must therefore be enforced in your `Model#getClient() ` implementation, which should reject a confidential client that fails to present its `client_secret`. + * By default, all grant types require the client to send its `client_secret` with the token request. `options.requireClientAuthentication` overrides this per grant type. + * It must be an object with `grant_type` values as keys (`authorization_code`, `client_credentials`, `password` and `refresh_token`) set to `true` or `false`. + * An entry takes precedence over the client `type` returned by `Model#getClient() ` and applies to all clients. + * `false` disables only the *presence* check of the `client_secret`; a secret that is sent is still passed to `Model#getClient()`, which must verify it. + * Grants without an entry follow the client `type`: `confidential` clients and clients without `type` always require a secret, even with PKCE. `public` clients may use `refresh_token` without a secret. + * Public clients must use PKCE for `authorization_code`. The `client_credentials` grant type cannot be used with public clients. + * Any other `type` value throws a `ServerError`. See the PKCE guide for details. * ```js * let options = { * // ... @@ -209,7 +215,7 @@ class OAuth2Server { * @param [options.accessTokenLifetime=3600] {number=} Lifetime of generated access tokens in seconds (default = 1 hour). * @param [options.refreshTokenLifetime=1209600] {number=} Lifetime of generated refresh tokens in seconds (default = 2 weeks). * @param [options.allowExtendedTokenAttributes=false] {boolean=} Allow extended attributes to be set on the returned token (see remarks section). - * @param [options.requireClientAuthentication=object] {object|boolean} Require a client secret for grant types (names as keys). Defaults to `true` for all grant types. + * @param [options.requireClientAuthentication=object] {object|boolean} Require a client secret for grant types (names as keys). An entry takes precedence over the client `type`; grants without an entry follow the client `type` (see remarks section). * @param [options.alwaysIssueNewRefreshToken=true] {boolean=} Always revoke the used refresh token and issue a new one for the `refresh_token` grant. * @param [options.extendedGrantTypes=object] {object} Additional supported grant types. * @return {Promise.} A `Promise` that resolves to the token object returned from the model's `saveToken` method. diff --git a/test/integration/handlers/token-handler_test.js b/test/integration/handlers/token-handler_test.js index 4fc52ca0..819dc325 100644 --- a/test/integration/handlers/token-handler_test.js +++ b/test/integration/handlers/token-handler_test.js @@ -617,6 +617,350 @@ describe('TokenHandler integration', function () { }) .catch(should.fail); }); + + describe('with client types', function () { + const grants = ['authorization_code', 'client_credentials', 'refresh_token']; + const clients = { + public: { id: 'public', type: 'public', grants }, + // a public client whose model accepts a secret + publicWithSecret: { id: 'publicWithSecret', type: 'public', secret: 'secret', grants }, + other: { id: 'other', type: 'public', grants }, + confidential: { id: 'confidential', type: 'confidential', secret: 'secret', grants }, + legacy: { id: 'legacy', secret: 'secret', grants }, + }; + const codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + const codeChallenge = stringUtil.base64URLEncode(crypto.createHash('sha256').update(codeVerifier).digest()); + + let getClientCalls = 0; + + // codes and refresh tokens are named after the client they were issued to: `code-`, `token-`. + // `bare-` codes were issued without a `code_challenge`. + const model = Model.from({ + getClient: function (clientId, clientSecret) { + getClientCalls++; + const client = clients[clientId]; + if (!client) return null; + // a provided secret must always match, also for public clients + if (clientSecret !== null && client.secret !== clientSecret) return null; + return { id: client.id, grants: client.grants, type: client.type }; + }, + getAuthorizationCode: function (code) { + if (code.startsWith('bare-')) { + return { + authorizationCode: code, + client: { id: code.replace('bare-', '') }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + }; + } + + return { + authorizationCode: code, + client: { id: code.replace('code-', '') }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + codeChallengeMethod: 'S256', + codeChallenge: codeChallenge, + }; + }, + revokeAuthorizationCode: function () { + return true; + }, + getRefreshToken: function (refreshToken) { + return { refreshToken: refreshToken, client: { id: refreshToken.replace('token-', '') }, user: {} }; + }, + revokeToken: function () { + return true; + }, + getUserFromClient: function () { + return {}; + }, + saveToken: function (token, client, user) { + return Object.assign({}, token, { client: client, user: user }); + }, + }); + + const requestToken = async function (body, { headers, requireClientAuthentication } = {}) { + const handler = new TokenHandler({ + accessTokenLifetime: 120, + model: model, + refreshTokenLifetime: 120, + requireClientAuthentication: requireClientAuthentication, + }); + const request = new Request({ + body: body, + headers: Object.assign( + { 'content-type': 'application/x-www-form-urlencoded', 'transfer-encoding': 'chunked' }, + headers, + ), + method: 'POST', + query: {}, + }); + const response = new Response({ body: {}, headers: {} }); + + getClientCalls = 0; + await handler.handle(request, response); + + return response; + }; + + const shouldFail = async function (promise, ErrorType, message) { + try { + await promise; + } catch (e) { + e.should.be.an.instanceOf(ErrorType); + e.message.should.equal(message); + return e; + } + + should.fail(); + }; + + const authorizationCode = function (clientId, extra) { + return Object.assign( + { + grant_type: 'authorization_code', + client_id: clientId, + code: `code-${clientId}`, + code_verifier: codeVerifier, + }, + extra, + ); + }; + + const refreshToken = function (clientId, extra) { + return Object.assign( + { grant_type: 'refresh_token', client_id: clientId, refresh_token: `token-${clientId}` }, + extra, + ); + }; + + const clientCredentials = function (clientId, extra) { + return Object.assign({ grant_type: 'client_credentials', client_id: clientId }, extra); + }; + + const passwordGrant = function (clientId, extra) { + return Object.assign({ grant_type: 'password', client_id: clientId, username: 'foo', password: 'bar' }, extra); + }; + + const invalidClient = 'Invalid client: client is invalid'; + const missingSecret = 'Invalid client: cannot retrieve client credentials'; + + // rejected before `model.getClient()` is called + const shouldFailEarly = async function (promise) { + await shouldFail(promise, InvalidClientError, missingSecret); + getClientCalls.should.equal(0); + }; + + describe('with a public client', function () { + it('should return a token for `authorization_code` with a valid S256 code verifier and no secret', async function () { + const response = await requestToken(authorizationCode('public')); + + should.exist(response.body.access_token); + response.body.token_type.should.equal('Bearer'); + }); + + it('should throw an error for `authorization_code` without a code verifier', async function () { + await shouldFailEarly(requestToken(authorizationCode('public', { code_verifier: undefined }))); + }); + + it('should throw an error for a code issued without a `code_challenge` if it supplies a secret', async function () { + await shouldFail( + requestToken( + authorizationCode('publicWithSecret', { + code: 'bare-publicWithSecret', + code_verifier: undefined, + client_secret: 'secret', + }), + ), + InvalidGrantError, + 'Invalid grant: authorization code was issued without a `code_challenge`', + ); + }); + + it('should throw an error for a code issued without a `code_challenge` if `requireClientAuthentication` is false for `authorization_code`', async function () { + await shouldFail( + requestToken(authorizationCode('public', { code: 'bare-public', code_verifier: undefined }), { + requireClientAuthentication: { authorization_code: false }, + }), + InvalidGrantError, + 'Invalid grant: authorization code was issued without a `code_challenge`', + ); + }); + + it('should throw an error for `authorization_code` with an invalid code verifier', async function () { + await shouldFail( + requestToken( + authorizationCode('public', { code_verifier: stringUtil.base64URLEncode(crypto.randomBytes(32)) }), + ), + InvalidGrantError, + 'Invalid grant: code verifier is invalid', + ); + }); + + it('should return a token for `refresh_token` without a secret', async function () { + const response = await requestToken(refreshToken('public')); + + should.exist(response.body.access_token); + }); + + it('should throw an error for `client_credentials` without a secret', async function () { + await shouldFailEarly(requestToken(clientCredentials('public'))); + }); + + it('should throw an error for `client_credentials` even if `requireClientAuthentication` is false for it', async function () { + await shouldFail( + requestToken(clientCredentials('public'), { requireClientAuthentication: { client_credentials: false } }), + UnauthorizedClientError, + 'Unauthorized client: `grant_type` is invalid', + ); + }); + + it('should throw an error if `requireClientAuthentication` is true for `authorization_code`', async function () { + await shouldFailEarly( + requestToken(authorizationCode('public'), { requireClientAuthentication: { authorization_code: true } }), + ); + }); + + it('should throw an error if `requireClientAuthentication` is true for `refresh_token`', async function () { + await shouldFailEarly( + requestToken(refreshToken('public'), { requireClientAuthentication: { refresh_token: true } }), + ); + }); + + it('should throw an error if it supplies a `client_secret` in the body', async function () { + await shouldFail( + requestToken(authorizationCode('public', { client_secret: 'wrong' })), + InvalidClientError, + invalidClient, + ); + }); + + it('should throw a 401 error if it supplies credentials in the Authorization header', async function () { + const e = await shouldFail( + requestToken(authorizationCode('public', { client_id: undefined }), { + headers: { authorization: util.format('Basic %s', Buffer.from('public:wrong').toString('base64')) }, + }), + InvalidClientError, + invalidClient, + ); + + e.code.should.equal(401); + }); + + it('should throw an error for an authorization code issued to another client', async function () { + await shouldFail( + requestToken(authorizationCode('public', { code: 'code-other' })), + InvalidGrantError, + 'Invalid grant: authorization code is invalid', + ); + }); + + it('should throw an error for a refresh token issued to another client', async function () { + await shouldFail( + requestToken(refreshToken('public', { refresh_token: 'token-other' })), + InvalidGrantError, + 'Invalid grant: refresh token was issued to another client', + ); + }); + }); + + describe('with a confidential client', function () { + it('should throw an error for `authorization_code` with a valid code verifier and no secret', async function () { + await shouldFail(requestToken(authorizationCode('confidential')), InvalidClientError, invalidClient); + }); + + it('should return a token for `authorization_code` with a valid code verifier and a secret', async function () { + const response = await requestToken(authorizationCode('confidential', { client_secret: 'secret' })); + + should.exist(response.body.access_token); + }); + + it('should throw an error for `authorization_code` with a valid code verifier and a wrong secret', async function () { + await shouldFail( + requestToken(authorizationCode('confidential', { client_secret: 'wrong' })), + InvalidClientError, + invalidClient, + ); + }); + + it('should throw an error for `refresh_token` without a secret', async function () { + await shouldFail(requestToken(refreshToken('confidential')), InvalidClientError, invalidClient); + }); + + it('should return a token for `refresh_token` without a secret if `requireClientAuthentication` is false for it', async function () { + const response = await requestToken(refreshToken('confidential'), { + requireClientAuthentication: { refresh_token: false }, + }); + + should.exist(response.body.access_token); + }); + + it('should return a token for `client_credentials` with a secret', async function () { + const response = await requestToken(clientCredentials('confidential', { client_secret: 'secret' })); + + should.exist(response.body.access_token); + }); + + it('should throw an error for an authorization code issued to another client, even with a valid secret', async function () { + await shouldFail( + requestToken(authorizationCode('confidential', { client_secret: 'secret', code: 'code-other' })), + InvalidGrantError, + 'Invalid grant: authorization code is invalid', + ); + }); + }); + + describe('with a client without `type` (treated as confidential)', function () { + it('should throw an error for `authorization_code` with a valid code verifier and no secret', async function () { + await shouldFail(requestToken(authorizationCode('legacy')), InvalidClientError, invalidClient); + }); + + it('should return a token for `authorization_code` with a valid code verifier and a secret', async function () { + const response = await requestToken(authorizationCode('legacy', { client_secret: 'secret' })); + + should.exist(response.body.access_token); + }); + + it('should return a token for `authorization_code` with a valid code verifier and no secret if `requireClientAuthentication` is false for it', async function () { + const response = await requestToken(authorizationCode('legacy'), { + requireClientAuthentication: { authorization_code: false }, + }); + + should.exist(response.body.access_token); + }); + + it('should throw an error for `authorization_code` without a code verifier and no secret', async function () { + await shouldFailEarly(requestToken(authorizationCode('legacy', { code_verifier: undefined }))); + }); + + it('should throw an error for `password` without a secret', async function () { + await shouldFailEarly(requestToken(passwordGrant('legacy'))); + }); + + it('should throw an error for `client_credentials` without a secret', async function () { + await shouldFailEarly(requestToken(clientCredentials('legacy'))); + }); + + it('should throw an error for `refresh_token` without a secret', async function () { + await shouldFail(requestToken(refreshToken('legacy')), InvalidClientError, invalidClient); + }); + + it('should throw an error for a wrong secret', async function () { + await shouldFail( + requestToken(refreshToken('legacy', { client_secret: 'wrong' })), + InvalidClientError, + invalidClient, + ); + }); + + it('should return a token for `client_credentials` with a secret', async function () { + const response = await requestToken(clientCredentials('legacy', { client_secret: 'secret' })); + + should.exist(response.body.access_token); + }); + }); + }); }); describe('getClient()', function () { @@ -816,6 +1160,198 @@ describe('TokenHandler integration', function () { .catch(should.fail); }); + describe('client authentication by client type', function () { + const getClient = function ({ client, body, requireClientAuthentication }) { + const model = Model.from({ + getClient: function () { + return client; + }, + saveToken: function () {}, + }); + const handler = new TokenHandler({ + accessTokenLifetime: 120, + model: model, + refreshTokenLifetime: 120, + requireClientAuthentication: requireClientAuthentication, + }); + const request = new Request({ body: body, headers: {}, method: {}, query: {} }); + + return handler.getClient(request); + }; + + const shouldReject = function (promise, message = 'Invalid client: client is invalid') { + return promise.then(should.fail).catch(function (e) { + e.should.be.an.instanceOf(InvalidClientError); + e.message.should.equal(message); + }); + }; + + // rejected before the model is called, the client type is unknown at that point + const shouldRejectEarly = function (promise) { + return shouldReject(promise, 'Invalid client: cannot retrieve client credentials'); + }; + + describe('with an unknown client `type`', function () { + ['Confidential', 'CONFIDENTIAL', '', 'native'].forEach(function (type) { + it(`should throw an error for type ${JSON.stringify(type)}`, function () { + const client = { id: 'foo', grants: ['authorization_code'], type }; + + return getClient({ + client, + body: { client_id: 'foo', grant_type: 'authorization_code', code_verifier: 'verifier' }, + }) + .then(should.fail) + .catch(function (e) { + e.should.be.an.instanceOf(ServerError); + e.message.should.equal('Server error: `type` must be "public" or "confidential"'); + }); + }); + }); + + it('should treat a `null` type as no type', function () { + const client = { id: 'foo', grants: ['authorization_code'], type: null }; + + return shouldReject( + getClient({ + client, + body: { client_id: 'foo', grant_type: 'authorization_code', code_verifier: 'verifier' }, + }), + ); + }); + }); + + describe('with a public client and no `client_secret`', function () { + it('should return the client for `authorization_code` with PKCE', function () { + const client = { id: 'foo', grants: ['authorization_code'], type: 'public' }; + + return getClient({ + client, + body: { client_id: 'foo', grant_type: 'authorization_code', code_verifier: 'verifier' }, + }) + .then(function (data) { + data.should.equal(client); + }) + .catch(should.fail); + }); + + it('should throw an error for `authorization_code` without PKCE', function () { + const client = { id: 'foo', grants: ['authorization_code'], type: 'public' }; + + return shouldRejectEarly(getClient({ client, body: { client_id: 'foo', grant_type: 'authorization_code' } })); + }); + + it('should return the client for `refresh_token`', function () { + const client = { id: 'foo', grants: ['refresh_token'], type: 'public' }; + + return getClient({ client, body: { client_id: 'foo', grant_type: 'refresh_token' } }) + .then(function (data) { + data.should.equal(client); + }) + .catch(should.fail); + }); + + it('should throw an error for `client_credentials`', function () { + const client = { id: 'foo', grants: ['client_credentials'], type: 'public' }; + + return shouldRejectEarly(getClient({ client, body: { client_id: 'foo', grant_type: 'client_credentials' } })); + }); + + it('should throw an error if `requireClientAuthentication` is explicitly true for the grant', function () { + const client = { id: 'foo', grants: ['authorization_code'], type: 'public' }; + + return shouldRejectEarly( + getClient({ + client, + body: { client_id: 'foo', grant_type: 'authorization_code', code_verifier: 'verifier' }, + requireClientAuthentication: { authorization_code: true }, + }), + ); + }); + }); + + describe('with a confidential client and no `client_secret`', function () { + it('should throw an error', function () { + const client = { id: 'foo', grants: ['authorization_code'], type: 'confidential' }; + + return shouldRejectEarly(getClient({ client, body: { client_id: 'foo', grant_type: 'authorization_code' } })); + }); + + it('should throw an error for `authorization_code` with PKCE', function () { + const client = { id: 'foo', grants: ['authorization_code'], type: 'confidential' }; + + return shouldReject( + getClient({ + client, + body: { client_id: 'foo', grant_type: 'authorization_code', code_verifier: 'verifier' }, + }), + ); + }); + + it('should return the client if `requireClientAuthentication` is explicitly false for the grant', function () { + const client = { id: 'foo', grants: ['refresh_token'], type: 'confidential' }; + + return getClient({ + client, + body: { client_id: 'foo', grant_type: 'refresh_token' }, + requireClientAuthentication: { refresh_token: false }, + }) + .then(function (data) { + data.should.equal(client); + }) + .catch(should.fail); + }); + }); + + describe('with a client without `type` (treated as confidential) and no `client_secret`', function () { + it('should throw an error', function () { + const client = { id: 'foo', grants: ['password'] }; + + return shouldRejectEarly(getClient({ client, body: { client_id: 'foo', grant_type: 'password' } })); + }); + + it('should throw an error before calling the model if the Authorization header has no secret', function () { + const model = Model.from({ + getClient: function () { + should.fail(); + }, + saveToken: function () {}, + }); + const handler = new TokenHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + const request = new Request({ + body: { grant_type: 'password' }, + headers: { authorization: util.format('Basic %s', Buffer.from('foo:').toString('base64')) }, + method: {}, + query: {}, + }); + + return handler + .getClient(request, new Response({ body: {}, headers: {} })) + .then(should.fail) + .catch(function (e) { + e.should.be.an.instanceOf(InvalidRequestError); + e.message.should.equal('Missing parameter: `client_secret`'); + }); + }); + + it('should throw an error for `refresh_token`', function () { + const client = { id: 'foo', grants: ['refresh_token'] }; + + return shouldReject(getClient({ client, body: { client_id: 'foo', grant_type: 'refresh_token' } })); + }); + + it('should throw an error for `authorization_code` with PKCE', function () { + const client = { id: 'foo', grants: ['authorization_code'] }; + + return shouldReject( + getClient({ + client, + body: { client_id: 'foo', grant_type: 'authorization_code', code_verifier: 'verifier' }, + }), + ); + }); + }); + }); + describe('with `password` grant type and `requireClientAuthentication` is false', function () { it('should return a client ', function () { const client = { id: 12345, grants: [] }; @@ -959,7 +1495,28 @@ describe('TokenHandler integration', function () { } }); - it('should throw an error if `client_secret` is missing', async function () { + it('should return credentials with only `clientId` when `client_secret` is missing', function () { + const model = Model.from({ + getClient: function () {}, + saveToken: function () {}, + }); + const handler = new TokenHandler({ + accessTokenLifetime: 120, + model: model, + refreshTokenLifetime: 120, + }); + const request = new Request({ + body: { client_id: 'foo', grant_type: 'refresh_token' }, + headers: {}, + method: {}, + query: {}, + }); + + const credentials = handler.getClientCredentials(request); + credentials.should.eql({ clientId: 'foo' }); + }); + + it('should throw an error if `client_secret` is missing and no client could be accepted without it', function () { const model = Model.from({ getClient: function () {}, saveToken: function () {}, @@ -970,14 +1527,14 @@ describe('TokenHandler integration', function () { refreshTokenLifetime: 120, }); const request = new Request({ - body: { client_id: 'foo' }, + body: { client_id: 'foo', grant_type: 'password' }, headers: {}, method: {}, query: {}, }); try { - await handler.getClientCredentials(request); + handler.getClientCredentials(request); should.fail(); } catch (e) { @@ -1169,6 +1726,37 @@ describe('TokenHandler integration', function () { } }); + it('should throw an error if a public client uses `client_credentials`', async function () { + const client = { grants: ['client_credentials'], type: 'public' }; + const model = Model.from({ + getClient: function () {}, + getUserFromClient: function () { + return {}; + }, + saveToken: function () {}, + }); + const handler = new TokenHandler({ + accessTokenLifetime: 120, + model: model, + refreshTokenLifetime: 120, + }); + const request = new Request({ + body: { grant_type: 'client_credentials' }, + headers: {}, + method: {}, + query: {}, + }); + + try { + await handler.handleGrantType(request, client); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(UnauthorizedClientError); + e.message.should.equal('Unauthorized client: `grant_type` is invalid'); + } + }); + it('should throw an invalid grant error if a non-oauth error is thrown', function () { const client = { grants: ['password'] }; const model = Model.from({ diff --git a/test/unit/handlers/token-handler_test.js b/test/unit/handlers/token-handler_test.js index 89b30fb2..caf26dd3 100644 --- a/test/unit/handlers/token-handler_test.js +++ b/test/unit/handlers/token-handler_test.js @@ -16,7 +16,7 @@ const should = require('chai').should(); describe('TokenHandler', function () { describe('getClient()', function () { - it('should call `model.getClient()`', function () { + it('should call `model.getClient()` with the provided secret', function () { const model = Model.from({ getClient: sinon.stub().returns({ grants: ['password'] }), saveToken: function () {}, @@ -44,5 +44,32 @@ describe('TokenHandler', function () { }) .catch(should.fail); }); + + it('should call `model.getClient()` when no client secret is provided (public client)', function () { + const model = Model.from({ + getClient: sinon.stub().returns({ grants: ['authorization_code'], type: 'public' }), + saveToken: function () {}, + }); + const handler = new TokenHandler({ + accessTokenLifetime: 120, + model: model, + refreshTokenLifetime: 120, + }); + const request = new Request({ + body: { client_id: 'foo', grant_type: 'authorization_code', code_verifier: 'verifier' }, + headers: {}, + method: {}, + query: {}, + }); + + return handler + .getClient(request) + .then(function () { + model.getClient.callCount.should.equal(1); + model.getClient.firstCall.args[0].should.equal('foo'); + should.equal(model.getClient.firstCall.args[1], null); + }) + .catch(should.fail); + }); }); });