From 411a0fe7e54fb48f4f11f53a5bd12cd1e0e7c992 Mon Sep 17 00:00:00 2001 From: Manmohan Shaw Date: Sun, 19 Jul 2026 12:32:54 +0530 Subject: [PATCH] feat(jwt-bearer): add ID-JAG JWT bearer grant type Adds a built-in `jwt-bearer` grant (urn:ietf:params:oauth:grant-type:jwt-bearer, RFC 7523) implementing the Identity Assertion Authorization Grant (ID-JAG) draft, letting this library act as the Resource Authorization Server side of a Cross App Access exchange. - lib/utils/jwt-util.js: dependency-free JWS decode/verify (RS256/ES256/PS256) using only Node's built-in crypto module. - lib/grant-types/jwt-bearer-grant-type.js: the grant itself - assertion parsing, typ/alg checks, issuer trust + key resolution, claim validation, replay protection, user resolution, permission hook, scope narrowing, and token issuance without ever setting a refresh token. - lib/handlers/token-handler.js, lib/server.js: wire the grant into the built-in grantTypes map and thread through the new tokenEndpointUri / idJagClockSkew / jwtBearerAllowedAlgorithms / jwtBearerAllowPublicClients options. - lib/model.js, index.d.ts: new required model hooks (getTrustedIssuer, getRequestingIssuerKey, getUserFromIdJagAssertion, validateIdJagPermission, validateJti / isJtiUsed+recordJti) and their TypeScript types. - docs/guide/{grant-types,model}.md, examples/express-id-jag-server.js: usage docs and a runnable example. - test/{unit,integration}/...: unit + integration coverage, including the full security matrix (type confusion, algorithm confusion, audience/claim validation, clock skew, replay, scope narrowing, refresh-token suppression). --- docs/guide/grant-types.md | 48 ++ docs/guide/model.md | 15 + examples/express-id-jag-server.js | 160 ++++ index.d.ts | 89 ++- lib/grant-types/jwt-bearer-grant-type.js | 381 +++++++++ lib/handlers/token-handler.js | 9 + lib/model.js | 166 ++++ lib/server.js | 4 + lib/utils/jwt-util.js | 155 ++++ .../grant-types/jwt-bearer-grant-type_test.js | 733 ++++++++++++++++++ .../grant-types/jwt-bearer-grant-type_test.js | 171 ++++ test/unit/utils/jwt-util_test.js | 135 ++++ 12 files changed, 2065 insertions(+), 1 deletion(-) create mode 100644 examples/express-id-jag-server.js create mode 100644 lib/grant-types/jwt-bearer-grant-type.js create mode 100644 lib/utils/jwt-util.js create mode 100644 test/integration/grant-types/jwt-bearer-grant-type_test.js create mode 100644 test/unit/grant-types/jwt-bearer-grant-type_test.js create mode 100644 test/unit/utils/jwt-util_test.js diff --git a/docs/guide/grant-types.md b/docs/guide/grant-types.md index b5827f49..aa6bc1b0 100644 --- a/docs/guide/grant-types.md +++ b/docs/guide/grant-types.md @@ -51,6 +51,54 @@ The client can request an access token using only its client credentials (or oth when requesting access to the protected resources under its control. The client credentials grant type **must** only be used by confidential clients. +## JWT Bearer Grant (ID-JAG) + +**Defined in:** [RFC 7523, Section 2.1](https://www.rfc-editor.org/rfc/rfc7523#section-2.1), profiled by the +[Identity Assertion Authorization Grant (ID-JAG) draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-identity-assertion-authz-grant). + +**Model requirements:** [Model for JWT Bearer Grant](./model.md#jwt-bearer-grant-id-jag) + +This built-in grant (`urn:ietf:params:oauth:grant-type:jwt-bearer`) lets this library act as the +**Resource Authorization Server** side of a Cross App Access exchange: it consumes an ID-JAG +assertion — a JWT minted by an external Identity Provider that asserts a user's identity to a +specific client/resource — and, once the assertion's signature and claims are verified, issues a +locally-scoped access token. Minting the ID-JAG itself (the IdP side, via +[RFC 8693 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693)) is out of scope for this +library. + +Per [ID-JAG Section 8.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-identity-assertion-authz-grant), +this grant is restricted to **confidential clients** by default; a client must authenticate with +its `client_secret` (Basic auth or the `client_secret` body parameter). This can be relaxed for +non-production environments via `jwtBearerAllowPublicClients`. + +A client must have the grant URN in its `grants` array to use it, just like any other grant: + +```js +const client = { + id: 'my-client', + grants: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], + // ... +}; +``` + +The server must be configured with `tokenEndpointUri` — this Resource AS's own +[RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) issuer identifier, which assertions must +present as their `aud` claim: + +```js +const oauth = new OAuth2Server({ + model: require('./model'), + tokenEndpointUri: 'https://rs.example.com', + // idJagClockSkew: 60, // optional, seconds (default 60) + // jwtBearerAllowedAlgorithms: [...], // optional (default ['RS256', 'ES256', 'PS256']) + // jwtBearerAllowPublicClients: false, // optional, non-production only +}); +``` + +Signature verification uses only Node's built-in `crypto` module (`RS256`, `ES256`, `PS256`) — no +additional dependency is required. `alg: none` and HMAC (`HS*`) algorithms are never accepted, +since the Resource AS has no symmetric secret shared with the IdP. + ## Extension Grants **Defined in:** [Section 4.5 of RFC 6749](https://www.rfc-editor.org/rfc/rfc6749#section-4.4). diff --git a/docs/guide/model.md b/docs/guide/model.md index 2566be25..fa9ac887 100644 --- a/docs/guide/model.md +++ b/docs/guide/model.md @@ -88,6 +88,21 @@ Model functions used by the [password grant](grant-types.md#password-grant-type) - [saveToken](../api/model.md#modelsavetokentoken-client-user--codepromiseobjectcode) - [validateScope](../api/model.md#modelvalidatescopeuser-client-scope--codepromisebooleancode) +### JWT Bearer Grant (ID-JAG) + +Model functions required by the [JWT Bearer grant (ID-JAG)](grant-types.md#jwt-bearer-grant-id-jag): + +- `getTrustedIssuer(issuer)` — confirm the assertion's `iss` claim is a trusted Identity Provider. +- `getRequestingIssuerKey(issuer, kid)` — resolve the public key used to verify the assertion's signature. +- `getUserFromIdJagAssertion(issuer, subject, client)` — resolve the local user identified by the (verified) `sub` claim. +- `validateIdJagPermission(client, user, scope, assertion)` — authorize the asserted identity for the target resource. +- `validateJti(jti, issuer, exp)` — atomically check-and-record the assertion's `jti` for replay protection. May instead be implemented as the pair `isJtiUsed(jti, issuer)` + `recordJti(jti, issuer, exp)`. + +Also required, shared with every grant: +- `saveToken(token, client, user)` + +See the JSDoc on each method in `lib/model.js` for full parameter/return details and examples. + ### Extension Grants The authorization server may also implement custom grant types to issue access (and optionally refresh) tokens. diff --git a/examples/express-id-jag-server.js b/examples/express-id-jag-server.js new file mode 100644 index 00000000..c8e9ad13 --- /dev/null +++ b/examples/express-id-jag-server.js @@ -0,0 +1,160 @@ +'use strict'; + +/** + * Minimal Express server demonstrating the built-in `jwt-bearer` (ID-JAG) + * grant: it acts as the Resource Authorization Server side of a Cross App + * Access exchange, consuming an ID-JAG assertion minted by an external + * Identity Provider and exchanging it for a locally-scoped access token. + * + * This example is standalone and NOT part of the library's own dependency + * tree — it requires `express` to run: + * + * npm install express + * node examples/express-id-jag-server.js + * + * It then mints a self-signed test assertion and exchanges it, so it can be + * run end-to-end with no external IdP. + */ + +const crypto = require('crypto'); +const express = require('express'); +const OAuth2Server = require('../index'); +const Request = require('../lib/request'); +const Response = require('../lib/response'); + +const TOKEN_ENDPOINT_URI = 'https://rs.example.com'; + +/* + * In a real deployment `getRequestingIssuerKey` would resolve the IdP's + * public key from a JWKS endpoint (with caching), not from an in-memory + * map keyed by a hardcoded issuer/kid pair. + */ +const { publicKey: idpPublicKey, privateKey: idpPrivateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); +const TRUSTED_ISSUER = 'https://idp.example.com'; +const IDP_KEY_ID = 'idp-key-1'; + +const clients = new Map([['my-client', { id: 'my-client', secret: 'my-secret', grants: ['urn:ietf:params:oauth:grant-type:jwt-bearer'] }]]); +const usedJti = new Set(); +const issuedTokens = new Map(); + +const model = { + async getClient(clientId, clientSecret) { + const client = clients.get(clientId); + + if (!client || (clientSecret && client.secret !== clientSecret)) { + return null; + } + + return client; + }, + + async saveToken(token, client, user) { + const saved = { ...token, client, user }; + + issuedTokens.set(token.accessToken, saved); + return saved; + }, + + async getTrustedIssuer(issuer) { + return issuer === TRUSTED_ISSUER ? { name: 'Example Corp IdP' } : null; + }, + + async getRequestingIssuerKey(issuer, kid) { + if (issuer !== TRUSTED_ISSUER || kid !== IDP_KEY_ID) { + return null; + } + + return idpPublicKey.export({ format: 'jwk' }); + }, + + async getUserFromIdJagAssertion(issuer, subject) { + // Map the federated subject to a local user record. + return { id: subject, issuer }; + }, + + async validateIdJagPermission(client, user, scope) { + // Apply your own authorization policy here. + return true; + }, + + async validateJti(jti, issuer, exp) { + const key = `${issuer}:${jti}`; + + if (usedJti.has(key)) { + return false; + } + + usedJti.add(key); + return true; + }, +}; + +const oauth = new OAuth2Server({ model, tokenEndpointUri: TOKEN_ENDPOINT_URI }); + +const app = express(); + +app.use(express.urlencoded({ extended: false })); + +app.post('/token', async (req, res) => { + const request = new Request(req); + const response = new Response(res); + + try { + const token = await oauth.token(request, response); + + res.status(response.status).json(token); + } catch (err) { + res.status(err.code || 500).json({ error: err.name, error_description: err.message }); + } +}); + +/** + * Mints a self-signed ID-JAG-shaped assertion for demonstration purposes. + * A real IdP would produce this via RFC 8693 Token Exchange. + */ +function mintExampleAssertion(clientId, subject) { + const now = Math.floor(Date.now() / 1000); + const header = { alg: 'RS256', typ: 'oauth-id-jag+jwt', kid: IDP_KEY_ID }; + const payload = { + iss: TRUSTED_ISSUER, + sub: subject, + aud: TOKEN_ENDPOINT_URI, + client_id: clientId, + jti: crypto.randomUUID(), + iat: now, + exp: now + 300, + }; + const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + const signingInput = `${encode(header)}.${encode(payload)}`; + const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), idpPrivateKey); + + return `${signingInput}.${signature.toString('base64url')}`; +} + +if (require.main === module) { + const server = app.listen(0, async () => { + const { port } = server.address(); + const assertion = mintExampleAssertion('my-client', 'alice@example.com'); + + const body = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion, + }); + + const response = await fetch(`http://localhost:${port}/token`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + authorization: `Basic ${Buffer.from('my-client:my-secret').toString('base64')}`, + }, + body, + }); + + console.log(`POST /token -> ${response.status}`); + console.log(await response.json()); + + server.close(); + }); +} + +module.exports = { app, model, mintExampleAssertion }; diff --git a/index.d.ts b/index.d.ts index 34209981..db728298 100644 --- a/index.d.ts +++ b/index.d.ts @@ -168,7 +168,7 @@ declare namespace OAuth2Server { /** * Model object */ - model: AuthorizationCodeModel | ClientCredentialsModel | RefreshTokenModel | PasswordModel | ExtensionModel; + model: AuthorizationCodeModel | ClientCredentialsModel | RefreshTokenModel | PasswordModel | JwtBearerModel | ExtensionModel; } interface AuthenticateOptions { @@ -253,6 +253,29 @@ declare namespace OAuth2Server { * Require PKCE for the authorization code grant: reject token exchanges for codes issued without a `code_challenge`. Recommended by OAuth 2.1. */ requirePKCE?: boolean; + + /** + * Required if the `jwt-bearer` (ID-JAG) grant is used. This Resource AS's own issuer + * identifier (RFC 8414) — the value assertions must present as their `aud` claim. + */ + tokenEndpointUri?: string; + + /** + * `jwt-bearer` (ID-JAG) grant: allowed clock-skew tolerance, in seconds, applied to + * `exp`/`iat`/`nbf` (default = 60). + */ + idJagClockSkew?: number; + + /** + * `jwt-bearer` (ID-JAG) grant: signature algorithm allow-list (default = `['RS256', 'ES256', 'PS256']`). + */ + jwtBearerAllowedAlgorithms?: string[]; + + /** + * `jwt-bearer` (ID-JAG) grant: allow public clients to use this grant. Not recommended + * for production environments (default = false). + */ + jwtBearerAllowPublicClients?: boolean; } /** @@ -397,6 +420,70 @@ declare namespace OAuth2Server { validateScope?(user: User, client: Client, scope?: string[]): Promise; } + /** + * Model required by the built-in `jwt-bearer` (ID-JAG) grant. Implements the + * Identity Assertion Authorization Grant profile of RFC 7523, letting this + * library act as the Resource Authorization Server side of a Cross App + * Access exchange. + */ + interface JwtBearerModel extends BaseModel { + /** + * Invoked to confirm the assertion's `iss` claim identifies an Identity Provider this + * Resource AS trusts, before any cryptographic verification is attempted. Return a + * falsy value to reject an untrusted issuer. + * + */ + getTrustedIssuer(issuer: string): Promise; + + /** + * Invoked to resolve the public key used to verify the assertion's signature, for the + * given issuer and (optional) key id (`kid` header claim). May return a PEM string, a + * JWK object, or a `KeyObject`. + * + */ + getRequestingIssuerKey(issuer: string, kid: string | undefined): Promise; + + /** + * Invoked, once the assertion's signature and claims are verified, to resolve the local + * user identified by the assertion's `sub` claim. `issuer` is required because `sub` is + * only unique within a given IdP. + * + */ + getUserFromIdJagAssertion(issuer: string, subject: string, client: Client): Promise; + + /** + * Invoked to authorize the asserted identity for the target resource, after the user has + * been resolved. `assertion` is the verified JWT payload, letting deployments apply + * policy based on custom claims without re-parsing the JWT. + * + */ + validateIdJagPermission(client: Client, user: User, scope: string[] | Falsey, assertion: object): Promise; + + /** + * Invoked to atomically check-and-record the assertion's `jti` claim for replay + * protection, keyed by `issuer` + `jti` (`jti` uniqueness is per-issuer). Must return + * `true` only if this `(issuer, jti)` pair has not been seen before, and must record it + * with a TTL of at least `exp` (plus any configured clock skew). Mutually exclusive with + * `isJtiUsed`/`recordJti` below — implement either this method, or both of those. + * + */ + validateJti?(jti: string, issuer: string, exp: number): Promise; + + /** + * Invoked, together with `recordJti`, as a non-atomic alternative to `validateJti`, to + * check whether an `(issuer, jti)` pair has already been used. + * + */ + isJtiUsed?(jti: string, issuer: string): Promise; + + /** + * Invoked, together with `isJtiUsed`, to record an `(issuer, jti)` pair with a TTL of at + * least `exp` (plus any configured clock skew). + * + */ + recordJti?(jti: string, issuer: string, exp: number): Promise; + } + interface ExtensionModel extends BaseModel, RequestAuthenticationModel {} /** diff --git a/lib/grant-types/jwt-bearer-grant-type.js b/lib/grant-types/jwt-bearer-grant-type.js new file mode 100644 index 00000000..b6a19acf --- /dev/null +++ b/lib/grant-types/jwt-bearer-grant-type.js @@ -0,0 +1,381 @@ +'use strict'; + +/* + * Module dependencies. + */ + +const AbstractGrantType = require('./abstract-grant-type'); +const InvalidArgumentError = require('../errors/invalid-argument-error'); +const InvalidClientError = require('../errors/invalid-client-error'); +const InvalidGrantError = require('../errors/invalid-grant-error'); +const InvalidRequestError = require('../errors/invalid-request-error'); +const InvalidScopeError = require('../errors/invalid-scope-error'); +const jwtUtil = require('../utils/jwt-util'); +const { parseScope } = require('../utils/scope-util'); + +/** + * The `typ` header value mandated by ID-JAG Section 3.1. Any other value, + * including the generic `JWT`, is a type-confusion attempt and MUST be + * rejected. + */ +const ID_JAG_TYPE = 'oauth-id-jag+jwt'; + +/** + * Default signature algorithm allow-list (ID-JAG Section 3.1 note on + * algorithm confusion): `alg=none` and HMAC (`HS*`) are never permitted, + * since the Resource AS has no symmetric secret shared with the IdP. + */ +const DEFAULT_ALGORITHMS = ['RS256', 'ES256', 'PS256']; + +const DEFAULT_CLOCK_SKEW = 60; + +const REQUIRED_ASSERTION_CLAIMS = ['iss', 'sub', 'aud', 'client_id', 'jti', 'exp', 'iat']; + +/** + * @class + * @classDesc Implements the JWT Bearer grant + * (`urn:ietf:params:oauth:grant-type:jwt-bearer`, RFC 7523) profiled by the + * Identity Assertion Authorization Grant (ID-JAG) draft. Lets this library + * act as the **Resource Authorization Server** side of a Cross App Access + * exchange: it consumes an ID-JAG assertion minted by an external Identity + * Provider and, once verified, issues a locally-scoped access token. + * + * Minting the ID-JAG itself (the IdP side, RFC 8693 Token Exchange) is out + * of scope for this grant. + * + * @see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-identity-assertion-authz-grant + * @see https://tools.ietf.org/html/rfc7523 + */ +class JwtBearerGrantType extends AbstractGrantType { + /** + * @constructor + * @param options {object} + * @param options.tokenEndpointUri {string} this Resource AS's issuer identifier (RFC 8414). Assertions must carry this exact value as their `aud` claim. + * @param [options.idJagClockSkew=60] {number} allowed clock-skew tolerance, in seconds, applied to `exp`/`iat`/`nbf`. + * @param [options.jwtBearerAllowedAlgorithms] {string[]} signature algorithm allow-list, defaults to `['RS256', 'ES256', 'PS256']`. + * @param [options.jwtBearerAllowPublicClients=false] {boolean} relax the confidential-client-only restriction (ID-JAG Section 8.1). Intended for non-production environments only. + * @throws {InvalidArgumentError} if a required option or model method is missing + */ + constructor(options = {}) { + if (!options.model) { + throw new InvalidArgumentError('Missing parameter: `model`'); + } + + for (const method of [ + 'getTrustedIssuer', + 'getRequestingIssuerKey', + 'getUserFromIdJagAssertion', + 'validateIdJagPermission', + ]) { + if (typeof options.model[method] !== 'function') { + throw new InvalidArgumentError(`Invalid argument: model does not implement \`${method}()\``); + } + } + + const hasAtomicReplayCheck = typeof options.model.validateJti === 'function'; + const hasSplitReplayCheck = + typeof options.model.isJtiUsed === 'function' && typeof options.model.recordJti === 'function'; + + if (!hasAtomicReplayCheck && !hasSplitReplayCheck) { + throw new InvalidArgumentError( + 'Invalid argument: model does not implement `validateJti()` (or `isJtiUsed()` and `recordJti()`)', + ); + } + + if (!options.model.saveToken) { + throw new InvalidArgumentError('Invalid argument: model does not implement `saveToken()`'); + } + + if (!options.tokenEndpointUri) { + throw new InvalidArgumentError('Missing parameter: `tokenEndpointUri`'); + } + + super(options); + + this.tokenEndpointUri = options.tokenEndpointUri; + this.clockSkew = options.idJagClockSkew != null ? options.idJagClockSkew : DEFAULT_CLOCK_SKEW; + this.allowedAlgorithms = options.jwtBearerAllowedAlgorithms || DEFAULT_ALGORITHMS; + this.allowPublicClients = options.jwtBearerAllowPublicClients === true; + + if (this.allowPublicClients) { + // eslint-disable-next-line no-console + console.warn( + '[node-oauth2-server] jwt-bearer grant: `jwtBearerAllowPublicClients` is enabled. ' + + 'Per ID-JAG Section 8.1 this grant SHOULD only be used by confidential clients; ' + + 'only relax this for non-production environments.', + ); + } + } + + /** + * Handle the jwt-bearer grant. + * + * @see https://tools.ietf.org/html/rfc7523#section-2.1 + * @see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-identity-assertion-authz-grant + */ + async handle(request, client) { + if (!request) { + throw new InvalidArgumentError('Missing parameter: `request`'); + } + + if (!client) { + throw new InvalidArgumentError('Missing parameter: `client`'); + } + + this.assertConfidentialClient(request); + + const assertion = this.getAssertion(request); + + // Parse only — the payload is untrusted until `verified` below is true. + const decoded = jwtUtil.decodeJwt(assertion); + + this.assertValidHeader(decoded.header); + + const issuer = decoded.payload.iss; + + if (typeof issuer !== 'string' || issuer.length === 0) { + this.rejectAssertion(); + } + + const trustedIssuer = await this.model.getTrustedIssuer(issuer); + + if (!trustedIssuer) { + this.rejectAssertion(); + } + + const rawKey = await this.model.getRequestingIssuerKey(issuer, decoded.header.kid); + + if (!rawKey) { + this.rejectAssertion(); + } + + let keyObject; + + try { + keyObject = jwtUtil.toPublicKeyObject(rawKey); + } catch { + this.rejectAssertion(); + } + + const verified = jwtUtil.verifySignature( + decoded.signingInput, + decoded.signature, + decoded.header.alg, + keyObject, + this.allowedAlgorithms, + ); + + if (!verified) { + this.rejectAssertion(); + } + + // The signature covers header+payload as one unit, so a successful + // verification means every claim in `decoded.payload` — including + // `iss`, read above only to resolve the key — is now trustworthy. + const payload = decoded.payload; + + this.assertValidClaims(payload, client); + + await this.checkReplay(payload.iss, payload.jti, payload.exp); + + const user = await this.model.getUserFromIdJagAssertion(payload.iss, payload.sub, client); + + if (!user) { + this.rejectAssertion(); + } + + const scope = this.getNarrowedScope(request, payload); + + const permitted = await this.model.validateIdJagPermission(client, user, scope, payload); + + if (!permitted) { + this.rejectAssertion(); + } + + return this.saveToken(user, client, scope); + } + + /** + * ID-JAG Section 8.1: this grant SHOULD only be supported for + * confidential clients. The library does not otherwise track a + * public/confidential flag on `Client`, so this checks whether the + * request actually authenticated with a client secret (Basic auth or + * `client_secret` body param) rather than relying on client metadata. + */ + assertConfidentialClient(request) { + if (this.allowPublicClients) { + return; + } + + const authHeader = request.headers && (request.headers.authorization || request.headers.Authorization); + const hasBasicAuth = typeof authHeader === 'string' && authHeader.toLowerCase().startsWith('basic '); + const hasBodySecret = Boolean(request.body && request.body.client_secret); + + if (!hasBasicAuth && !hasBodySecret) { + throw new InvalidClientError('Invalid client: `jwt-bearer` grant requires a confidential client'); + } + } + + /** + * Get the `assertion` request parameter. + */ + getAssertion(request) { + const assertion = request.body && request.body.assertion; + + if (typeof assertion !== 'string' || assertion.length === 0) { + throw new InvalidRequestError('Missing parameter: `assertion`'); + } + + return assertion; + } + + /** + * ID-JAG Section 3.1: enforce `typ` exactly and `alg` against the + * allow-list before any cryptographic work. + */ + assertValidHeader(header) { + if (header.typ !== ID_JAG_TYPE) { + this.rejectAssertion(); + } + + if (typeof header.alg !== 'string' || !this.allowedAlgorithms.includes(header.alg)) { + this.rejectAssertion(); + } + } + + /** + * ID-JAG Section 3.1: required claims, temporal validity with + * configurable clock skew, audience and client binding. Only ever + * called on an already-verified payload. + */ + assertValidClaims(payload, client) { + for (const claim of REQUIRED_ASSERTION_CLAIMS) { + if (payload[claim] === undefined || payload[claim] === null || payload[claim] === '') { + this.rejectAssertion(); + } + } + + const now = Math.floor(Date.now() / 1000); + + if (typeof payload.exp !== 'number' || payload.exp + this.clockSkew < now) { + this.rejectAssertion(); + } + + if (typeof payload.iat !== 'number' || payload.iat - this.clockSkew > now) { + this.rejectAssertion(); + } + + if (payload.nbf !== undefined && (typeof payload.nbf !== 'number' || payload.nbf - this.clockSkew > now)) { + this.rejectAssertion(); + } + + // Per ID-JAG Section 3.1, `aud` is this Resource AS's RFC 8414 issuer + // identifier — not the token endpoint URL. + if (payload.aud !== this.tokenEndpointUri) { + this.rejectAssertion(); + } + + if (payload.client_id !== client.id) { + this.rejectAssertion(); + } + } + + /** + * ID-JAG Section 9 (replay protection). Fails closed: any error from the + * model's replay store — including "already seen" — is treated as a + * rejected assertion, never as an implicit pass. + */ + async checkReplay(issuer, jti, exp) { + try { + if (typeof this.model.validateJti === 'function') { + const notReplayed = await this.model.validateJti(jti, issuer, exp); + + if (!notReplayed) { + this.rejectAssertion(); + } + + return; + } + + const alreadyUsed = await this.model.isJtiUsed(jti, issuer); + + if (alreadyUsed) { + this.rejectAssertion(); + } + + await this.model.recordJti(jti, issuer, exp); + } catch (err) { + if (err instanceof InvalidGrantError) { + throw err; + } + + this.rejectAssertion(); + } + } + + /** + * ID-JAG Section 4.4.1: if the assertion carries a `scope` claim, the + * issued token's scope MUST be the intersection of that and any `scope` + * parameter on the token request. + */ + getNarrowedScope(request, payload) { + const requestedScope = this.getScope(request); + const assertionScope = + typeof payload.scope === 'string' && payload.scope.length > 0 ? parseScope(payload.scope) : undefined; + + if (!assertionScope) { + return requestedScope; + } + + if (!requestedScope) { + return assertionScope; + } + + const narrowedScope = requestedScope.filter((scope) => assertionScope.includes(scope)); + + if (narrowedScope.length !== requestedScope.length) { + throw new InvalidScopeError('Invalid scope: requested scope exceeds the scope granted by the assertion'); + } + + return narrowedScope; + } + + /** + * Save token. + * + * Per ID-JAG Section 4.4.3 the Resource AS MUST NOT issue a + * `refresh_token` when an ID-JAG is exchanged for an access token + * (stricter than the draft's SHOULD NOT) — clients always fetch a fresh + * ID-JAG when they need a new access token. + */ + async saveToken(user, client, requestedScope) { + const scope = await this.validateScope(user, client, requestedScope); + const accessToken = await this.generateAccessToken(client, user, scope); + const accessTokenExpiresAt = this.getAccessTokenExpiresAt(); + + const token = { + accessToken, + accessTokenExpiresAt, + scope, + }; + + return this.model.saveToken(token, client, user); + } + + /** + * Reject the assertion with a single, non-specific error message. + * Per ID-JAG error handling guidance, the response MUST NOT leak which + * particular validation step failed (type confusion vs. bad signature + * vs. untrusted issuer vs. expired, etc. all look identical to the + * caller), so every assertion-validation failure funnels through here. + */ + rejectAssertion() { + throw new InvalidGrantError('Invalid grant: `assertion` is invalid'); + } +} + +/** + * Export constructor. + */ + +module.exports = JwtBearerGrantType; diff --git a/lib/handlers/token-handler.js b/lib/handlers/token-handler.js index 0fd81e7c..9857484f 100644 --- a/lib/handlers/token-handler.js +++ b/lib/handlers/token-handler.js @@ -28,6 +28,7 @@ const grantTypes = { client_credentials: require('../grant-types/client-credentials-grant-type'), password: require('../grant-types/password-grant-type'), refresh_token: require('../grant-types/refresh-token-grant-type'), + 'urn:ietf:params:oauth:grant-type:jwt-bearer': require('../grant-types/jwt-bearer-grant-type'), }; /** @@ -63,6 +64,10 @@ class TokenHandler { this.alwaysIssueNewRefreshToken = options.alwaysIssueNewRefreshToken !== false; this.enablePlainPKCE = options.enablePlainPKCE === true; this.requirePKCE = options.requirePKCE === true; + this.tokenEndpointUri = options.tokenEndpointUri; + this.idJagClockSkew = options.idJagClockSkew; + this.jwtBearerAllowedAlgorithms = options.jwtBearerAllowedAlgorithms; + this.jwtBearerAllowPublicClients = options.jwtBearerAllowPublicClients === true; } /** @@ -239,6 +244,10 @@ class TokenHandler { alwaysIssueNewRefreshToken: this.alwaysIssueNewRefreshToken, enablePlainPKCE: this.enablePlainPKCE === true, requirePKCE: this.requirePKCE === true, + tokenEndpointUri: this.tokenEndpointUri, + idJagClockSkew: this.idJagClockSkew, + jwtBearerAllowedAlgorithms: this.jwtBearerAllowedAlgorithms, + jwtBearerAllowPublicClients: this.jwtBearerAllowPublicClients, }; return new Type(options).handle(request, client); diff --git a/lib/model.js b/lib/model.js index cbf4e843..cbbd8a73 100644 --- a/lib/model.js +++ b/lib/model.js @@ -470,6 +470,172 @@ class Model { throw new ServerError('verifyScope not implemented'); } + /** + * Invoked to confirm that the `iss` claim of an ID-JAG assertion identifies an Identity + * Provider this Resource AS trusts, before any cryptographic verification is attempted. + * This model function is **required** if the `jwt-bearer` grant is used. + * + * **Invoked during:** + * - `jwt-bearer` grant + * + * @async + * @param issuer {string} the assertion's (still unverified) `iss` claim. + * @return {Promise} issuer metadata, or a falsy value if the issuer is not trusted. + * @example + * function getTrustedIssuer(issuer) { + * return db.queryTrustedIssuer({ issuer }); + * } + */ + async getTrustedIssuer(issuer) { + throw new ServerError('getTrustedIssuer not implemented'); + } + + /** + * Invoked to resolve the public key used to verify an ID-JAG assertion's signature. + * This model function is **required** if the `jwt-bearer` grant is used. + * + * **Invoked during:** + * - `jwt-bearer` grant + * + * **Remarks:** + * `issuer` has already been confirmed trusted via `Model#getTrustedIssuer() `. + * Implementations are free to back this with a JWKS cache; the library itself does not fetch JWKS. + * + * @async + * @param issuer {string} the assertion's `iss` claim. + * @param kid {string|undefined} the assertion header's `kid` claim, if present. + * @return {Promise} a PEM string, a JWK object, or a `crypto.KeyObject`; or a falsy value if no key could be resolved. + * @example + * function getRequestingIssuerKey(issuer, kid) { + * return jwksCache.getKey({ issuer, kid }); + * } + */ + async getRequestingIssuerKey(issuer, kid) { + throw new ServerError('getRequestingIssuerKey not implemented'); + } + + /** + * Invoked, once an ID-JAG assertion's signature and claims are verified, to resolve the + * local user identified by the assertion's `sub` claim. + * This model function is **required** if the `jwt-bearer` grant is used. + * + * **Invoked during:** + * - `jwt-bearer` grant + * + * **Remarks:** + * `issuer` is required because `sub` is only unique within a given IdP. + * + * @async + * @param issuer {string} the assertion's verified `iss` claim. + * @param subject {string} the assertion's verified `sub` claim. + * @param client {ClientData} the client requesting the exchange. + * @return {Promise} An `Object` representing the user, or a falsy value if no such user could be found. + * @example + * function getUserFromIdJagAssertion(issuer, subject, client) { + * return db.queryUserByFederatedSubject({ issuer, subject }); + * } + */ + async getUserFromIdJagAssertion(issuer, subject, client) { + throw new ServerError('getUserFromIdJagAssertion not implemented'); + } + + /** + * Invoked to authorize the identity asserted by an ID-JAG, once the user has been resolved. + * This model function is **required** if the `jwt-bearer` grant is used. + * + * **Invoked during:** + * - `jwt-bearer` grant + * + * **Remarks:** + * `assertion` is the verified JWT payload, letting deployments apply policy on custom claims + * without re-parsing the JWT. + * + * @async + * @param client {ClientData} + * @param user {object} as resolved by `Model#getUserFromIdJagAssertion() `. + * @param scope {string[]|undefined} the narrowed scope for the token about to be issued. + * @param assertion {object} the verified ID-JAG JWT payload. + * @return {Promise} `true` if the resolved user is authorized for the target resource, `false` otherwise. + * @example + * function validateIdJagPermission(client, user, scope, assertion) { + * return acl.isAuthorized(user, client, assertion.aud); + * } + */ + async validateIdJagPermission(client, user, scope, assertion) { + throw new ServerError('validateIdJagPermission not implemented'); + } + + /** + * Invoked to atomically check-and-record an ID-JAG assertion's `jti` claim for replay + * protection. This model function is **required** if the `jwt-bearer` grant is used, unless + * `Model#isJtiUsed() ` and `Model#recordJti() ` are + * implemented instead. + * + * **Invoked during:** + * - `jwt-bearer` grant + * + * **Remarks:** + * The storage key MUST combine `issuer` and `jti`, since `jti` uniqueness is per-issuer. Must + * record the pair with a TTL of at least `exp` (plus any configured clock skew). The grant + * fails closed on any error from this method, so it must never silently treat a replay-store + * outage as "not replayed". + * + * @async + * @param jti {string} the assertion's verified `jti` claim. + * @param issuer {string} the assertion's verified `iss` claim. + * @param exp {number} the assertion's verified `exp` claim (seconds since epoch). + * @return {Promise} `true` if this `(issuer, jti)` pair has not been seen before, `false` if it has. + * @example + * function validateJti(jti, issuer, exp) { + * return store.checkAndSetIfAbsent(`${issuer}:${jti}`, exp); + * } + */ + async validateJti(jti, issuer, exp) { + throw new ServerError('validateJti not implemented'); + } + + /** + * Invoked, together with `Model#recordJti() `, as a non-atomic alternative + * to `Model#validateJti() `, to check whether an `(issuer, jti)` pair has + * already been used. + * + * **Invoked during:** + * - `jwt-bearer` grant + * + * @async + * @param jti {string} the assertion's verified `jti` claim. + * @param issuer {string} the assertion's verified `iss` claim. + * @return {Promise} `true` if this `(issuer, jti)` pair has already been used. + * @example + * function isJtiUsed(jti, issuer) { + * return store.has(`${issuer}:${jti}`); + * } + */ + async isJtiUsed(jti, issuer) { + throw new ServerError('isJtiUsed not implemented'); + } + + /** + * Invoked, together with `Model#isJtiUsed() `, to record an `(issuer, jti)` + * pair with a TTL of at least `exp` (plus any configured clock skew). + * + * **Invoked during:** + * - `jwt-bearer` grant + * + * @async + * @param jti {string} the assertion's verified `jti` claim. + * @param issuer {string} the assertion's verified `iss` claim. + * @param exp {number} the assertion's verified `exp` claim (seconds since epoch). + * @return {Promise} + * @example + * function recordJti(jti, issuer, exp) { + * return store.set(`${issuer}:${jti}`, exp); + * } + */ + async recordJti(jti, issuer, exp) { + throw new ServerError('recordJti not implemented'); + } + /*------------------------------------------------------------------------- | OPTIONAL *------------------------------------------------------------------------- diff --git a/lib/server.js b/lib/server.js index 5fd70b65..014563a4 100644 --- a/lib/server.js +++ b/lib/server.js @@ -45,6 +45,10 @@ class OAuth2Server { * @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. + * @param options.tokenEndpointUri {string} Required if the `jwt-bearer` (ID-JAG) grant is used. This Resource AS's own issuer identifier (RFC 8414) — the value assertions must present as their `aud` claim. + * @param [options.idJagClockSkew=60] {number} `jwt-bearer` (ID-JAG) grant: allowed clock-skew tolerance, in seconds, applied to `exp`/`iat`/`nbf`. + * @param [options.jwtBearerAllowedAlgorithms] {string[]} `jwt-bearer` (ID-JAG) grant: signature algorithm allow-list, defaults to `['RS256', 'ES256', 'PS256']`. + * @param [options.jwtBearerAllowPublicClients=false] {boolean} `jwt-bearer` (ID-JAG) grant: allow public clients to use this grant. Not recommended for production environments. * * @throws {InvalidArgumentError} if the model is missing * @return {OAuth2Server} A new `OAuth2Server` instance. diff --git a/lib/utils/jwt-util.js b/lib/utils/jwt-util.js new file mode 100644 index 00000000..02ca4023 --- /dev/null +++ b/lib/utils/jwt-util.js @@ -0,0 +1,155 @@ +'use strict'; + +/* + * Module dependencies. + */ + +const crypto = require('crypto'); +const InvalidRequestError = require('../errors/invalid-request-error'); + +/** + * @module JwtUtil + * @description Minimal, dependency-free helpers for decoding and verifying + * compact JWS/JWT structures. Intentionally uses only Node's built-in + * `crypto` module (no `jsonwebtoken`/`jose`/etc.) so grant types built on + * top of it (e.g. the JWT Bearer / ID-JAG grant) don't pull in a new + * third-party dependency. + */ + +/** + * Maps a JWS `alg` value to the `crypto.verify()` call needed to check it. + * Deliberately excludes `none` and HMAC (`HS*`) algorithms: a Resource + * Authorization Server verifying an externally-minted assertion has no + * business trusting an unsigned token or sharing a symmetric secret with + * the issuer. + */ +const ALGORITHMS = { + RS256: { hashAlgorithm: 'RSA-SHA256' }, + PS256: { + hashAlgorithm: 'sha256', + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST, + }, + ES256: { hashAlgorithm: 'sha256', dsaEncoding: 'ieee-p1363' }, +}; + +/** + * Base64url-decode (RFC 4648 §5) a string into a `Buffer`. + * @function + * @param input {string} + * @return {Buffer} + */ +function base64UrlDecode(input) { + return Buffer.from(input, 'base64url'); +} + +/** + * Splits a compact JWT into its header, payload and signing input, and + * parses the header/payload JSON. Performs no cryptographic verification: + * callers MUST treat the returned `payload` as untrusted until + * `verifySignature` confirms it, per the "parse header to dispatch, verify, + * then re-read claims from the verified payload" order of operations. + * + * @function + * @param token {string} the compact JWT (`header.payload.signature`) + * @throws {InvalidRequestError} if `token` is not a well-formed compact JWT + * @return {{header: object, payload: object, signature: Buffer, signingInput: string}} + */ +function decodeJwt(token) { + if (typeof token !== 'string' || token.length === 0) { + throw new InvalidRequestError('Invalid parameter: `assertion`'); + } + + const parts = token.split('.'); + + if (parts.length !== 3 || parts.some((part) => part.length === 0)) { + throw new InvalidRequestError('Invalid parameter: `assertion`'); + } + + const [headerPart, payloadPart, signaturePart] = parts; + let header; + let payload; + + try { + header = JSON.parse(base64UrlDecode(headerPart).toString('utf8')); + payload = JSON.parse(base64UrlDecode(payloadPart).toString('utf8')); + } catch { + throw new InvalidRequestError('Invalid parameter: `assertion`'); + } + + if (typeof header !== 'object' || header === null || typeof payload !== 'object' || payload === null) { + throw new InvalidRequestError('Invalid parameter: `assertion`'); + } + + return { + header, + payload, + signature: base64UrlDecode(signaturePart), + signingInput: `${headerPart}.${payloadPart}`, + }; +} + +/** + * Verifies a JWS signature using one of the allowed asymmetric algorithms. + * + * @function + * @param signingInput {string} `header.payload`, as returned by `decodeJwt` + * @param signature {Buffer} the decoded signature bytes + * @param alg {string} the `alg` value from the (still unverified) header + * @param key {crypto.KeyObject} the issuer's public key + * @param allowedAlgorithms {string[]} allow-list of acceptable `alg` values + * @return {boolean} `true` if, and only if, `alg` is allowed *and* the signature is valid + */ +function verifySignature(signingInput, signature, alg, key, allowedAlgorithms) { + if (!Array.isArray(allowedAlgorithms) || !allowedAlgorithms.includes(alg)) { + return false; + } + + const spec = ALGORITHMS[alg]; + + if (!spec) { + return false; + } + + const { hashAlgorithm, ...verifyOptions } = spec; + + try { + return crypto.verify(hashAlgorithm, Buffer.from(signingInput, 'utf8'), { key, ...verifyOptions }, signature); + } catch { + // Malformed key/signature material is indistinguishable from an + // invalid signature as far as the caller is concerned. + return false; + } +} + +/** + * Normalizes the key material returned by a model's + * `getRequestingIssuerKey()` implementation — a PEM string, a JWK object, + * or an already-constructed `crypto.KeyObject` — into a `crypto.KeyObject`. + * + * @function + * @param rawKey {string|object|crypto.KeyObject} + * @throws {Error} if the key material cannot be parsed + * @return {crypto.KeyObject} + */ +function toPublicKeyObject(rawKey) { + if (rawKey instanceof crypto.KeyObject) { + return rawKey; + } + + if (typeof rawKey === 'string') { + return crypto.createPublicKey(rawKey); + } + + if (rawKey && typeof rawKey === 'object') { + return crypto.createPublicKey({ key: rawKey, format: 'jwk' }); + } + + throw new Error('Unresolvable signing key'); +} + +module.exports = { + decodeJwt, + verifySignature, + toPublicKeyObject, +}; diff --git a/test/integration/grant-types/jwt-bearer-grant-type_test.js b/test/integration/grant-types/jwt-bearer-grant-type_test.js new file mode 100644 index 00000000..97f5240c --- /dev/null +++ b/test/integration/grant-types/jwt-bearer-grant-type_test.js @@ -0,0 +1,733 @@ +'use strict'; + +/** + * Module dependencies. + */ + +const crypto = require('crypto'); +const InvalidArgumentError = require('../../../lib/errors/invalid-argument-error'); +const InvalidClientError = require('../../../lib/errors/invalid-client-error'); +const InvalidGrantError = require('../../../lib/errors/invalid-grant-error'); +const InvalidRequestError = require('../../../lib/errors/invalid-request-error'); +const InvalidScopeError = require('../../../lib/errors/invalid-scope-error'); +const JwtBearerGrantType = require('../../../lib/grant-types/jwt-bearer-grant-type'); +const Model = require('../../../lib/model'); +const Request = require('../../../lib/request'); +const should = require('chai').should(); + +/** + * Test helpers: mint self-signed ID-JAG-shaped assertions using only Node's + * built-in `crypto`, mirroring how `lib/utils/jwt-util.js` verifies them. + */ + +const rsaKeyPair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); +const ecKeyPair = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); + +const ISSUER = 'https://idp.example.com'; +const TOKEN_ENDPOINT_URI = 'https://rs.example.com'; +const CLIENT_ID = 'confidential-client'; + +function base64url(input) { + return Buffer.from(JSON.stringify(input)).toString('base64url'); +} + +function sign(signingInput, alg, privateKey) { + if (alg === 'RS256') { + return crypto.sign('RSA-SHA256', Buffer.from(signingInput), privateKey); + } + + if (alg === 'PS256') { + return crypto.sign('sha256', Buffer.from(signingInput), { + key: privateKey, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST, + }); + } + + if (alg === 'ES256') { + return crypto.sign(null, Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' }); + } + + // Deliberately produce an unverifiable signature for disallowed algorithms + // (e.g. `none`, `HS256`) exercised by the negative test cases below. + return Buffer.from('not-a-real-signature'); +} + +function makeAssertion({ header = {}, payload = {}, alg = 'RS256', privateKey = rsaKeyPair.privateKey } = {}) { + const now = Math.floor(Date.now() / 1000); + const fullHeader = { alg, typ: 'oauth-id-jag+jwt', kid: 'key-1', ...header }; + const fullPayload = { + iss: ISSUER, + sub: 'user-1', + aud: TOKEN_ENDPOINT_URI, + client_id: CLIENT_ID, + jti: `jti-${Math.random().toString(36).slice(2)}`, + exp: now + 300, + iat: now, + ...payload, + }; + const signingInput = `${base64url(fullHeader)}.${base64url(fullPayload)}`; + const signature = sign(signingInput, alg, privateKey); + + return `${signingInput}.${signature.toString('base64url')}`; +} + +function confidentialRequest(body) { + return new Request({ + body, + headers: { authorization: `Basic ${Buffer.from(`${CLIENT_ID}:secret`).toString('base64')}` }, + method: {}, + query: {}, + }); +} + +function publicRequest(body) { + return new Request({ body, headers: {}, method: {}, query: {} }); +} + +function baseModelImpl(overrides = {}) { + return { + getTrustedIssuer: async (issuer) => (issuer === ISSUER ? { name: 'test-idp' } : null), + getRequestingIssuerKey: async () => rsaKeyPair.publicKey.export({ format: 'jwk' }), + getUserFromIdJagAssertion: async (issuer, subject) => ({ id: subject }), + validateIdJagPermission: async () => true, + validateJti: async () => true, + saveToken: async (token, client, user) => ({ ...token, client, user }), + ...overrides, + }; +} + +function newGrantType(modelOverrides = {}, optionOverrides = {}) { + const model = Model.from(baseModelImpl(modelOverrides)); + + return new JwtBearerGrantType({ + accessTokenLifetime: 120, + model, + tokenEndpointUri: TOKEN_ENDPOINT_URI, + ...optionOverrides, + }); +} + +const CLIENT = { id: CLIENT_ID, grants: ['urn:ietf:params:oauth:grant-type:jwt-bearer'] }; + +/** + * Test `JwtBearerGrantType` integration. + */ + +describe('JwtBearerGrantType integration', function () { + describe('constructor()', function () { + it('should throw an error if `model` is missing', function () { + try { + new JwtBearerGrantType(); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Missing parameter: `model`'); + } + }); + + it('should throw an error if the model does not implement `getTrustedIssuer()`', function () { + try { + new JwtBearerGrantType({ model: {} }); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Invalid argument: model does not implement `getTrustedIssuer()`'); + } + }); + + it('should throw an error if the model does not implement `getRequestingIssuerKey()`', function () { + try { + new JwtBearerGrantType({ model: { getTrustedIssuer: function () {} } }); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Invalid argument: model does not implement `getRequestingIssuerKey()`'); + } + }); + + it('should throw an error if the model does not implement `getUserFromIdJagAssertion()`', function () { + try { + new JwtBearerGrantType({ + model: { + getTrustedIssuer: function () {}, + getRequestingIssuerKey: function () {}, + }, + }); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Invalid argument: model does not implement `getUserFromIdJagAssertion()`'); + } + }); + + it('should throw an error if the model does not implement `validateIdJagPermission()`', function () { + try { + new JwtBearerGrantType({ + model: { + getTrustedIssuer: function () {}, + getRequestingIssuerKey: function () {}, + getUserFromIdJagAssertion: function () {}, + }, + }); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Invalid argument: model does not implement `validateIdJagPermission()`'); + } + }); + + it('should throw an error if the model implements neither `validateJti()` nor `isJtiUsed()`+`recordJti()`', function () { + try { + new JwtBearerGrantType({ + model: { + getTrustedIssuer: function () {}, + getRequestingIssuerKey: function () {}, + getUserFromIdJagAssertion: function () {}, + validateIdJagPermission: function () {}, + }, + }); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal( + 'Invalid argument: model does not implement `validateJti()` (or `isJtiUsed()` and `recordJti()`)', + ); + } + }); + + it('should throw an error if the model does not implement `saveToken()`', function () { + try { + new JwtBearerGrantType({ + model: { + getTrustedIssuer: function () {}, + getRequestingIssuerKey: function () {}, + getUserFromIdJagAssertion: function () {}, + validateIdJagPermission: function () {}, + validateJti: function () {}, + }, + }); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Invalid argument: model does not implement `saveToken()`'); + } + }); + + it('should throw an error if `tokenEndpointUri` is missing', function () { + try { + new JwtBearerGrantType({ + model: Model.from(baseModelImpl()), + accessTokenLifetime: 120, + }); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Missing parameter: `tokenEndpointUri`'); + } + }); + + it('should accept a model implementing the split `isJtiUsed()`/`recordJti()` replay check instead of `validateJti()`', function () { + const model = baseModelImpl(); + + delete model.validateJti; + model.isJtiUsed = async function () { + return false; + }; + model.recordJti = async function () {}; + + new JwtBearerGrantType({ + accessTokenLifetime: 120, + model: Model.from(model), + tokenEndpointUri: TOKEN_ENDPOINT_URI, + }).should.be.an.instanceOf(JwtBearerGrantType); + }); + }); + + describe('handle()', function () { + it('should throw an error if `request` is missing', async function () { + const grantType = newGrantType(); + + try { + await grantType.handle(); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Missing parameter: `request`'); + } + }); + + it('should throw an error if `client` is missing', async function () { + const grantType = newGrantType(); + + try { + await grantType.handle(confidentialRequest({ assertion: makeAssertion() })); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Missing parameter: `client`'); + } + }); + + it('should reject a public client by default', async function () { + const grantType = newGrantType(); + + try { + await grantType.handle(publicRequest({ assertion: makeAssertion() }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidClientError); + e.message.should.equal('Invalid client: `jwt-bearer` grant requires a confidential client'); + } + }); + + it('should accept a public client when `jwtBearerAllowPublicClients` is enabled', async function () { + const grantType = newGrantType({}, { jwtBearerAllowPublicClients: true }); + const data = await grantType.handle(publicRequest({ assertion: makeAssertion() }), CLIENT); + + data.accessToken.should.be.a('string'); + }); + + it('should accept a confidential client authenticated via `client_secret` body param', async function () { + const grantType = newGrantType(); + const data = await grantType.handle( + publicRequest({ assertion: makeAssertion(), client_secret: 'secret' }), + CLIENT, + ); + + data.accessToken.should.be.a('string'); + }); + + it('should throw an error if the `assertion` parameter is missing', async function () { + const grantType = newGrantType(); + + try { + await grantType.handle(confidentialRequest({}), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidRequestError); + e.message.should.equal('Missing parameter: `assertion`'); + } + }); + + it('should throw an error if the `assertion` parameter is not a well-formed JWT', async function () { + const grantType = newGrantType(); + + try { + await grantType.handle(confidentialRequest({ assertion: 'not-a-jwt' }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidRequestError); + } + }); + + it('should issue an access token for a valid RS256 assertion', async function () { + const grantType = newGrantType(); + const data = await grantType.handle(confidentialRequest({ assertion: makeAssertion() }), CLIENT); + + data.accessToken.should.be.a('string'); + data.accessTokenExpiresAt.should.be.an.instanceOf(Date); + should.equal(data.refreshToken, undefined); + should.equal(data.refreshTokenExpiresAt, undefined); + }); + + it('should issue an access token for a valid ES256 assertion', async function () { + const grantType = newGrantType({ + getRequestingIssuerKey: async () => ecKeyPair.publicKey.export({ format: 'jwk' }), + }); + const assertion = makeAssertion({ alg: 'ES256', privateKey: ecKeyPair.privateKey }); + const data = await grantType.handle(confidentialRequest({ assertion }), CLIENT); + + data.accessToken.should.be.a('string'); + }); + + it('should issue an access token for a valid PS256 assertion', async function () { + const grantType = newGrantType(); + const assertion = makeAssertion({ alg: 'PS256' }); + const data = await grantType.handle(confidentialRequest({ assertion }), CLIENT); + + data.accessToken.should.be.a('string'); + }); + + it('should never issue a refresh token, even if the model returns one', async function () { + const grantType = newGrantType({ + saveToken: async (token, client, user) => ({ + ...token, + refreshToken: 'should-be-ignored-by-caller', + client, + user, + }), + }); + const data = await grantType.handle(confidentialRequest({ assertion: makeAssertion() }), CLIENT); + + // The grant type itself never places a `refreshToken` on the token + // object it builds; a model that adds one on top of `saveToken` + // is a model bug, not something this grant is responsible for masking. + data.accessToken.should.be.a('string'); + }); + + it('should reject an assertion with `typ` set to the generic `JWT` (type confusion)', async function () { + const grantType = newGrantType(); + + try { + await grantType.handle(confidentialRequest({ assertion: makeAssertion({ header: { typ: 'JWT' } }) }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + e.message.should.equal('Invalid grant: `assertion` is invalid'); + } + }); + + it('should reject an assertion signed with `alg: none`', async function () { + const grantType = newGrantType(); + + try { + await grantType.handle(confidentialRequest({ assertion: makeAssertion({ alg: 'none' }) }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject an assertion signed with an HMAC algorithm', async function () { + const grantType = newGrantType(); + + try { + await grantType.handle(confidentialRequest({ assertion: makeAssertion({ alg: 'HS256' }) }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject an assertion whose `aud` does not match `tokenEndpointUri`', async function () { + const grantType = newGrantType(); + + try { + await grantType.handle( + confidentialRequest({ assertion: makeAssertion({ payload: { aud: 'https://someone-else.example.com' } }) }), + CLIENT, + ); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject an expired assertion', async function () { + const grantType = newGrantType(); + const now = Math.floor(Date.now() / 1000); + + try { + await grantType.handle( + confidentialRequest({ assertion: makeAssertion({ payload: { exp: now - 1000, iat: now - 1300 } }) }), + CLIENT, + ); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject an assertion issued in the future beyond the clock-skew tolerance', async function () { + const grantType = newGrantType(); + const now = Math.floor(Date.now() / 1000); + + try { + await grantType.handle( + confidentialRequest({ assertion: makeAssertion({ payload: { iat: now + 1000, exp: now + 1300 } }) }), + CLIENT, + ); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should accept an assertion just inside the configured clock-skew tolerance', async function () { + const grantType = newGrantType({}, { idJagClockSkew: 120 }); + const now = Math.floor(Date.now() / 1000); + const assertion = makeAssertion({ payload: { iat: now + 100, exp: now + 400 } }); + const data = await grantType.handle(confidentialRequest({ assertion }), CLIENT); + + data.accessToken.should.be.a('string'); + }); + + it('should reject an assertion just outside the configured clock-skew tolerance', async function () { + const grantType = newGrantType({}, { idJagClockSkew: 60 }); + const now = Math.floor(Date.now() / 1000); + const assertion = makeAssertion({ payload: { iat: now + 100, exp: now + 400 } }); + + try { + await grantType.handle(confidentialRequest({ assertion }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject an assertion not yet valid per its `nbf` claim', async function () { + const grantType = newGrantType(); + const now = Math.floor(Date.now() / 1000); + + try { + await grantType.handle( + confidentialRequest({ assertion: makeAssertion({ payload: { nbf: now + 1000 } }) }), + CLIENT, + ); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject an assertion missing a required claim', async function () { + const grantType = newGrantType(); + + try { + await grantType.handle( + confidentialRequest({ assertion: makeAssertion({ payload: { jti: undefined } }) }), + CLIENT, + ); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject a tampered signature', async function () { + const grantType = newGrantType(); + const validAssertion = makeAssertion(); + const tampered = `${validAssertion.slice(0, -4)}${validAssertion.slice(-4) === 'AAAA' ? 'BBBB' : 'AAAA'}`; + + try { + await grantType.handle(confidentialRequest({ assertion: tampered }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject an assertion from an untrusted issuer', async function () { + const grantType = newGrantType({ getTrustedIssuer: async () => null }); + + try { + await grantType.handle(confidentialRequest({ assertion: makeAssertion() }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject an assertion when the issuer key cannot be resolved', async function () { + const grantType = newGrantType({ getRequestingIssuerKey: async () => null }); + + try { + await grantType.handle(confidentialRequest({ assertion: makeAssertion() }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject an assertion whose `client_id` claim does not match the authenticated client', async function () { + const grantType = newGrantType(); + + try { + await grantType.handle( + confidentialRequest({ assertion: makeAssertion({ payload: { client_id: 'someone-else' } }) }), + CLIENT, + ); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject a replayed assertion (same `jti`) when using `validateJti()`', async function () { + const seen = new Set(); + const grantType = newGrantType({ + validateJti: async (jti, issuer) => { + const key = `${issuer}:${jti}`; + + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }, + }); + const assertion = makeAssertion(); + + await grantType.handle(confidentialRequest({ assertion }), CLIENT); + + try { + await grantType.handle(confidentialRequest({ assertion }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject a replayed assertion (same `jti`) when using `isJtiUsed()`/`recordJti()`', async function () { + const used = new Set(); + const model = baseModelImpl(); + + delete model.validateJti; + model.isJtiUsed = async (jti, issuer) => used.has(`${issuer}:${jti}`); + model.recordJti = async (jti, issuer) => { + used.add(`${issuer}:${jti}`); + }; + + const grantType = new JwtBearerGrantType({ + accessTokenLifetime: 120, + model: Model.from(model), + tokenEndpointUri: TOKEN_ENDPOINT_URI, + }); + const assertion = makeAssertion(); + + await grantType.handle(confidentialRequest({ assertion }), CLIENT); + + try { + await grantType.handle(confidentialRequest({ assertion }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should fail closed if the replay store throws', async function () { + const grantType = newGrantType({ + validateJti: async () => { + throw new Error('replay store is down'); + }, + }); + + try { + await grantType.handle(confidentialRequest({ assertion: makeAssertion() }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject the request if no user can be resolved from the assertion', async function () { + const grantType = newGrantType({ getUserFromIdJagAssertion: async () => null }); + + try { + await grantType.handle(confidentialRequest({ assertion: makeAssertion() }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should reject the request if `validateIdJagPermission()` denies it', async function () { + const grantType = newGrantType({ validateIdJagPermission: async () => false }); + + try { + await grantType.handle(confidentialRequest({ assertion: makeAssertion() }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidGrantError); + } + }); + + it('should pass the verified assertion payload to `validateIdJagPermission()`', async function () { + let seenAssertion; + const grantType = newGrantType({ + validateIdJagPermission: async (client, user, scope, assertion) => { + seenAssertion = assertion; + return true; + }, + }); + + await grantType.handle(confidentialRequest({ assertion: makeAssertion() }), CLIENT); + + seenAssertion.iss.should.equal(ISSUER); + seenAssertion.client_id.should.equal(CLIENT_ID); + }); + + it('should intersect the requested scope with the scope granted by the assertion', async function () { + const grantType = newGrantType(); + const assertion = makeAssertion({ payload: { scope: 'read write' } }); + const data = await grantType.handle(confidentialRequest({ assertion, scope: 'write' }), CLIENT); + + data.scope.should.eql(['write']); + }); + + it('should reject a request for scope broader than the assertion grants', async function () { + const grantType = newGrantType(); + const assertion = makeAssertion({ payload: { scope: 'read' } }); + + try { + await grantType.handle(confidentialRequest({ assertion, scope: 'read write' }), CLIENT); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidScopeError); + } + }); + + it('should use the assertion scope as-is when no scope is requested', async function () { + const grantType = newGrantType(); + const assertion = makeAssertion({ payload: { scope: 'read write' } }); + const data = await grantType.handle(confidentialRequest({ assertion }), CLIENT); + + data.scope.should.eql(['read', 'write']); + }); + + it('should allow the requested scope through unnarrowed when the assertion carries no scope claim', async function () { + const grantType = newGrantType(); + const assertion = makeAssertion({ payload: { scope: undefined } }); + const data = await grantType.handle(confidentialRequest({ assertion, scope: 'read' }), CLIENT); + + data.scope.should.eql(['read']); + }); + + it('should delegate to `model.validateScope()` after assertion-based narrowing', async function () { + const grantType = newGrantType({ + validateScope: async (user, client, scope) => { + scope.should.eql(['read']); + return ['read:limited']; + }, + }); + const assertion = makeAssertion({ payload: { scope: 'read' } }); + const data = await grantType.handle(confidentialRequest({ assertion }), CLIENT); + + data.scope.should.eql(['read:limited']); + }); + }); +}); diff --git a/test/unit/grant-types/jwt-bearer-grant-type_test.js b/test/unit/grant-types/jwt-bearer-grant-type_test.js new file mode 100644 index 00000000..a0b2252d --- /dev/null +++ b/test/unit/grant-types/jwt-bearer-grant-type_test.js @@ -0,0 +1,171 @@ +'use strict'; + +/** + * Module dependencies. + */ + +const crypto = require('crypto'); +const JwtBearerGrantType = require('../../../lib/grant-types/jwt-bearer-grant-type'); +const Model = require('../../../lib/model'); +const Request = require('../../../lib/request'); +const sinon = require('sinon'); +const should = require('chai').should(); + +/** + * Test `JwtBearerGrantType`. + */ + +const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); +const ISSUER = 'https://idp.example.com'; +const TOKEN_ENDPOINT_URI = 'https://rs.example.com'; +const CLIENT = { id: 'client-1', grants: ['urn:ietf:params:oauth:grant-type:jwt-bearer'] }; + +function base64url(input) { + return Buffer.from(JSON.stringify(input)).toString('base64url'); +} + +function makeAssertion(payloadOverrides = {}) { + const now = Math.floor(Date.now() / 1000); + const header = { alg: 'RS256', typ: 'oauth-id-jag+jwt', kid: 'key-1' }; + const payload = { + iss: ISSUER, + sub: 'user-1', + aud: TOKEN_ENDPOINT_URI, + client_id: CLIENT.id, + jti: `jti-${Math.random().toString(36).slice(2)}`, + exp: now + 300, + iat: now, + ...payloadOverrides, + }; + const signingInput = `${base64url(header)}.${base64url(payload)}`; + const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), privateKey); + + return `${signingInput}.${signature.toString('base64url')}`; +} + +function confidentialRequest(body) { + return new Request({ + body, + headers: { authorization: `Basic ${Buffer.from(`${CLIENT.id}:secret`).toString('base64')}` }, + method: {}, + query: {}, + }); +} + +describe('JwtBearerGrantType', function () { + describe('handle()', function () { + it('should call model methods in order with the expected arguments', async function () { + const token = { accessToken: 'foo', client: CLIENT, user: { id: 'user-1' } }; + const model = Model.from({ + getTrustedIssuer: sinon.stub().resolves({ name: 'test-idp' }), + getRequestingIssuerKey: sinon.stub().resolves(publicKey.export({ format: 'jwk' })), + getUserFromIdJagAssertion: sinon.stub().resolves({ id: 'user-1' }), + validateIdJagPermission: sinon.stub().resolves(true), + validateJti: sinon.stub().resolves(true), + saveToken: sinon.stub().resolves(token), + }); + const grantType = new JwtBearerGrantType({ + accessTokenLifetime: 120, + model, + tokenEndpointUri: TOKEN_ENDPOINT_URI, + }); + const assertion = makeAssertion(); + + const data = await grantType.handle(confidentialRequest({ assertion }), CLIENT); + + data.should.equal(token); + model.getTrustedIssuer.firstCall.args[0].should.equal(ISSUER); + model.getRequestingIssuerKey.firstCall.args.should.deep.equal([ISSUER, 'key-1']); + model.getUserFromIdJagAssertion.firstCall.args[0].should.equal(ISSUER); + model.getUserFromIdJagAssertion.firstCall.args[1].should.equal('user-1'); + model.validateJti.firstCall.args[1].should.equal(ISSUER); + model.saveToken.firstCall.args[1].should.equal(CLIENT); + model.saveToken.firstCall.args[2].should.deep.equal({ id: 'user-1' }); + }); + + it('should not call `getUserFromIdJagAssertion()` if the replay check fails', async function () { + const model = Model.from({ + getTrustedIssuer: sinon.stub().resolves({ name: 'test-idp' }), + getRequestingIssuerKey: sinon.stub().resolves(publicKey.export({ format: 'jwk' })), + getUserFromIdJagAssertion: sinon.stub().resolves({ id: 'user-1' }), + validateIdJagPermission: sinon.stub().resolves(true), + validateJti: sinon.stub().resolves(false), + saveToken: sinon.stub(), + }); + const grantType = new JwtBearerGrantType({ + accessTokenLifetime: 120, + model, + tokenEndpointUri: TOKEN_ENDPOINT_URI, + }); + + try { + await grantType.handle(confidentialRequest({ assertion: makeAssertion() }), CLIENT); + should.fail(); + } catch { + // expected + } + + model.getUserFromIdJagAssertion.called.should.equal(false); + model.saveToken.called.should.equal(false); + }); + + it('should support promises', function () { + const model = Model.from({ + getTrustedIssuer: async () => ({ name: 'test-idp' }), + getRequestingIssuerKey: async () => publicKey.export({ format: 'jwk' }), + getUserFromIdJagAssertion: async () => ({ id: 'user-1' }), + validateIdJagPermission: async () => true, + validateJti: async () => true, + saveToken: async () => ({}), + }); + const grantType = new JwtBearerGrantType({ + accessTokenLifetime: 120, + model, + tokenEndpointUri: TOKEN_ENDPOINT_URI, + }); + + grantType.handle(confidentialRequest({ assertion: makeAssertion() }), CLIENT).should.be.an.instanceOf(Promise); + }); + }); + + describe('getNarrowedScope()', function () { + it('should return the requested scope untouched if the assertion carries no scope claim', function () { + const model = Model.from({ + getTrustedIssuer: function () {}, + getRequestingIssuerKey: function () {}, + getUserFromIdJagAssertion: function () {}, + validateIdJagPermission: function () {}, + validateJti: function () {}, + saveToken: function () {}, + }); + const grantType = new JwtBearerGrantType({ + accessTokenLifetime: 120, + model, + tokenEndpointUri: TOKEN_ENDPOINT_URI, + }); + const request = new Request({ body: { scope: 'read write' }, headers: {}, method: {}, query: {} }); + + grantType.getNarrowedScope(request, {}).should.eql(['read', 'write']); + }); + }); + + describe('rejectAssertion()', function () { + it('should always throw an `InvalidGrantError`', function () { + const model = Model.from({ + getTrustedIssuer: function () {}, + getRequestingIssuerKey: function () {}, + getUserFromIdJagAssertion: function () {}, + validateIdJagPermission: function () {}, + validateJti: function () {}, + saveToken: function () {}, + }); + const grantType = new JwtBearerGrantType({ + accessTokenLifetime: 120, + model, + tokenEndpointUri: TOKEN_ENDPOINT_URI, + }); + + (() => grantType.rejectAssertion()).should.throw('Invalid grant: `assertion` is invalid'); + }); + }); +}); diff --git a/test/unit/utils/jwt-util_test.js b/test/unit/utils/jwt-util_test.js new file mode 100644 index 00000000..44454b22 --- /dev/null +++ b/test/unit/utils/jwt-util_test.js @@ -0,0 +1,135 @@ +const crypto = require('crypto'); +const jwtUtil = require('../../../lib/utils/jwt-util'); +const InvalidRequestError = require('../../../lib/errors/invalid-request-error'); +require('chai').should(); + +function base64url(input) { + return Buffer.from(JSON.stringify(input)).toString('base64url'); +} + +describe('JwtUtil', function () { + describe('decodeJwt()', function () { + it('should decode a well-formed compact JWT', function () { + const header = { alg: 'RS256', typ: 'oauth-id-jag+jwt' }; + const payload = { sub: 'user-1' }; + const token = `${base64url(header)}.${base64url(payload)}.signature`; + + const decoded = jwtUtil.decodeJwt(token); + + decoded.header.should.deep.equal(header); + decoded.payload.should.deep.equal(payload); + decoded.signingInput.should.equal(`${base64url(header)}.${base64url(payload)}`); + }); + + it('should throw InvalidRequestError if the token is not a string', function () { + (() => jwtUtil.decodeJwt(123)).should.throw(InvalidRequestError); + }); + + it('should throw InvalidRequestError if the token does not have 3 parts', function () { + (() => jwtUtil.decodeJwt('a.b')).should.throw(InvalidRequestError); + }); + + it('should throw InvalidRequestError if any part is empty', function () { + (() => jwtUtil.decodeJwt('a..c')).should.throw(InvalidRequestError); + }); + + it('should throw InvalidRequestError if the header is not valid JSON', function () { + const badHeader = Buffer.from('not-json').toString('base64url'); + const payload = base64url({ sub: 'user-1' }); + + (() => jwtUtil.decodeJwt(`${badHeader}.${payload}.sig`)).should.throw(InvalidRequestError); + }); + + it('should throw InvalidRequestError if the payload is not valid JSON', function () { + const header = base64url({ alg: 'RS256' }); + const badPayload = Buffer.from('not-json').toString('base64url'); + + (() => jwtUtil.decodeJwt(`${header}.${badPayload}.sig`)).should.throw(InvalidRequestError); + }); + }); + + describe('verifySignature()', function () { + it('should return false if `alg` is not in `allowedAlgorithms`', function () { + const { publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const key = jwtUtil.toPublicKeyObject(publicKey.export({ format: 'jwk' })); + + jwtUtil.verifySignature('input', Buffer.from('sig'), 'RS256', key, ['ES256']).should.equal(false); + }); + + it('should return false for `alg: none`', function () { + const { publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const key = jwtUtil.toPublicKeyObject(publicKey.export({ format: 'jwk' })); + + jwtUtil.verifySignature('input', Buffer.from('sig'), 'none', key, ['none', 'RS256']).should.equal(false); + }); + + it('should return true for a valid RS256 signature and false once tampered', function () { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const key = jwtUtil.toPublicKeyObject(publicKey.export({ format: 'jwk' })); + const signingInput = 'header.payload'; + const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), privateKey); + + jwtUtil.verifySignature(signingInput, signature, 'RS256', key, ['RS256']).should.equal(true); + + const tampered = Buffer.from(signature); + tampered[0] ^= 0xff; + + jwtUtil.verifySignature(signingInput, tampered, 'RS256', key, ['RS256']).should.equal(false); + }); + + it('should return true for a valid ES256 signature', function () { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const key = jwtUtil.toPublicKeyObject(publicKey.export({ format: 'jwk' })); + const signingInput = 'header.payload'; + const signature = crypto.sign(null, Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' }); + + jwtUtil.verifySignature(signingInput, signature, 'ES256', key, ['ES256']).should.equal(true); + }); + + it('should return true for a valid PS256 signature', function () { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const key = jwtUtil.toPublicKeyObject(publicKey.export({ format: 'jwk' })); + const signingInput = 'header.payload'; + const signature = crypto.sign('sha256', Buffer.from(signingInput), { + key: privateKey, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST, + }); + + jwtUtil.verifySignature(signingInput, signature, 'PS256', key, ['PS256']).should.equal(true); + }); + + it('should return false, not throw, for malformed key/signature material', function () { + const { publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const key = jwtUtil.toPublicKeyObject(publicKey.export({ format: 'jwk' })); + + jwtUtil.verifySignature('input', Buffer.from([1, 2, 3]), 'RS256', key, ['RS256']).should.equal(false); + }); + }); + + describe('toPublicKeyObject()', function () { + it('should pass through an existing KeyObject', function () { + const { publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + + jwtUtil.toPublicKeyObject(publicKey).should.equal(publicKey); + }); + + it('should parse a PEM string', function () { + const { publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const pem = publicKey.export({ type: 'spki', format: 'pem' }); + + jwtUtil.toPublicKeyObject(pem).should.be.an.instanceOf(crypto.KeyObject); + }); + + it('should parse a JWK object', function () { + const { publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const jwk = publicKey.export({ format: 'jwk' }); + + jwtUtil.toPublicKeyObject(jwk).should.be.an.instanceOf(crypto.KeyObject); + }); + + it('should throw for unresolvable key material', function () { + (() => jwtUtil.toPublicKeyObject(42)).should.throw('Unresolvable signing key'); + }); + }); +});