Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Instantiates `OAuth2Server` using the supplied model.
| [options.alwaysIssueNewRefreshToken] | <code>boolean</code> | <code>true</code> | Always revoke the used refresh token and issue a new one for the `refresh_token` grant. |
| [options.extendedGrantTypes] | <code>object</code> | <code>object</code> | Additional supported grant types. |
| [options.enablePlainPKCE] | <code>boolean</code> | <code>false</code> | Allow the use of the `plain` code challenge method for PKCE. This is not recommended for production environments. |
| [options.requirePKCE] | <code>boolean</code> | <code>false</code> | 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
Expand Down
33 changes: 33 additions & 0 deletions docs/guide/pkce.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<div id="PKCE#authorizationRequest">
Expand Down
10 changes: 10 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -243,6 +248,11 @@ declare namespace OAuth2Server {
* Additional supported grant types.
*/
extendedGrantTypes?: Record<string, typeof AbstractGrantType>;

/**
* Require PKCE for the authorization code grant: reject token exchanges for codes issued without a `code_challenge`. Recommended by OAuth 2.1.
*/
requirePKCE?: boolean;
}

/**
Expand Down
7 changes: 7 additions & 0 deletions lib/grant-types/authorization-code-grant-type.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -164,6 +166,11 @@ class AuthorizationCodeGrantType extends AbstractGrantType {
throw new InvalidGrantError('Invalid grant: code verifier is invalid');
}
} 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');
Expand Down
7 changes: 7 additions & 0 deletions lib/handlers/authorize-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -100,7 +101,13 @@ class AuthorizeHandler {

const ResponseType = this.getResponseType(request);
const codeChallenge = this.getCodeChallenge(request);

if (this.requirePKCE && !codeChallenge) {
throw new InvalidRequestError('Missing parameter: `code_challenge`');
}

const codeChallengeMethod = this.getCodeChallengeMethod(request);

const code = await this.saveAuthorizationCode(
authorizationCode,
expiresAt,
Expand Down
2 changes: 2 additions & 0 deletions lib/handlers/token-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -237,6 +238,7 @@ class TokenHandler {
refreshTokenLifetime: refreshTokenLifetime,
alwaysIssueNewRefreshToken: this.alwaysIssueNewRefreshToken,
enablePlainPKCE: this.enablePlainPKCE === true,
requirePKCE: this.requirePKCE === true,
};

return new Type(options).handle(request, client);
Expand Down
1 change: 1 addition & 0 deletions lib/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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.
Expand Down
112 changes: 112 additions & 0 deletions test/compliance/pkce_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -675,4 +675,116 @@ 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('rejects a `code_challenge_method` without a `code_challenge` as a missing `code_challenge`', async function () {
// the missing-`code_challenge` error must take precedence over method
// validation, so a request with an (otherwise invalid) method but no
// challenge reports the missing parameter, not a method error.
const server = requirePKCEServer();
const response = new Response({ headers: {} });
let error = null;
try {
await server.authorize(authorizeRequest({ code_challenge_method: 'plain' }), response, { authenticateHandler });
} catch (e) {
error = e;
}
(error !== null).should.equal(true);
error.should.be.an.instanceOf(InvalidRequestError);
error.message.should.equal('Missing parameter: `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`');
});
});
});
Loading