Skip to content

Commit

Permalink
refactor: moved checks to a separate security util, replaced crypto b…
Browse files Browse the repository at this point in the history
…y uncrypto
  • Loading branch information
Azurency committed Nov 13, 2023
1 parent e9e31da commit bc64538
Show file tree
Hide file tree
Showing 3 changed files with 129 additions and 49 deletions.
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
60 changes: 11 additions & 49 deletions src/runtime/server/lib/oauth/auth0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { withQuery, parsePath } from 'ufo'
import { ofetch } from 'ofetch'
import { defu } from 'defu'
import { useRuntimeConfig } from '#imports'
import crypto from 'crypto'
import { type OAuthChecks, checks } from '../../utils/security'

export interface OAuthAuth0Config {
/**
Expand Down Expand Up @@ -48,28 +48,17 @@ export interface OAuthAuth0Config {
checks?: OAuthChecks[]
}

type OAuthChecks = 'pkce' | 'state'
interface OAuthConfig {
config?: OAuthAuth0Config
onSuccess: (event: H3Event, result: { user: any, tokens: any }) => Promise<void> | void
onError?: (event: H3Event, error: H3Error) => Promise<void> | void
}

function base64URLEncode(str: string) {
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
function randomBytes(length: number) {
return crypto.randomBytes(length).toString('base64')
}
function sha256(buffer: string) {
return crypto.createHash('sha256').update(buffer).digest('base64')
}

export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig) {
return eventHandler(async (event: H3Event) => {
// @ts-ignore
config = defu(config, useRuntimeConfig(event).oauth?.auth0) as OAuthAuth0Config
const { code, state } = getQuery(event)
const { code } = getQuery(event)

if (!config.clientId || !config.clientSecret || !config.domain) {
const error = createError({
Expand All @@ -84,19 +73,7 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig) {

const redirectUrl = getRequestURL(event).href
if (!code) {
// Initialize checks
const checks: Record<string, string> = {}
if (config.checks?.includes('pkce')) {
const pkceVerifier = base64URLEncode(randomBytes(32))
const pkceChallenge = base64URLEncode(sha256(pkceVerifier))
checks['code_challenge'] = pkceChallenge
checks['code_challenge_method'] = 'S256'
setCookie(event, 'nuxt-auth-util-verifier', pkceVerifier, { maxAge: 60 * 15, secure: true, httpOnly: true })
}
if (config.checks?.includes('state')) {
checks['state'] = base64URLEncode(randomBytes(32))
setCookie(event, 'nuxt-auth-util-state', checks['state'], { maxAge: 60 * 15, secure: true, httpOnly: true })
}
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 @@ -110,33 +87,18 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig) {
redirect_uri: redirectUrl,
scope: config.scope.join(' '),
audience: config.audience || '',
...checks
...authParam
})
)
}

// Verify checks
const pkceVerifier = getCookie(event, 'nuxt-auth-util-verifier')
setCookie(event, 'nuxt-auth-util-verifier', '', { maxAge: -1 })
const stateInCookie = getCookie(event, 'nuxt-auth-util-state')
setCookie(event, 'nuxt-auth-util-state', '', { maxAge: -1 })
if (config.checks?.includes('state')) {
if (!state || !stateInCookie) {
const error = createError({
statusCode: 401,
message: 'Auth0 login failed: state is missing'
})
if (!onError) throw error
return onError(event, error)
}
if (state !== stateInCookie) {
const error = createError({
statusCode: 401,
message: 'Auth0 login failed: state does not match'
})
if (!onError) throw error
return onError(event, error)
}
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(
Expand All @@ -152,7 +114,7 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig) {
client_secret: config.clientSecret,
redirect_uri: parsePath(redirectUrl).pathname,
code,
code_verifier: pkceVerifier
...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)
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 })
}
if (checks?.includes('state')) {
res['state'] = generateState()
setCookie(event, 'nuxt-auth-util-state', res['state'], { maxAge: 60 * 15, secure: true, httpOnly: true })
}
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'
})
throw error
}
if (state !== stateInCookie) {
const error = createError({
statusCode: 401,
message: 'Auth0 login failed: state does not match'
})
throw error
}
}
}
return res
},
}

0 comments on commit bc64538

Please sign in to comment.