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
10 changes: 10 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ export default defineNuxtModule<ModuleOptions>({
sameSite: 'lax'
}
})
// Security settings
runtimeConfig.nuxtAuthUtils = defu(runtimeConfig.nuxtAuthUtils, {})
runtimeConfig.nuxtAuthUtils.security = defu(runtimeConfig.nuxtAuthUtils.security, {
cookie: {
secure: true,
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 15
}
})
// OAuth settings
runtimeConfig.oauth = defu(runtimeConfig.oauth, {})
// GitHub OAuth
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 @@ -45,6 +46,13 @@ export interface OAuthAuth0Config {
* @see https://auth0.com/docs/authenticate/login/max-age-reauthentication
*/
maxAge?: number
/**
* 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 @@ -66,6 +74,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 @@ -80,10 +89,20 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig<OA
scope: config.scope.join(' '),
audience: config.audience || '',
max_age: config.maxAge || 0,
...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 @@ -97,6 +116,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, setCookie, getCookie, getQuery, createError } from 'h3'
import { subtle, getRandomValues } from 'uncrypto'
import { useRuntimeConfig } from '#imports'

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> = {}
const runtimeConfig = useRuntimeConfig()
if (checks?.includes('pkce')) {
const pkceVerifier = generateCodeVerifier()
const pkceChallenge = await pkceCodeChallenge(pkceVerifier)
res['code_challenge'] = pkceChallenge
res['code_challenge_method'] = 'S256'
setCookie(event, 'nuxt-auth-util-verifier', pkceVerifier, runtimeConfig.nuxtAuthUtils.security.cookie)

Check failure on line 73 in src/runtime/server/utils/security.ts

View workflow job for this annotation

GitHub Actions / test

Argument of type '{ secure: boolean; httpOnly: boolean; sameSite: string; maxAge: number; }' is not assignable to parameter of type 'CookieSerializeOptions'.
}
if (checks?.includes('state')) {
res['state'] = generateState()
setCookie(event, 'nuxt-auth-util-state', res['state'], runtimeConfig.nuxtAuthUtils.security.cookie)

Check failure on line 77 in src/runtime/server/utils/security.ts

View workflow job for this annotation

GitHub Actions / test

Argument of type '{ secure: boolean; httpOnly: boolean; sameSite: string; maxAge: number; }' is not assignable to parameter of type 'CookieSerializeOptions'.
}
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: 'Login failed: state is missing'
})
throw error
}
if (state !== stateInCookie) {
const error = createError({
statusCode: 401,
message: 'Login failed: state does not match'
})
throw error
}
}
}
return res
},
}
Loading