Skip to content

Commit bc64538

Browse files
committed
refactor: moved checks to a separate security util, replaced crypto by uncrypto
1 parent e9e31da commit bc64538

File tree

3 files changed

+129
-49
lines changed

3 files changed

+129
-49
lines changed

playground/server/routes/auth/auth0.get.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export default oauth.auth0EventHandler({
22
config: {
33
emailRequired: true,
4+
checks: ['state']
45
},
56
async onSuccess(event, { user }) {
67
await setUserSession(event, {

src/runtime/server/lib/oauth/auth0.ts

Lines changed: 11 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { withQuery, parsePath } from 'ufo'
44
import { ofetch } from 'ofetch'
55
import { defu } from 'defu'
66
import { useRuntimeConfig } from '#imports'
7-
import crypto from 'crypto'
7+
import { type OAuthChecks, checks } from '../../utils/security'
88

99
export interface OAuthAuth0Config {
1010
/**
@@ -48,28 +48,17 @@ export interface OAuthAuth0Config {
4848
checks?: OAuthChecks[]
4949
}
5050

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

58-
function base64URLEncode(str: string) {
59-
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
60-
}
61-
function randomBytes(length: number) {
62-
return crypto.randomBytes(length).toString('base64')
63-
}
64-
function sha256(buffer: string) {
65-
return crypto.createHash('sha256').update(buffer).digest('base64')
66-
}
67-
6857
export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig) {
6958
return eventHandler(async (event: H3Event) => {
7059
// @ts-ignore
7160
config = defu(config, useRuntimeConfig(event).oauth?.auth0) as OAuthAuth0Config
72-
const { code, state } = getQuery(event)
61+
const { code } = getQuery(event)
7362

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

8574
const redirectUrl = getRequestURL(event).href
8675
if (!code) {
87-
// Initialize checks
88-
const checks: Record<string, string> = {}
89-
if (config.checks?.includes('pkce')) {
90-
const pkceVerifier = base64URLEncode(randomBytes(32))
91-
const pkceChallenge = base64URLEncode(sha256(pkceVerifier))
92-
checks['code_challenge'] = pkceChallenge
93-
checks['code_challenge_method'] = 'S256'
94-
setCookie(event, 'nuxt-auth-util-verifier', pkceVerifier, { maxAge: 60 * 15, secure: true, httpOnly: true })
95-
}
96-
if (config.checks?.includes('state')) {
97-
checks['state'] = base64URLEncode(randomBytes(32))
98-
setCookie(event, 'nuxt-auth-util-state', checks['state'], { maxAge: 60 * 15, secure: true, httpOnly: true })
99-
}
76+
const authParam = await checks.create(event, config.checks) // Initialize checks
10077
config.scope = config.scope || ['openid', 'offline_access']
10178
if (config.emailRequired && !config.scope.includes('email')) {
10279
config.scope.push('email')
@@ -110,33 +87,18 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig) {
11087
redirect_uri: redirectUrl,
11188
scope: config.scope.join(' '),
11289
audience: config.audience || '',
113-
...checks
90+
...authParam
11491
})
11592
)
11693
}
11794

11895
// Verify checks
119-
const pkceVerifier = getCookie(event, 'nuxt-auth-util-verifier')
120-
setCookie(event, 'nuxt-auth-util-verifier', '', { maxAge: -1 })
121-
const stateInCookie = getCookie(event, 'nuxt-auth-util-state')
122-
setCookie(event, 'nuxt-auth-util-state', '', { maxAge: -1 })
123-
if (config.checks?.includes('state')) {
124-
if (!state || !stateInCookie) {
125-
const error = createError({
126-
statusCode: 401,
127-
message: 'Auth0 login failed: state is missing'
128-
})
129-
if (!onError) throw error
130-
return onError(event, error)
131-
}
132-
if (state !== stateInCookie) {
133-
const error = createError({
134-
statusCode: 401,
135-
message: 'Auth0 login failed: state does not match'
136-
})
137-
if (!onError) throw error
138-
return onError(event, error)
139-
}
96+
let checkResult
97+
try {
98+
checkResult = await checks.use(event, config.checks)
99+
} catch (error) {
100+
if (!onError) throw error
101+
return onError(event, error as H3Error)
140102
}
141103

142104
const tokens: any = await ofetch(
@@ -152,7 +114,7 @@ export function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig) {
152114
client_secret: config.clientSecret,
153115
redirect_uri: parsePath(redirectUrl).pathname,
154116
code,
155-
code_verifier: pkceVerifier
117+
...checkResult
156118
}
157119
}
158120
).catch(error => {

src/runtime/server/utils/security.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { H3Event } from 'h3'
2+
import { subtle, getRandomValues } from 'uncrypto'
3+
4+
export type OAuthChecks = 'pkce' | 'state'
5+
6+
// From oauth4webapi https://github.com/panva/oauth4webapi/blob/4b46a7b4a4ca77a513774c94b718592fe3ad576f/src/index.ts#L567C1-L579C2
7+
const CHUNK_SIZE = 0x8000
8+
export function encodeBase64Url(input: Uint8Array | ArrayBuffer) {
9+
if (input instanceof ArrayBuffer) {
10+
input = new Uint8Array(input)
11+
}
12+
13+
const arr = []
14+
for (let i = 0; i < input.byteLength; i += CHUNK_SIZE) {
15+
// @ts-expect-error
16+
arr.push(String.fromCharCode.apply(null, input.subarray(i, i + CHUNK_SIZE)))
17+
}
18+
return btoa(arr.join('')).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
19+
}
20+
21+
function randomBytes() {
22+
return encodeBase64Url(getRandomValues(new Uint8Array(32)))
23+
}
24+
25+
/**
26+
* Generate a random `code_verifier` for use in the PKCE flow
27+
* @see https://tools.ietf.org/html/rfc7636#section-4.1
28+
*/
29+
export function generateCodeVerifier() {
30+
return randomBytes()
31+
}
32+
33+
/**
34+
* Generate a random `state` used to prevent CSRF attacks
35+
* @see https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.1
36+
*/
37+
export function generateState() {
38+
return randomBytes()
39+
}
40+
41+
/**
42+
* Generate a `code_challenge` from a `code_verifier` for use in the PKCE flow
43+
* @param verifier `code_verifier` string
44+
* @returns `code_challenge` string
45+
* @see https://tools.ietf.org/html/rfc7636#section-4.1
46+
*/
47+
export async function pkceCodeChallenge(verifier: string) {
48+
return encodeBase64Url(await subtle.digest({ name: 'SHA-256' }, new TextEncoder().encode(verifier)))
49+
}
50+
51+
interface CheckUseResult {
52+
code_verifier?: string
53+
}
54+
/**
55+
* Checks for PKCE and state
56+
*/
57+
export const checks = {
58+
/**
59+
* Create checks
60+
* @param event, H3Event
61+
* @param checks, OAuthChecks[] a list of checks to create
62+
* @returns Record<string, string> a map of check parameters to add to the authorization URL
63+
*/
64+
async create(event: H3Event, checks?: OAuthChecks[]) {
65+
const res: Record<string, string> = {}
66+
if (checks?.includes('pkce')) {
67+
const pkceVerifier = generateCodeVerifier()
68+
const pkceChallenge = await pkceCodeChallenge(pkceVerifier)
69+
console.log('pkceVerifier', pkceVerifier)
70+
console.log('pkceChallenge', pkceChallenge)
71+
res['code_challenge'] = pkceChallenge
72+
res['code_challenge_method'] = 'S256'
73+
setCookie(event, 'nuxt-auth-util-verifier', pkceVerifier, { maxAge: 60 * 15, secure: true, httpOnly: true })
74+
}
75+
if (checks?.includes('state')) {
76+
res['state'] = generateState()
77+
setCookie(event, 'nuxt-auth-util-state', res['state'], { maxAge: 60 * 15, secure: true, httpOnly: true })
78+
}
79+
return res
80+
},
81+
/**
82+
* Use checks, verifying and returning the results
83+
* @param event, H3Event
84+
* @param checks, OAuthChecks[] a list of checks to use
85+
* @returns CheckUseResult a map that can contain `code_verifier` if `pkce` was used to be used in the token exchange
86+
*/
87+
async use(event: H3Event, checks?: OAuthChecks[]) : Promise<CheckUseResult> {
88+
const res: CheckUseResult = {}
89+
const { state } = getQuery(event)
90+
if (checks?.includes('pkce')) {
91+
const pkceVerifier = getCookie(event, 'nuxt-auth-util-verifier')
92+
setCookie(event, 'nuxt-auth-util-verifier', '', { maxAge: -1 })
93+
res['code_verifier'] = pkceVerifier
94+
}
95+
if (checks?.includes('state')) {
96+
const stateInCookie = getCookie(event, 'nuxt-auth-util-state')
97+
setCookie(event, 'nuxt-auth-util-state', '', { maxAge: -1 })
98+
if (checks?.includes('state')) {
99+
if (!state || !stateInCookie) {
100+
const error = createError({
101+
statusCode: 401,
102+
message: 'Auth0 login failed: state is missing'
103+
})
104+
throw error
105+
}
106+
if (state !== stateInCookie) {
107+
const error = createError({
108+
statusCode: 401,
109+
message: 'Auth0 login failed: state does not match'
110+
})
111+
throw error
112+
}
113+
}
114+
}
115+
return res
116+
},
117+
}

0 commit comments

Comments
 (0)