Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added simple PKCE and state checks utils, used PKCE and state checks in auth0 #12

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions playground/server/routes/auth/auth0.get.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export default oauth.auth0EventHandler({
config: {
emailRequired: true,
checks: ['state']
},
async onSuccess(event, { user }) {
await setUserSession(event, {
Expand Down
24 changes: 22 additions & 2 deletions src/runtime/server/lib/oauth/auth0.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { H3Event } from 'h3'
import type { H3Event, H3Error } from 'h3'
import { eventHandler, createError, getQuery, getRequestURL, sendRedirect } from 'h3'
import { withQuery, parsePath } from 'ufo'
import { ofetch } from 'ofetch'
import { defu } from 'defu'
import { useRuntimeConfig } from '#imports'
import type { OAuthConfig } from '#auth-utils'
import { type OAuthChecks, checks } from '../../utils/security'

export interface OAuthAuth0Config {
/**
Expand All @@ -24,7 +25,7 @@ export interface OAuthAuth0Config {
domain?: string
/**
* Auth0 OAuth Audience
* @default process.env.NUXT_OAUTH_AUTH0_AUDIENCE
* @default ''
*/
audience?: string
/**
Expand All @@ -39,6 +40,13 @@ export interface OAuthAuth0Config {
* @default false
*/
emailRequired?: boolean
/**
* checks
* @default []
* @see https://auth0.com/docs/flows/authorization-code-flow-with-proof-key-for-code-exchange-pkce
* @see https://auth0.com/docs/protocols/oauth2/oauth-state
*/
checks?: OAuthChecks[]
}

export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig<OAuthAuth0Config>) {
Expand All @@ -60,6 +68,7 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig<OA

const redirectUrl = getRequestURL(event).href
if (!code) {
const authParam = await checks.create(event, config.checks) // Initialize checks
config.scope = config.scope || ['openid', 'offline_access']
if (config.emailRequired && !config.scope.includes('email')) {
config.scope.push('email')
Expand All @@ -73,10 +82,20 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig<OA
redirect_uri: redirectUrl,
scope: config.scope.join(' '),
audience: config.audience || '',
...authParam
})
)
}

// Verify checks
let checkResult
try {
checkResult = await checks.use(event, config.checks)
} catch (error) {
if (!onError) throw error
return onError(event, error as H3Error)
}

const tokens: any = await ofetch(
tokenURL as string,
{
Expand All @@ -90,6 +109,7 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig<OA
client_secret: config.clientSecret,
redirect_uri: parsePath(redirectUrl).pathname,
code,
...checkResult
}
}
).catch(error => {
Expand Down
117 changes: 117 additions & 0 deletions src/runtime/server/utils/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { H3Event } from 'h3'
import { subtle, getRandomValues } from 'uncrypto'

export type OAuthChecks = 'pkce' | 'state'

// From oauth4webapi https://github.com/panva/oauth4webapi/blob/4b46a7b4a4ca77a513774c94b718592fe3ad576f/src/index.ts#L567C1-L579C2
const CHUNK_SIZE = 0x8000
export function encodeBase64Url(input: Uint8Array | ArrayBuffer) {
if (input instanceof ArrayBuffer) {
input = new Uint8Array(input)
}

const arr = []
for (let i = 0; i < input.byteLength; i += CHUNK_SIZE) {
// @ts-expect-error
arr.push(String.fromCharCode.apply(null, input.subarray(i, i + CHUNK_SIZE)))
}
return btoa(arr.join('')).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
}

function randomBytes() {
return encodeBase64Url(getRandomValues(new Uint8Array(32)))
}

/**
* Generate a random `code_verifier` for use in the PKCE flow
* @see https://tools.ietf.org/html/rfc7636#section-4.1
*/
export function generateCodeVerifier() {
return randomBytes()
}

/**
* Generate a random `state` used to prevent CSRF attacks
* @see https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.1
*/
export function generateState() {
return randomBytes()
}

/**
* Generate a `code_challenge` from a `code_verifier` for use in the PKCE flow
* @param verifier `code_verifier` string
* @returns `code_challenge` string
* @see https://tools.ietf.org/html/rfc7636#section-4.1
*/
export async function pkceCodeChallenge(verifier: string) {
return encodeBase64Url(await subtle.digest({ name: 'SHA-256' }, new TextEncoder().encode(verifier)))
}

interface CheckUseResult {
code_verifier?: string
}
/**
* Checks for PKCE and state
*/
export const checks = {
/**
* Create checks
* @param event, H3Event
* @param checks, OAuthChecks[] a list of checks to create
* @returns Record<string, string> a map of check parameters to add to the authorization URL
*/
async create(event: H3Event, checks?: OAuthChecks[]) {
const res: Record<string, string> = {}
if (checks?.includes('pkce')) {
const pkceVerifier = generateCodeVerifier()
const pkceChallenge = await pkceCodeChallenge(pkceVerifier)
console.log('pkceVerifier', pkceVerifier)
Copy link
Contributor

Choose a reason for hiding this comment

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

console.log leftover

console.log('pkceChallenge', pkceChallenge)
res['code_challenge'] = pkceChallenge
res['code_challenge_method'] = 'S256'
setCookie(event, 'nuxt-auth-util-verifier', pkceVerifier, { maxAge: 60 * 15, secure: true, httpOnly: true, sameSite: 'lax' })
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the cookie settings should configurable or reuse the cookie settings from the module

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I didn't reuse the cookie settings because they were under the session key, I don't know if it would be confusing to reuse that or not. But I agree that a shared cookie config somewhere would be nice.

Copy link
Contributor

Choose a reason for hiding this comment

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

I agree it would be a bit confusing. Maybe an optional config for pkce cookie?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a cookie setting (runtimeConfig), under nuxtAuthConfig.security.cookie

}
if (checks?.includes('state')) {
res['state'] = generateState()
setCookie(event, 'nuxt-auth-util-state', res['state'], { maxAge: 60 * 15, secure: true, httpOnly: true, sameSite: 'lax' })
}
return res
},
/**
* Use checks, verifying and returning the results
* @param event, H3Event
* @param checks, OAuthChecks[] a list of checks to use
* @returns CheckUseResult a map that can contain `code_verifier` if `pkce` was used to be used in the token exchange
*/
async use(event: H3Event, checks?: OAuthChecks[]) : Promise<CheckUseResult> {
const res: CheckUseResult = {}
const { state } = getQuery(event)
if (checks?.includes('pkce')) {
const pkceVerifier = getCookie(event, 'nuxt-auth-util-verifier')
setCookie(event, 'nuxt-auth-util-verifier', '', { maxAge: -1 })
res['code_verifier'] = pkceVerifier
}
if (checks?.includes('state')) {
const stateInCookie = getCookie(event, 'nuxt-auth-util-state')
setCookie(event, 'nuxt-auth-util-state', '', { maxAge: -1 })
if (checks?.includes('state')) {
if (!state || !stateInCookie) {
const error = createError({
statusCode: 401,
message: 'Auth0 login failed: state is missing'
Copy link
Contributor

Choose a reason for hiding this comment

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

This message is still specific to Auth0

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch, I updated this

})
throw error
}
if (state !== stateInCookie) {
const error = createError({
statusCode: 401,
message: 'Auth0 login failed: state does not match'
Copy link
Contributor

Choose a reason for hiding this comment

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

This message is still specific to Auth0

})
throw error
}
}
}
return res
},
}