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 @@ -240,6 +245,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 @@ -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');
Expand Down
6 changes: 6 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 @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion 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 @@ -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);
Expand Down
1 change: 1 addition & 0 deletions lib/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
95 changes: 95 additions & 0 deletions test/compliance/pkce_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`');
});
});
});
Loading