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) => { 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 {