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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/guide/grant-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
15 changes: 15 additions & 0 deletions docs/guide/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
160 changes: 160 additions & 0 deletions examples/express-id-jag-server.js
Original file line number Diff line number Diff line change
@@ -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 };
89 changes: 88 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ declare namespace OAuth2Server {
/**
* Model object
*/
model: AuthorizationCodeModel | ClientCredentialsModel | RefreshTokenModel | PasswordModel | ExtensionModel;
model: AuthorizationCodeModel | ClientCredentialsModel | RefreshTokenModel | PasswordModel | JwtBearerModel | ExtensionModel;
}

interface AuthenticateOptions {
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -397,6 +420,70 @@ declare namespace OAuth2Server {
validateScope?(user: User, client: Client, scope?: string[]): Promise<string[] | Falsey>;
}

/**
* 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<object | Falsey>;

/**
* 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<string | object | Falsey>;

/**
* 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<User | Falsey>;

/**
* 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<boolean>;

/**
* 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<boolean>;

/**
* 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<boolean>;

/**
* 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<void>;
}

interface ExtensionModel extends BaseModel, RequestAuthenticationModel {}

/**
Expand Down
Loading
Loading