Skip to content

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

New issue

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

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

Already on GitHub? # to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
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 defineOAuthAuth0EventHandler({
config: {
emailRequired: true,
checks: ['state'],
},
async onSuccess(event, { user }) {
await setUserSession(event, {
Expand Down
11 changes: 11 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,17 @@ export default defineNuxtModule<ModuleOptions>({
authenticate: {},
})

// 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, {})
// Gitea OAuth
Expand Down
33 changes: 31 additions & 2 deletions src/runtime/server/lib/oauth/auth0.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { H3Event } from 'h3'
import type { H3Event, H3Error } from 'h3'
import { eventHandler, getQuery, sendRedirect } from 'h3'
import { withQuery } from 'ufo'
import { defu } from 'defu'
import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken } from '../utils'
import { checks } from '../../utils/security'
import { type OAuthChecks, checks } from '../../utils/security'
import { useRuntimeConfig } from '#imports'
import type { OAuthConfig } from '#auth-utils'

Expand All @@ -24,7 +26,7 @@ export interface OAuthAuth0Config {
domain?: string
/**
* Auth0 OAuth Audience
* @default process.env.NUXT_OAUTH_AUTH0_AUDIENCE
* @default ''
*/
audience?: string
/**
Expand All @@ -45,6 +47,20 @@ export interface OAuthAuth0Config {
* @see https://auth0.com/docs/authenticate/#/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[]
/**
* 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[]
/**
* Login connection. If no connection is specified, it will redirect to the standard Auth0 login page and show the Login Widget.
* @default ''
Expand Down Expand Up @@ -81,6 +97,7 @@ export function defineOAuthAuth0EventHandler({ config, onSuccess, onError }: OAu
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)

if (!query.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 @@ -97,10 +114,21 @@ export function defineOAuthAuth0EventHandler({ config, onSuccess, onError }: OAu
max_age: config.maxAge || 0,
connection: config.connection || '',
...config.authorizationParams,
...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 = await requestAccessToken(tokenURL as string, {
headers: {
'Content-Type': 'application/json',
Expand All @@ -111,6 +139,7 @@ export function defineOAuthAuth0EventHandler({ config, onSuccess, onError }: OAu
client_secret: config.clientSecret,
redirect_uri: redirectURL,
code: query.code,
...checkResult,
},
})

Expand Down
116 changes: 116 additions & 0 deletions src/runtime/server/utils/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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) {
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)
}
if (checks?.includes('state')) {
res['state'] = generateState()
setCookie(event, 'nuxt-auth-util-state', res['state'], runtimeConfig.nuxtAuthUtils.security.cookie)
}
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
},
}
1 change: 1 addition & 0 deletions src/runtime/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export type {
WebAuthnComposable,
WebAuthnUser,
} from './webauthn'
export type { OAuthChecks } from './security'
1 change: 1 addition & 0 deletions src/runtime/types/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type OAuthChecks = 'pkce' | 'state'