From 4c52355c57cf8ab3622e408c1ea0a56f56d055f0 Mon Sep 17 00:00:00 2001 From: Anon <206556099+An0n-01@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:40:32 +0100 Subject: [PATCH 1/2] feat: Adds support for Pushed Authorization Requests (PAR) Implements support for Pushed Authorization Requests (PAR) according to RFC9126. This enhances security by allowing clients to push authorization request parameters directly to the authorization server via a backchannel POST request, receiving a request_uri to use in the subsequent authorization request. Prevents parameter tampering and reduces exposure in browser URLs. --- index.js | 168 +++++++++++++++++++++++++++++++++++++++++++-- test/index.test.js | 59 ++++++++++++++++ types/index.d.ts | 6 ++ 3 files changed, 226 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index 918530c..cfeca6a 100644 --- a/index.js +++ b/index.js @@ -117,6 +117,14 @@ function fastifyOauth2 (fastify, options, next) { new Error('options.redirectStateCookieName should be a string') ) } + if (options.usePushedAuthorizationRequests && !options.discovery) { + if (!options.credentials.auth?.parPath) { + return next(new Error('options.credentials.auth.parPath is required when usePushedAuthorizationRequests is enabled without discovery')) + } + } + if (options.parRequestParams && typeof options.parRequestParams !== 'object') { + return next(new Error('options.parRequestParams should be an object')) + } if (!fastify.hasReplyDecorator('cookie')) { fastify.register(require('@fastify/cookie')) } @@ -126,6 +134,62 @@ function fastifyOauth2 (fastify, options, next) { ? undefined : (options.userAgent || USER_AGENT) + function pushAuthorizationRequest (parPath, parHost, params, credentials, httpHeaders, callback) { + const parUrl = new URL(parPath, parHost) + + const body = new URLSearchParams() + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null) { + body.append(k, Array.isArray(v) ? v.join(' ') : String(v)) + } + }) + + // Add client authentication + const auth = Buffer.from(`${credentials.client.id}:${credentials.client.secret}`).toString('base64') + + const httpOpts = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${auth}`, + ...httpHeaders + } + } + + const aClient = (parHost.startsWith('https://') ? https : http) + const req = aClient.request(parUrl, httpOpts, onParResponse) + .on('error', errHandler) + + req.write(body.toString()) + req.end() + + function onParResponse (res) { + let rawData = '' + res.on('data', (chunk) => { rawData += chunk }) + res.on('end', () => { + try { + const data = JSON.parse(rawData) + if (res.statusCode >= 200 && res.statusCode < 300) { + callback(null, data) + } else { + const err = new Error(`PAR request failed: ${data.error_description || data.error || 'Unknown error'}`) + err.statusCode = res.statusCode + err.data = data + callback(err) + } + } catch (err) { + callback(err) + } + }) + } + + function errHandler (e) { + const err = new Error('Problem calling PAR endpoint. See innerError for details.') + err.innerError = e + callback(err) + } + } + const configure = (configured, fetchedMetadata) => { const { name, @@ -156,6 +220,25 @@ function fastifyOauth2 (fastify, options, next) { } } } + + // NEW: Extract PAR configuration before passing to simple-oauth2 + const parConfig = { + parPath: credentials.auth?.parPath, + parHost: credentials.auth?.parHost || credentials.auth?.tokenHost || credentials.auth?.authorizeHost + } + + // NEW: Create credentials without PAR fields for simple-oauth2 + const oauth2Credentials = { + ...configured.credentials, + auth: { + ...configured.credentials.auth + } + } + + // Remove PAR-specific fields from auth config + delete oauth2Credentials.auth.parPath + delete oauth2Credentials.auth.parHost + const generateCallbackUriParams = credentials.auth?.[kGenerateCallbackUriParams] || defaultGenerateCallbackUriParams const cookieOpts = Object.assign({ httpOnly: true, sameSite: 'lax' }, options.cookie) @@ -192,13 +275,55 @@ function fastifyOauth2 (fastify, options, next) { reply.setCookie(verifierCookieName, verifier, cookieOpts) } - const urlOptions = Object.assign({}, generateCallbackUriParams(callbackUriParams, request, scope, state), { - redirect_uri: typeof callbackUri === 'function' ? callbackUri(request) : callbackUri, - scope, - state - }, pkceParams) + // Use PAR if enabled + if (configured.usePushedAuthorizationRequests) { + // Parameters to send to PAR endpoint + const baseParams = Object.assign({}, generateCallbackUriParams(callbackUriParams, request, scope, state), { + redirect_uri: typeof callbackUri === 'function' ? callbackUri(request) : callbackUri, + scope: Array.isArray(scope) ? scope.join(' ') : scope, + state, + response_type: 'code', + client_id: credentials.client.id + }, pkceParams, configured.parRequestParams || {}) + + const httpHeaders = { + ...credentials.http?.headers + } + + if (userAgent) { + httpHeaders['User-Agent'] = userAgent + } + + if (omitUserAgent) { + delete httpHeaders['User-Agent'] + } + + pushAuthorizationRequest(parConfig.parPath, parConfig.parHost, baseParams, credentials, httpHeaders, function (err, parResponse) { + if (err) { + callback(err, null) + return + } + + // Build authorization URL with just client_id and request_uri + // We need to construct the URL manually to avoid simple-oauth2 adding unwanted parameters + const authorizeHost = credentials.auth?.authorizeHost || credentials.auth?.tokenHost + const authorizePath = credentials.auth?.authorizePath || '/oauth/authorize' + const authUrl = new URL(authorizePath, authorizeHost) + authUrl.searchParams.set('client_id', credentials.client.id) + authUrl.searchParams.set('request_uri', parResponse.request_uri) - callback(null, oauth2.authorizeURL(urlOptions)) + callback(null, authUrl.toString()) + }) + } else { + // Traditional flow without PAR + const urlOptions = Object.assign({}, generateCallbackUriParams(callbackUriParams, request, scope, state), { + redirect_uri: typeof callbackUri === 'function' ? callbackUri(request) : callbackUri, + scope, + state + }, pkceParams) + + callback(null, oauth2.authorizeURL(urlOptions)) + } }) } @@ -372,7 +497,7 @@ function fastifyOauth2 (fastify, options, next) { fetchUserInfo(fetchedMetadata.userinfo_endpoint, token, { method: _method, params, via }, callback) } - const oauth2 = new AuthorizationCode(configured.credentials) + const oauth2 = new AuthorizationCode(oauth2Credentials) if (startRedirectPath) { fastify.get(startRedirectPath, { schema }, startRedirectHandler) @@ -417,6 +542,20 @@ function fastifyOauth2 (fastify, options, next) { // otherwise select optimal pkce method for them, discoveredOptions.pkce = selectPkceFromMetadata(fetchedMetadata) } + + // if the provider requires pushed authorization requests and the user didn't explicitly disable it, enable it for them + if (options.usePushedAuthorizationRequests === true || + (fetchedMetadata.require_pushed_authorization_requests && + options.usePushedAuthorizationRequests !== false)) { + discoveredOptions.usePushedAuthorizationRequests = true + + // Validate that PAR endpoint was discovered + if (!authFromMetadata.parPath) { + next(new Error('PAR is enabled but pushed_authorization_request_endpoint was not found in discovery metadata')) + return + } + } + configure(discoveredOptions, fetchedMetadata) next() }) @@ -588,6 +727,21 @@ function getAuthFromMetadata (metadata) { processedResponse.revokePath = path } + /* + pushed_authorization_request_endpoint + OPTIONAL. URL of the authorization server's pushed authorization + request endpoint [RFC9126]. This endpoint allows clients to push + authorization request parameters directly to the authorization server + via a backchannel POST request, receiving a request_uri to use in + the subsequent authorization request. Enhances security by preventing + parameter tampering and reducing exposure in browser URLs. + */ + if (metadata.pushed_authorization_request_endpoint) { + const { path, host } = formatEndpoint(metadata.pushed_authorization_request_endpoint) + processedResponse.parPath = path + processedResponse.parHost = host + } + return processedResponse } diff --git a/test/index.test.js b/test/index.test.js index 2058c3a..ad0646b 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -3057,3 +3057,62 @@ test('options.verifierCookieName', async (t) => { ) }) }) +test('PAR - Pushed Authorization Requests', async t => { + await t.test('options.usePushedAuthorizationRequests should be a boolean', t => { + t.plan(1) + const fastify = createFastify({ logger: { level: 'silent' } }) + + return t.assert.rejects(fastify.register(fastifyOauth2, { + name: 'the-name', + credentials: { + client: { + id: 'my-client-id', + secret: 'my-secret' + }, + auth: fastifyOauth2.GITHUB_CONFIGURATION + }, + callbackUri: '/callback', + usePushedAuthorizationRequests: 'invalid' + }) + .ready(), undefined, 'options.usePushedAuthorizationRequests should be a boolean') + }) + + await t.test('options.parRequestParams should be an object', t => { + t.plan(1) + const fastify = createFastify({ logger: { level: 'silent' } }) + + return t.assert.rejects(fastify.register(fastifyOauth2, { + name: 'the-name', + credentials: { + client: { + id: 'my-client-id', + secret: 'my-secret' + }, + auth: fastifyOauth2.GITHUB_CONFIGURATION + }, + callbackUri: '/callback', + usePushedAuthorizationRequests: true, + parRequestParams: 'invalid' + }) + .ready(), undefined, 'options.parRequestParams should be an object') + }) + + await t.test('PAR requires parPath when enabled', t => { + t.plan(1) + const fastify = createFastify({ logger: { level: 'silent' } }) + + return t.assert.rejects(fastify.register(fastifyOauth2, { + name: 'the-name', + credentials: { + client: { + id: 'my-client-id', + secret: 'my-secret' + }, + auth: fastifyOauth2.GITHUB_CONFIGURATION + }, + callbackUri: '/callback', + usePushedAuthorizationRequests: true + }) + .ready(), undefined, 'options.credentials.auth.parPath is required when usePushedAuthorizationRequests is enabled') + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index c35133e..82a7e7e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -49,6 +49,8 @@ declare namespace fastifyOauth2 { discovery?: { issuer: string; } redirectStateCookieName?: string; verifierCookieName?: string; + usePushedAuthorizationRequests?: boolean; + parRequestParams?: Object; } export type TToken = 'access_token' | 'refresh_token' @@ -97,6 +99,10 @@ declare namespace fastifyOauth2 { authorizeHost?: string | undefined; /** String path to request an authorization code. Default to /oauth/authorize. */ authorizePath?: string | undefined; + /** String used to set the host to request pushed authorization requests. If par enabled, default to the value set on auth.tokenHost. */ + parHost?: string | undefined; + /** String path to request pushed authorization requests. Default to /oauth/par. */ + parPath?: string | undefined; } export interface Credentials { From 3e303620f50ce3be34a7c426c167393378238e96 Mon Sep 17 00:00:00 2001 From: Anon <206556099+An0n-01@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:48:32 +0100 Subject: [PATCH 2/2] feat: Add Pushed Authorization Requests (PAR) support to README --- README.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7701822..32f463f 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,139 @@ Important notes for discovery: - When your provider supports it, plugin will also select appropriate PKCE method in authorization code grant - In case you still want to select method yourself, and know exactly what you are doing; you can still do it explicitly. +## Pushed Authorization Requests (PAR) + +[RFC 9126](https://www.rfc-editor.org/rfc/rfc9126.html) defines Pushed Authorization Requests (PAR), an enhancement to OAuth 2.0 that improves security by moving authorization request parameters from the front-channel (browser URL) to a back-channel (direct server-to-server) communication. + +### Benefits of PAR + +- **Enhanced Security**: Authorization parameters are sent directly from your server to the authorization server via a secure back-channel, preventing parameter tampering and exposure in browser URLs +- **Reduced URL Length**: Only a short-lived `request_uri` is passed in the authorization redirect, avoiding issues with long URLs +- **Parameter Integrity**: Authorization parameters cannot be modified by the end-user or intermediaries + +### Usage with Discovery + +When using OpenID Connect Discovery, PAR support can be automatically detected: + +```js +fastify.register(oauthPlugin, { + name: 'secureOAuth2', + scope: ['profile', 'email'], + credentials: { + client: { + id: '', + secret: '', + }, + }, + startRedirectPath: '/login', + callbackUri: 'http://localhost:3000/callback', + discovery: { issuer: 'https://identity.mycustomdomain.com' }, + // Explicitly enable PAR (optional if provider supports it) + usePushedAuthorizationRequests: true +}); +``` + +**Automatic PAR enablement:** +- If the authorization server advertises `require_pushed_authorization_requests: true` in its discovery metadata, PAR will be automatically enabled unless you explicitly set `usePushedAuthorizationRequests: false` +- The `pushed_authorization_request_endpoint` is automatically discovered from the metadata + +### Usage without Discovery + +For providers that support PAR but don't provide OpenID Connect Discovery, you can configure it manually: + +```js +fastify.register(oauthPlugin, { + name: 'customOauth2', + credentials: { + client: { + id: '', + secret: '' + }, + auth: { + authorizeHost: 'https://my-site.com', + authorizePath: '/authorize', + tokenHost: 'https://token.my-site.com', + tokenPath: '/api/token', + // PAR endpoint configuration + parHost: 'https://my-site.com', + parPath: '/oauth/par' + } + }, + startRedirectPath: '/login', + callbackUri: 'http://localhost:3000/login/callback', + // Enable PAR + usePushedAuthorizationRequests: true +}); +``` + +### Additional PAR Parameters + +You can pass additional parameters to the PAR endpoint using `parRequestParams`: + +```js +fastify.register(oauthPlugin, { + name: 'customOauth2', + credentials: { + client: { + id: '', + secret: '' + }, + auth: { + authorizeHost: 'https://my-site.com', + authorizePath: '/authorize', + tokenHost: 'https://token.my-site.com', + tokenPath: '/api/token', + parHost: 'https://my-site.com', + parPath: '/oauth/par' + } + }, + startRedirectPath: '/login', + callbackUri: 'http://localhost:3000/login/callback', + usePushedAuthorizationRequests: true, + // Additional parameters for PAR request + parRequestParams: { + resource: 'https://api.example.com', + audience: 'https://api.example.com' + } +}); +``` + +### PAR with PKCE + +PAR works seamlessly with PKCE for even stronger security: + +```js +fastify.register(oauthPlugin, { + name: 'secureOAuth2', + credentials: { + client: { + id: '', + secret: '' + }, + auth: { + authorizeHost: 'https://my-site.com', + authorizePath: '/authorize', + tokenHost: 'https://token.my-site.com', + tokenPath: '/api/token', + parHost: 'https://my-site.com', + parPath: '/oauth/par' + } + }, + startRedirectPath: '/login', + callbackUri: 'http://localhost:3000/login/callback', + usePushedAuthorizationRequests: true, + pkce: 'S256' +}); +``` + +**Important notes for PAR:** + +- PAR requires client authentication at the PAR endpoint (client_id and client_secret are sent via Basic Authentication) +- The PAR endpoint returns a `request_uri` that expires quickly (typically 60-90 seconds) +- Only `client_id` and `request_uri` are included in the authorization redirect URL +- When using discovery, the PAR endpoint is automatically configured from `pushed_authorization_request_endpoint` in the metadata +- If `parHost` is not explicitly provided, it defaults to the `tokenHost` or `authorizeHost` + ### Schema configuration You can specify your own schema for the `startRedirectPath` end-point. It allows you to create a well-documented document when using `@fastify/swagger` together. @@ -320,7 +453,7 @@ fastify.register(oauthPlugin, { ## Set custom tokenRequest body Parameters The `tokenRequestParams` parameter accepts an object that will be translated to additional parameters in the POST body -when requesting access tokens via the service’s token endpoint. +when requesting access tokens via the service's token endpoint. ## Examples @@ -367,7 +500,6 @@ This fastify plugin adds 6 utility decorators to your fastify instance using the *Important to note*: if your provider supports `S256` as code_challenge_method, always prefer that. Only use `plain` when your provider doesn't support `S256`. - - `getNewAccessTokenUsingRefreshToken(Token, params, callback)`: A function that takes a `AccessToken`-Object as `Token` and retrieves a new `AccessToken`-Object. This is generally useful with background processing workers to re-issue a new AccessToken when the previous AccessToken has expired. The `params` argument is optional and it is an object that can be used to pass in additional parameters to the refresh request (e.g. a stricter set of scopes). If the callback is not passed this function will return a Promise. The object resulting from the callback call or the resolved Promise is a new `AccessToken` object (see above). Example of how you would use it for `name:googleOAuth2`: ```js fastify.googleOAuth2.getNewAccessTokenUsingRefreshToken(currentAccessToken, (err, newAccessToken) => {