diff --git a/docs/api/server.md b/docs/api/server.md index 5286687..fdb4a36 100644 --- a/docs/api/server.md +++ b/docs/api/server.md @@ -44,6 +44,7 @@ Instantiates `OAuth2Server` using the supplied model. | [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. | +| [options.requirePKCE] | boolean | false | Require PKCE for the `authorization_code` grant: `authorize` rejects requests without a `code_challenge`, and the token exchange rejects authorization codes that were issued without one. Recommended by OAuth 2.1. | **Example** ```js diff --git a/docs/guide/pkce.md b/docs/guide/pkce.md index 3eace68..f216dfd 100644 --- a/docs/guide/pkce.md +++ b/docs/guide/pkce.md @@ -29,6 +29,39 @@ Figure 2: Abstract Protocol Flow See [Section 1 of RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636#section-1.1). +## Requiring PKCE + +By default PKCE is *optional*: the library verifies a `code_challenge` when one is +present (and enforces the +[RFC 7636 §4.6](https://datatracker.ietf.org/doc/html/rfc7636#section-4.6) +downgrade protection — a `code_verifier` with no stored challenge is rejected), +but a client may also complete the `authorization_code` flow without it. + +[OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) makes +PKCE **mandatory** for the authorization code grant — for all clients, public and +confidential — and +[RFC 9700 (OAuth 2.0 Security BCP) §2.1.1](https://www.rfc-editor.org/rfc/rfc9700#section-2.1.1) +recommends that authorization servers require it, partly to defend against PKCE +*downgrade* attacks. To enforce this, enable `requirePKCE`: + +```js +const server = new OAuth2Server({ + model, + requirePKCE: true +}) +``` + +When enabled: + +- the **authorization** endpoint rejects requests without a `code_challenge` + (`invalid_request`), so no PKCE-less codes are ever issued; and +- the **token** endpoint rejects authorization codes that were issued without a + `code_challenge` (`invalid_grant`) — covering codes minted before the option + was turned on, or through another path. + +`requirePKCE` defaults to `false` to preserve backwards compatibility. It is a +strong candidate to become the default in a future major release. + ## 1. Authorization request
diff --git a/index.d.ts b/index.d.ts index e67540b..920b47a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -208,6 +208,11 @@ declare namespace OAuth2Server { * Lifetime of generated authorization codes in seconds (default = 5 minutes). */ authorizationCodeLifetime?: number; + + /** + * Require PKCE for the authorization code grant: reject authorize requests without a `code_challenge`. Recommended by OAuth 2.1. + */ + requirePKCE?: boolean; } interface TokenOptions { @@ -240,6 +245,11 @@ declare namespace OAuth2Server { * Additional supported grant types. */ extendedGrantTypes?: Record; + + /** + * Require PKCE for the authorization code grant: reject token exchanges for codes issued without a `code_challenge`. Recommended by OAuth 2.1. + */ + requirePKCE?: boolean; } /** diff --git a/lib/grant-types/authorization-code-grant-type.js b/lib/grant-types/authorization-code-grant-type.js index 56df29e..6e93470 100644 --- a/lib/grant-types/authorization-code-grant-type.js +++ b/lib/grant-types/authorization-code-grant-type.js @@ -42,6 +42,8 @@ class AuthorizationCodeGrantType extends AbstractGrantType { // xxx: plain PKCE is only allowed if explicitly enabled this.enablePlainPKCE = options.enablePlainPKCE === true; + // when enabled, the authorization code grant requires PKCE + this.requirePKCE = options.requirePKCE === true; } /** @@ -165,6 +167,11 @@ class AuthorizationCodeGrantType extends AbstractGrantType { } } else { + if (this.requirePKCE) { + // PKCE is required, but the authorization code was not associated with a `code_challenge`. + throw new InvalidGrantError('Invalid grant: authorization code was issued without a `code_challenge`'); + } + if (request.body.code_verifier) { // No code challenge but code_verifier was passed in. throw new InvalidGrantError('Invalid grant: code verifier is invalid'); diff --git a/lib/handlers/authorize-handler.js b/lib/handlers/authorize-handler.js index c5f0db1..71c9623 100644 --- a/lib/handlers/authorize-handler.js +++ b/lib/handlers/authorize-handler.js @@ -63,6 +63,7 @@ class AuthorizeHandler { this.authenticateHandler = options.authenticateHandler || new AuthenticateHandler(options); this.authorizationCodeLifetime = options.authorizationCodeLifetime; this.enablePlainPKCE = options.enablePlainPKCE === true; + this.requirePKCE = options.requirePKCE === true; this.model = options.model; } @@ -101,6 +102,11 @@ class AuthorizeHandler { const ResponseType = this.getResponseType(request); const codeChallenge = this.getCodeChallenge(request); const codeChallengeMethod = this.getCodeChallengeMethod(request); + + if (this.requirePKCE && !codeChallenge) { + throw new InvalidRequestError('Missing parameter: `code_challenge`'); + } + const code = await this.saveAuthorizationCode( authorizationCode, expiresAt, diff --git a/lib/handlers/token-handler.js b/lib/handlers/token-handler.js index 3b98307..650bf48 100644 --- a/lib/handlers/token-handler.js +++ b/lib/handlers/token-handler.js @@ -62,6 +62,7 @@ class TokenHandler { this.requireClientAuthentication = options.requireClientAuthentication || {}; this.alwaysIssueNewRefreshToken = options.alwaysIssueNewRefreshToken !== false; this.enablePlainPKCE = options.enablePlainPKCE === true; + this.requirePKCE = options.requirePKCE === true; } /** @@ -231,7 +232,8 @@ class TokenHandler { model: this.model, refreshTokenLifetime: refreshTokenLifetime, alwaysIssueNewRefreshToken: this.alwaysIssueNewRefreshToken, - enablePlainPKCE: this.enablePlainPKCE === true + enablePlainPKCE: this.enablePlainPKCE === true, + requirePKCE: this.requirePKCE === true }; return new Type(options).handle(request, client); diff --git a/lib/server.js b/lib/server.js index 320ddea..f5a5a32 100644 --- a/lib/server.js +++ b/lib/server.js @@ -45,6 +45,7 @@ class OAuth2Server { * @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. + * @param [options.requirePKCE=false] {boolean} Require PKCE for the `authorization_code` grant: `authorize` rejects requests without a `code_challenge`, and the token exchange rejects authorization codes that were issued without one. Recommended by OAuth 2.1. * * @throws {InvalidArgumentError} if the model is missing * @return {OAuth2Server} A new `OAuth2Server` instance. diff --git a/test/compliance/pkce_test.js b/test/compliance/pkce_test.js index fa46b8f..a1bd94d 100644 --- a/test/compliance/pkce_test.js +++ b/test/compliance/pkce_test.js @@ -671,4 +671,99 @@ describe('PKCE Compliance (RFC 7636)', function () { } }); }); + + // ================================================================== + // requirePKCE option (OAuth 2.1 / RFC 9700 §2.1.1) + // + // When `requirePKCE` is enabled, the authorization_code grant must use + // PKCE: the authorize endpoint rejects requests without a + // `code_challenge`, and the token endpoint rejects authorization codes + // that were issued without one. + // ================================================================== + describe('requirePKCE option', function () { + function pkceModel () { + const baseModel = createModel(db); + return { + ...baseModel, + getAuthorizationCode: async (authorizationCode) => db.authorizationCodes.get(authorizationCode) || null, + saveAuthorizationCode: async (code, client, user) => { + const doc = { ...code, client, user }; + db.authorizationCodes.set(code.authorizationCode, doc); + return doc; + }, + revokeAuthorizationCode: async (code) => db.authorizationCodes.delete(code.authorizationCode), + validateScope: async (user, client, scope) => scope + }; + } + + function requirePKCEServer () { + return new OAuth2Server({ requirePKCE: true, authorizationCodeLifetime: 300, model: pkceModel() }); + } + + function authorizeRequest (extraQuery = {}) { + return createRequest({ + method: 'GET', + query: { + response_type: 'code', + client_id: clientDoc.id, + redirect_uri: clientDoc.redirectUris[0], + state: 'teststate', + scope: 'read', + ...extraQuery + } + }); + } + + const authenticateHandler = { handle: () => userDoc }; + + it('rejects an authorize request without a `code_challenge`', async function () { + const server = requirePKCEServer(); + const response = new Response({ headers: {} }); + let error = null; + try { + await server.authorize(authorizeRequest(), response, { authenticateHandler }); + } catch (e) { + error = e; + } + (error !== null).should.equal(true); + error.should.be.an.instanceOf(InvalidRequestError); + error.message.should.match(/code_challenge/); + }); + + it('allows an authorize request that includes a `code_challenge`', async function () { + const server = requirePKCEServer(); + const response = new Response({ headers: {} }); + const challenge = computeS256Challenge('a'.repeat(43)); + const code = await server.authorize( + authorizeRequest({ code_challenge: challenge, code_challenge_method: 'S256' }), + response, + { authenticateHandler } + ); + code.codeChallenge.should.equal(challenge); + }); + + it('rejects a token exchange for a code issued without a `code_challenge`', async function () { + const server = requirePKCEServer(); + const codeValue = 'no-pkce-code-' + Math.random().toString(36).slice(2); + db.authorizationCodes.set(codeValue, { + authorizationCode: codeValue, + expiresAt: new Date(Date.now() + 60000), + redirectUri: 'https://client.example/callback', + client: clientDoc, + user: userDoc, + scope: ['read'] + // intentionally no codeChallenge + }); + const response = new Response(); + let error = null; + try { + await server.token(tokenRequest(codeValue), response); + } catch (e) { + error = e; + } + (error !== null).should.equal(true); + error.should.be.an.instanceOf(InvalidGrantError); + error.message.should.equal('Invalid grant: authorization code was issued without a `code_challenge`'); + }); + }); });