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
136 changes: 134 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<CLIENT_ID>',
secret: '<CLIENT_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: '<CLIENT_ID>',
secret: '<CLIENT_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: '<CLIENT_ID>',
secret: '<CLIENT_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: '<CLIENT_ID>',
secret: '<CLIENT_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.
Expand Down Expand Up @@ -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 services token endpoint.
when requesting access tokens via the service's token endpoint.

## Examples

Expand Down Expand Up @@ -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) => {
Expand Down
168 changes: 161 additions & 7 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
}
Expand All @@ -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))
}
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use a for(;;) loop


// 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 })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you need to set the encoding correctly

res.on('end', () => {
try {
const data = JSON.parse(rawData)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use secure-json-parse here?

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'}`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use fastify-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.')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use fastify-error

err.innerError = e
callback(err)
}
}

const configure = (configured, fetchedMetadata) => {
const {
name,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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))
}
})
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fastify-error

return
}
}

configure(discoveredOptions, fetchedMetadata)
next()
})
Expand Down Expand Up @@ -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
}

Expand Down
Loading