Skip to content

Commit

Permalink
feat(oidc): Support form-urlencoded token requests
Browse files Browse the repository at this point in the history
Support application/x-www-form-urlencoded token requests
  • Loading branch information
DallasHoff authored Apr 13, 2024
1 parent 123d24f commit 30ffe23
Show file tree
Hide file tree
Showing 4 changed files with 45 additions and 11 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ You can theoretically register a hook that overwrites internal session fields li
| optionalClaims | `string[]` (optional) | - | Claims to be extracted from the id token |
| logoutUrl | `string` (optional) | '' | Logout endpoint URL |
| scopeInTokenRequest | `boolean` (optional) | false | Include scope in token request |
| tokenRequestType | `'form'` \| `'json'` (optional) | `'form'` | Token request type |
| tokenRequestType | `'form'` \| `'form-urlencoded'` \| `'json'` (optional) | `'form'` | Token request type |
| audience | `string` (optional) | - | Audience used for token validation (not included in requests by default, use additionalTokenParameters or additionalAuthParameters to add it) |
| requiredProperties | `string`[] | - | Required properties of the configuration that will be validated at runtime. |
| filterUserinfo | `string[]`(optional) | - | Filter userinfo response to only include these properties. |
Expand Down
13 changes: 7 additions & 6 deletions src/runtime/server/lib/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useRuntimeConfig, useStorage } from '#imports'
import { validateConfig } from '../utils/config'
import { generateRandomUrlSafeString, generatePkceVerifier, generatePkceCodeChallenge, parseJwtToken, encryptToken, validateToken, genBase64FromString } from '../utils/security'
import { getUserSessionId, clearUserSession } from '../utils/session'
import { configMerger, convertObjectToSnakeCase, generateFormDataRequest, oidcErrorHandler, useOidcLogger } from '../utils/oidc'
import { configMerger, convertObjectToSnakeCase, convertTokenRequestToType, getTokenRequestContentType, oidcErrorHandler, useOidcLogger } from '../utils/oidc'
import { SignJWT } from 'jose'
import * as providerPresets from '../../providers'
import type { H3Event } from 'h3'
Expand Down Expand Up @@ -135,6 +135,9 @@ export function callbackEventHandler({ onSuccess, onError }: OAuthConfig<UserSes
headers.authorization = `Basic ${encodedCredentials}`
}

// Set Content-Type header
headers['content-type'] = getTokenRequestContentType(config.tokenRequestType ?? undefined)

// Construct form data for token request
const requestBody: TokenRequest = {
client_id: config.clientId,
Expand All @@ -147,8 +150,6 @@ export function callbackEventHandler({ onSuccess, onError }: OAuthConfig<UserSes
...config.additionalTokenParameters && convertObjectToSnakeCase(config.additionalTokenParameters),
}

const requestForm = generateFormDataRequest(requestBody)

// Make token request
let tokenResponse: TokenRespose
try {
Expand All @@ -157,15 +158,15 @@ export function callbackEventHandler({ onSuccess, onError }: OAuthConfig<UserSes
{
method: 'POST',
headers,
body: config.tokenRequestType === 'json' ? requestBody : requestForm
body: convertTokenRequestToType(requestBody, config.tokenRequestType ?? undefined)
}
)
} catch (error: any) {
// Log ofetch error data to console
logger.error(error.data)
logger.error(error?.data ?? error)

// Handle Microsoft consent_required error
if (error.data.suberror === 'consent_required') {
if (error?.data?.suberror === 'consent_required') {
const consentUrl = `https://login.microsoftonline.com/${parseURL(config.authorizationUrl).pathname.split('/')[1]}/adminconsent?client_id=${config.clientId}`
return sendRedirect(
event,
Expand Down
39 changes: 36 additions & 3 deletions src/runtime/server/utils/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export async function refreshAccessToken(refreshToken: string, config: OidcProvi
const encodedCredentials = genBase64FromString(`${config.clientId}:${config.clientSecret}`)
headers.authorization = `Basic ${encodedCredentials}`
}

// Set Content-Type header
headers['content-type'] = getTokenRequestContentType(config.tokenRequestType)

// Construct form data for refresh token request
const requestBody: RefreshTokenRequest = {
Expand All @@ -40,7 +43,6 @@ export async function refreshAccessToken(refreshToken: string, config: OidcProvi
...(config.scopeInTokenRequest && config.scope) && { scope: config.scope.join(' ') },
...(config.authenticationScheme === 'body') && { client_secret: normalizeURL(config.clientSecret) }
}
const requestForm = generateFormDataRequest(requestBody)

// Make refresh token request
let tokenResponse: TokenRespose
Expand All @@ -50,11 +52,11 @@ export async function refreshAccessToken(refreshToken: string, config: OidcProvi
{
method: 'POST',
headers,
body: config.tokenRequestType === 'json' ? requestBody : requestForm,
body: convertTokenRequestToType(requestBody, config.tokenRequestType)
}
)
} catch (error: any) {
logger.error(error.data) // Log ofetch error data to console
logger.error(error?.data ?? error) // Log ofetch error data to console
throw new Error('Failed to refresh token')
}

Expand Down Expand Up @@ -100,6 +102,37 @@ export function generateFormDataRequest(requestValues: RefreshTokenRequest | Tok
return requestBody
}

export function generateFormUrlEncodedRequest(requestValues: RefreshTokenRequest | TokenRequest) {
const requestEntries = Object.entries(requestValues).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
return new URLSearchParams(requestEntries).toString()
}

export function convertTokenRequestToType(
requestValues: RefreshTokenRequest | TokenRequest,
requestType: OidcProviderConfig['tokenRequestType'] = 'form',
) {
switch (requestType) {
case 'json':
return requestValues
case 'form-urlencoded':
return generateFormUrlEncodedRequest(requestValues)
default:
return generateFormDataRequest(requestValues)
}
}

export function getTokenRequestContentType(
requestType: OidcProviderConfig['tokenRequestType'] = 'form',
) {
return (
{
json: 'application/json',
form: 'multipart/form-data',
'form-urlencoded': 'application/x-www-form-urlencoded',
}[requestType]
)
}

export function convertObjectToSnakeCase(object: Record<string, any>) {
return Object.entries(object).reduce((acc, [key, value]) => {
acc[snakeCase(key)] = value
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/types/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export interface OidcProviderConfig {
* Token request type
* @default 'form'
*/
tokenRequestType?: 'form' | 'json'
tokenRequestType?: 'form' | 'json' | 'form-urlencoded'
/**
* Audience used for token validation (not included in requests by default, use additionalTokenParameters or additionalAuthParameters to add it)
*/
Expand Down

4 comments on commit 30ffe23

@hederson
Copy link

Choose a reason for hiding this comment

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

First @DallasHoff I'd like to thank you for this feat.

@itpropro when do you plan to generate a new version with this feat? I'm using a OIDC provider that supports only form-urlencoded.

Thank you!

@itpropro
Copy link
Owner

Choose a reason for hiding this comment

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

Planning to release this week, just some small things in the pipe that will go put with it!

@hederson
Copy link

Choose a reason for hiding this comment

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

@itpropro do you have some roadmap for the features? I'd like to contribute with this project

@itpropro
Copy link
Owner

Choose a reason for hiding this comment

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

@itpropro do you have some roadmap for the features? I'd like to contribute with this project

You are very welcome to contribute! I am mainly looking into adding more providers, but everything I am currently working on actively is an open issue.
If you have any questions, feel free to ask or just create a draft PR for a feature you plan to add.

Please sign in to comment.