Skip to content

Commit e15dc25

Browse files
dhensbyclaude
andcommitted
feat: add JWT bearer authorization grant (RFC 7523)
Add `JwtBearerGrantType`, an opt-in extension grant for the JWT bearer authorization grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, RFC 7521 §4.1 / RFC 7523 §2.1). A signed JWT is the grant: its `iss` names a trusted issuer (whose key verifies the assertion) and `sub` the principal the access token is issued for. The assertion is verified with jose (signature, audience, `exp`, required claims, algorithm-family pinning); `scope` comes from the request body and no refresh token is issued (RFC 7521 §5.2). Register it via `extendedGrantTypes`. The model implements `getJWTBearerIssuer(issuer)` (trusted issuer keys + expected audience) and `getJWTBearerUser({ issuer, subject, client, scope, jti, exp })` (resolve and authorize the subject; may enforce `jti` single-use). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2bb0b42 commit e15dc25

5 files changed

Lines changed: 754 additions & 0 deletions

File tree

index.d.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,14 @@ declare namespace OAuth2Server {
164164
abstract handle(request: Request, client: Client): Promise<Token | Falsey>;
165165
}
166166

167+
/**
168+
* The JWT bearer authorization grant (RFC 7523 §2.1). Register via
169+
* `extendedGrantTypes` under `urn:ietf:params:oauth:grant-type:jwt-bearer`.
170+
*/
171+
class JwtBearerGrantType extends AbstractGrantType {
172+
handle(request: Request, client: Client): Promise<Token | Falsey>;
173+
}
174+
167175
interface ServerOptions extends AuthenticateOptions, AuthorizeOptions, TokenOptions {
168176
/**
169177
* Model object
@@ -269,6 +277,40 @@ declare namespace OAuth2Server {
269277
presentedMethod(request: Request): string;
270278
}
271279

280+
/**
281+
* A trusted issuer for the JWT bearer authorization grant: the verification
282+
* key material plus the audience the assertion's `aud` claim must contain.
283+
*/
284+
interface JWTBearerIssuer {
285+
/** The value(s) the assertion's `aud` claim must contain (e.g. the token endpoint URL). */
286+
audience: string | string[];
287+
/** A JWK Set (RFC 7517) of the issuer's public keys (asymmetric assertions). */
288+
jwks?: object;
289+
/** A URL of the issuer's JWK Set, fetched and cached by the server (asymmetric assertions). */
290+
jwksUri?: string;
291+
/** A shared secret used as the HMAC key (HS* assertions). */
292+
secret?: string;
293+
[key: string]: any;
294+
}
295+
296+
/** Parameters passed to `getJWTBearerUser` for the JWT bearer grant. */
297+
interface JWTBearerUserParams {
298+
/** The verified assertion issuer (`iss`). */
299+
issuer: string;
300+
/** The verified assertion subject (`sub`) — the principal the token is for. */
301+
subject: string;
302+
/** The (resolved) client making the request. */
303+
client: Client;
304+
/** The requested scope (from the `scope` body parameter). */
305+
scope?: string[];
306+
/** The assertion `jti`, if present. */
307+
jti?: string;
308+
/** Stable single-use id: the `jti`, or a signing-input fingerprint when the assertion has no `jti`. */
309+
assertionId: string;
310+
/** The assertion `exp` (epoch seconds). */
311+
exp?: number;
312+
}
313+
272314
/**
273315
* For returning falsey parameters in cases of failure
274316
*/
@@ -304,6 +346,20 @@ declare namespace OAuth2Server {
304346
* for single-use replay protection.
305347
*/
306348
saveClientAssertionJti?(jti: string, exp?: number): Promise<void>;
349+
350+
/**
351+
* Required by the JWT bearer authorization grant (`JwtBearerGrantType`). Invoked to
352+
* resolve a trusted assertion issuer's verification keys and expected audience.
353+
* Return a falsy value to reject the issuer as untrusted.
354+
*/
355+
getJWTBearerIssuer?(issuer: string): Promise<JWTBearerIssuer | Falsey>;
356+
357+
/**
358+
* Required by the JWT bearer authorization grant (`JwtBearerGrantType`). Invoked to
359+
* resolve and authorize the principal (`sub`) the access token is issued for.
360+
* Return a falsy value to deny the grant.
361+
*/
362+
getJWTBearerUser?(params: JWTBearerUserParams): Promise<User | Falsey>;
307363
}
308364

309365
interface RequestAuthenticationModel {

index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ exports.Response = require('./lib/response');
1414

1515
exports.AbstractGrantType = require('./lib/grant-types/abstract-grant-type');
1616

17+
/**
18+
* Export the JWT bearer authorization grant (register via `extendedGrantTypes`).
19+
*/
20+
21+
exports.JwtBearerGrantType = require('./lib/grant-types/jwt-bearer-grant-type');
22+
1723
/**
1824
* Export client authentication helpers for pluggable `token_endpoint_auth_method`s.
1925
*/
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
'use strict';
2+
3+
/*
4+
* Module dependencies.
5+
*/
6+
7+
const AbstractGrantType = require('./abstract-grant-type');
8+
const InvalidArgumentError = require('../errors/invalid-argument-error');
9+
const InvalidGrantError = require('../errors/invalid-grant-error');
10+
const InvalidRequestError = require('../errors/invalid-request-error');
11+
const ServerError = require('../errors/server-error');
12+
const { decodeJwt, decodeProtectedHeader, jwtVerify, createLocalJWKSet } = require('jose');
13+
const { algorithmsForHeader, isHmac, isValidationError, replayId, getRemoteJwks } = require('../utils/jws-util');
14+
15+
/**
16+
* The `grant_type` value for the JWT bearer authorization grant.
17+
* @see https://datatracker.ietf.org/doc/html/rfc7523#section-2.1
18+
*/
19+
const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
20+
21+
/**
22+
* @class JwtBearerGrantType
23+
* @classdesc
24+
* The JWT bearer authorization grant (RFC 7521 §4.1, RFC 7523 §2.1/§3): a signed
25+
* JWT *is* the authorization grant. The assertion's `iss` identifies a trusted
26+
* issuer (whose key verifies the assertion) and `sub` identifies the principal
27+
* the access token is issued for. Unlike JWT *client authentication*, `sub` is
28+
* the user/principal — not the client — and `iss`/`sub` are not bound to the
29+
* client id.
30+
*
31+
* This is an extension grant; register it via `extendedGrantTypes`. The
32+
* requested `scope` is taken from the body parameter (RFC 7521 §4.1), and no
33+
* refresh token is issued (RFC 7521 §5.2).
34+
*
35+
* The model must implement:
36+
* - `getJWTBearerIssuer(issuer)` → `{ audience, jwks | jwksUri | secret }` (or
37+
* falsy for an untrusted issuer) — the verification key material and the
38+
* expected `aud`.
39+
* - `getJWTBearerUser({ issuer, subject, client, scope, jti, assertionId, exp })` → the
40+
* authorized user (or falsy to deny). Replay (`jti`) can be enforced here.
41+
*
42+
* @example
43+
* new OAuth2Server({
44+
* model,
45+
* extendedGrantTypes: {
46+
* 'urn:ietf:params:oauth:grant-type:jwt-bearer': JwtBearerGrantType
47+
* },
48+
* // typically a public requester; identify it with `client_id`:
49+
* requireClientAuthentication: { 'urn:ietf:params:oauth:grant-type:jwt-bearer': false }
50+
* });
51+
*
52+
* @see https://datatracker.ietf.org/doc/html/rfc7521#section-4.1
53+
* @see https://datatracker.ietf.org/doc/html/rfc7523#section-2.1
54+
* @see https://datatracker.ietf.org/doc/html/rfc7523#section-3
55+
* @extends AbstractGrantType
56+
*/
57+
class JwtBearerGrantType extends AbstractGrantType {
58+
constructor(options = {}) {
59+
if (!options.model) {
60+
throw new InvalidArgumentError('Missing parameter: `model`');
61+
}
62+
63+
if (!options.model.getJWTBearerIssuer) {
64+
throw new InvalidArgumentError('Invalid argument: model does not implement `getJWTBearerIssuer()`');
65+
}
66+
67+
if (!options.model.getJWTBearerUser) {
68+
throw new InvalidArgumentError('Invalid argument: model does not implement `getJWTBearerUser()`');
69+
}
70+
71+
if (!options.model.saveToken) {
72+
throw new InvalidArgumentError('Invalid argument: model does not implement `saveToken()`');
73+
}
74+
75+
super(options);
76+
77+
this.maxTokenAge = options.maxTokenAge;
78+
this.clockTolerance = options.clockTolerance;
79+
this.algorithms = options.algorithms;
80+
}
81+
82+
/**
83+
* Handle the JWT bearer grant.
84+
*
85+
* @param request {Request}
86+
* @param client {ClientData}
87+
* @see https://datatracker.ietf.org/doc/html/rfc7523#section-2.1
88+
*/
89+
async handle(request, client) {
90+
if (!request) {
91+
throw new InvalidArgumentError('Missing parameter: `request`');
92+
}
93+
94+
if (!client) {
95+
throw new InvalidArgumentError('Missing parameter: `client`');
96+
}
97+
98+
const payload = await this.verifyAssertion(request);
99+
const scope = this.getScope(request);
100+
const user = await this.getUser(payload, client, scope, request.body.assertion);
101+
102+
return this.saveToken(user, client, scope);
103+
}
104+
105+
/**
106+
* Verify the `assertion` and return its (trusted) claims.
107+
*/
108+
async verifyAssertion(request) {
109+
const assertion = request.body.assertion;
110+
111+
if (!assertion) {
112+
throw new InvalidRequestError('Missing parameter: `assertion`');
113+
}
114+
115+
// Decode (without verifying) only to discover the issuer we must verify
116+
// against; nothing here is trusted until the signature is checked.
117+
let issuer;
118+
let header;
119+
try {
120+
issuer = decodeJwt(assertion).iss;
121+
header = decodeProtectedHeader(assertion);
122+
} catch (e) {
123+
throw new InvalidGrantError('Invalid grant: `assertion` is malformed');
124+
}
125+
126+
if (!issuer) {
127+
throw new InvalidGrantError('Invalid grant: `assertion` is missing the `iss` claim');
128+
}
129+
130+
const issuerData = await this.model.getJWTBearerIssuer(issuer);
131+
if (!issuerData) {
132+
throw new InvalidGrantError('Invalid grant: `assertion` issuer is not trusted');
133+
}
134+
135+
if (!issuerData.audience || (Array.isArray(issuerData.audience) && issuerData.audience.length === 0)) {
136+
throw new ServerError('Server error: `getJWTBearerIssuer()` did not return an `audience`');
137+
}
138+
139+
const algorithms = this.algorithms || algorithmsForHeader(header);
140+
const key = await this.getKey(issuerData, header);
141+
142+
try {
143+
const { payload } = await jwtVerify(assertion, key, {
144+
algorithms,
145+
audience: issuerData.audience,
146+
issuer,
147+
requiredClaims: ['iss', 'sub', 'aud', 'exp'],
148+
maxTokenAge: this.maxTokenAge,
149+
clockTolerance: this.clockTolerance,
150+
});
151+
152+
return payload;
153+
} catch (e) {
154+
if (isValidationError(e)) {
155+
throw new InvalidGrantError('Invalid grant: ' + e.message);
156+
}
157+
throw new ServerError(e);
158+
}
159+
}
160+
161+
/**
162+
* Resolve the verification key for the issuer (HMAC secret or asymmetric JWKS).
163+
*/
164+
async getKey(issuerData, header) {
165+
const hasSecret = typeof issuerData.secret === 'string' && issuerData.secret.length > 0;
166+
const hasJwks = !!issuerData.jwks || !!issuerData.jwksUri;
167+
168+
// No key material at all is a server-side misconfiguration of the issuer.
169+
if (!hasSecret && !hasJwks) {
170+
throw new ServerError('Server error: issuer has no key material for assertion verification');
171+
}
172+
173+
if (isHmac(header.alg)) {
174+
// The issuer has key material, just not for this algorithm family — that
175+
// makes the assertion unverifiable against this issuer, i.e. the client's
176+
// fault, not the server's.
177+
if (!hasSecret) {
178+
throw new InvalidGrantError('Invalid grant: assertion algorithm does not match the issuer key material');
179+
}
180+
return new TextEncoder().encode(issuerData.secret);
181+
}
182+
183+
if (!hasJwks) {
184+
throw new InvalidGrantError('Invalid grant: assertion algorithm does not match the issuer key material');
185+
}
186+
187+
if (issuerData.jwks) {
188+
return createLocalJWKSet(issuerData.jwks);
189+
}
190+
191+
return getRemoteJwks(issuerData.jwksUri);
192+
}
193+
194+
/**
195+
* Resolve and authorize the principal (`sub`) the token is issued for.
196+
*/
197+
async getUser(payload, client, scope, assertion) {
198+
const user = await this.model.getJWTBearerUser({
199+
issuer: payload.iss,
200+
subject: payload.sub,
201+
client,
202+
scope,
203+
jti: payload.jti,
204+
assertionId: replayId(assertion, payload.jti),
205+
exp: payload.exp,
206+
});
207+
208+
if (!user) {
209+
throw new InvalidGrantError('Invalid grant: the assertion subject is not authorized');
210+
}
211+
212+
return user;
213+
}
214+
215+
/**
216+
* Save and return the access token. No refresh token is issued (RFC 7521 §5.2).
217+
*/
218+
async saveToken(user, client, requestedScope) {
219+
const validatedScope = await this.validateScope(user, client, requestedScope);
220+
const accessToken = await this.generateAccessToken(client, user, validatedScope);
221+
const accessTokenExpiresAt = this.getAccessTokenExpiresAt(client, user, validatedScope);
222+
223+
const token = {
224+
accessToken,
225+
accessTokenExpiresAt,
226+
scope: validatedScope,
227+
};
228+
229+
return this.model.saveToken(token, client, user);
230+
}
231+
}
232+
233+
JwtBearerGrantType.GRANT_TYPE = GRANT_TYPE;
234+
235+
module.exports = JwtBearerGrantType;

lib/model.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,39 @@ class Model {
659659
async saveClientAssertionJti(jti, exp) {
660660
throw new ServerError('saveClientAssertionJti not implemented');
661661
}
662+
663+
/**
664+
* Invoked to resolve a trusted issuer for the JWT bearer authorization grant.
665+
* **Required** when the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant is enabled.
666+
*
667+
* **Invoked during:**
668+
* - JWT bearer authorization grant (`JwtBearerGrantType`)
669+
*
670+
* @async
671+
* @param issuer {string} The assertion's `iss` claim.
672+
* @return {Promise<object>} Resolves to the issuer's verification key material and the
673+
* expected audience: `{ audience, jwks | jwksUri | secret }`. Resolve to a falsy value
674+
* to reject the issuer as untrusted (`invalid_grant`).
675+
*/
676+
async getJWTBearerIssuer(issuer) {
677+
throw new ServerError('getJWTBearerIssuer not implemented');
678+
}
679+
680+
/**
681+
* Invoked to resolve and authorize the principal a JWT bearer assertion is issued for.
682+
* **Required** when the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant is enabled.
683+
*
684+
* **Invoked during:**
685+
* - JWT bearer authorization grant (`JwtBearerGrantType`)
686+
*
687+
* @async
688+
* @param params {object} `{ issuer, subject, client, scope, jti, assertionId, exp }` from the verified assertion. `assertionId` is the `jti`, or a fingerprint of the assertion's signing input when it has no `jti` — use it for single-use replay protection.
689+
* @return {Promise<object>} Resolves to the authorized user, or a falsy value to deny the
690+
* grant (`invalid_grant`). Single-use (`jti`) replay protection can be enforced here.
691+
*/
692+
async getJWTBearerUser(params) {
693+
throw new ServerError('getJWTBearerUser not implemented');
694+
}
662695
}
663696

664697
module.exports = Model;

0 commit comments

Comments
 (0)