Skip to content

Commit f12d454

Browse files
committed
feat: added simple pkce and state checks for auth0
1 parent 34227d8 commit f12d454

File tree

6 files changed

+161
-2
lines changed

6 files changed

+161
-2
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 defineOAuthAuth0EventHandler({
22
config: {
33
emailRequired: true,
4+
checks: ['state'],
45
},
56
async onSuccess(event, { user }) {
67
await setUserSession(event, {

src/module.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,17 @@ export default defineNuxtModule<ModuleOptions>({
168168
authenticate: {},
169169
})
170170

171+
// Security settings
172+
runtimeConfig.nuxtAuthUtils = defu(runtimeConfig.nuxtAuthUtils, {})
173+
runtimeConfig.nuxtAuthUtils.security = defu(runtimeConfig.nuxtAuthUtils.security, {
174+
cookie: {
175+
secure: true,
176+
httpOnly: true,
177+
sameSite: 'lax',
178+
maxAge: 60 * 15,
179+
},
180+
})
181+
171182
// OAuth settings
172183
runtimeConfig.oauth = defu(runtimeConfig.oauth, {})
173184
// Gitea OAuth

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import type { H3Event } from 'h3'
1+
import type { H3Event, H3Error } from 'h3'
22
import { eventHandler, getQuery, sendRedirect } from 'h3'
33
import { withQuery } from 'ufo'
44
import { defu } from 'defu'
55
import { handleMissingConfiguration, handleAccessTokenErrorResponse, getOAuthRedirectURL, requestAccessToken } from '../utils'
6+
import { checks } from '../../utils/security'
7+
import { type OAuthChecks, checks } from '../../utils/security'
68
import { useRuntimeConfig } from '#imports'
79
import type { OAuthConfig } from '#auth-utils'
810

@@ -24,7 +26,7 @@ export interface OAuthAuth0Config {
2426
domain?: string
2527
/**
2628
* Auth0 OAuth Audience
27-
* @default process.env.NUXT_OAUTH_AUTH0_AUDIENCE
29+
* @default ''
2830
*/
2931
audience?: string
3032
/**
@@ -45,6 +47,20 @@ export interface OAuthAuth0Config {
4547
* @see https://auth0.com/docs/authenticate/login/max-age-reauthentication
4648
*/
4749
maxAge?: number
50+
/**
51+
* checks
52+
* @default []
53+
* @see https://auth0.com/docs/flows/authorization-code-flow-with-proof-key-for-code-exchange-pkce
54+
* @see https://auth0.com/docs/protocols/oauth2/oauth-state
55+
*/
56+
checks?: OAuthChecks[]
57+
/**
58+
* checks
59+
* @default []
60+
* @see https://auth0.com/docs/flows/authorization-code-flow-with-proof-key-for-code-exchange-pkce
61+
* @see https://auth0.com/docs/protocols/oauth2/oauth-state
62+
*/
63+
checks?: OAuthChecks[]
4864
/**
4965
* Login connection. If no connection is specified, it will redirect to the standard Auth0 login page and show the Login Widget.
5066
* @default ''
@@ -81,6 +97,7 @@ export function defineOAuthAuth0EventHandler({ config, onSuccess, onError }: OAu
8197
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
8298

8399
if (!query.code) {
100+
const authParam = await checks.create(event, config.checks) // Initialize checks
84101
config.scope = config.scope || ['openid', 'offline_access']
85102
if (config.emailRequired && !config.scope.includes('email')) {
86103
config.scope.push('email')
@@ -97,10 +114,21 @@ export function defineOAuthAuth0EventHandler({ config, onSuccess, onError }: OAu
97114
max_age: config.maxAge || 0,
98115
connection: config.connection || '',
99116
...config.authorizationParams,
117+
...authParam,
100118
}),
101119
)
102120
}
103121

122+
// Verify checks
123+
let checkResult
124+
try {
125+
checkResult = await checks.use(event, config.checks)
126+
}
127+
catch (error) {
128+
if (!onError) throw error
129+
return onError(event, error as H3Error)
130+
}
131+
104132
const tokens = await requestAccessToken(tokenURL as string, {
105133
headers: {
106134
'Content-Type': 'application/json',
@@ -111,6 +139,7 @@ export function defineOAuthAuth0EventHandler({ config, onSuccess, onError }: OAu
111139
client_secret: config.clientSecret,
112140
redirect_uri: redirectURL,
113141
code: query.code,
142+
...checkResult,
114143
},
115144
})
116145

src/runtime/server/utils/security.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { type H3Event, setCookie, getCookie, getQuery, createError } from 'h3'
2+
import { subtle, getRandomValues } from 'uncrypto'
3+
import { useRuntimeConfig } from '#imports'
4+
5+
export type OAuthChecks = 'pkce' | 'state'
6+
7+
// From oauth4webapi https://github.com/panva/oauth4webapi/blob/4b46a7b4a4ca77a513774c94b718592fe3ad576f/src/index.ts#L567C1-L579C2
8+
const CHUNK_SIZE = 0x8000
9+
export function encodeBase64Url(input: Uint8Array | ArrayBuffer) {
10+
if (input instanceof ArrayBuffer) {
11+
input = new Uint8Array(input)
12+
}
13+
14+
const arr = []
15+
for (let i = 0; i < input.byteLength; i += CHUNK_SIZE) {
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+
const runtimeConfig = useRuntimeConfig()
67+
if (checks?.includes('pkce')) {
68+
const pkceVerifier = generateCodeVerifier()
69+
const pkceChallenge = await pkceCodeChallenge(pkceVerifier)
70+
res['code_challenge'] = pkceChallenge
71+
res['code_challenge_method'] = 'S256'
72+
setCookie(event, 'nuxt-auth-util-verifier', pkceVerifier, runtimeConfig.nuxtAuthUtils.security.cookie)
73+
}
74+
if (checks?.includes('state')) {
75+
res['state'] = generateState()
76+
setCookie(event, 'nuxt-auth-util-state', res['state'], runtimeConfig.nuxtAuthUtils.security.cookie)
77+
}
78+
return res
79+
},
80+
/**
81+
* Use checks, verifying and returning the results
82+
* @param event H3Event
83+
* @param checks OAuthChecks[] a list of checks to use
84+
* @returns CheckUseResult a map that can contain `code_verifier` if `pkce` was used to be used in the token exchange
85+
*/
86+
async use(event: H3Event, checks?: OAuthChecks[]): Promise<CheckUseResult> {
87+
const res: CheckUseResult = {}
88+
const { state } = getQuery(event)
89+
if (checks?.includes('pkce')) {
90+
const pkceVerifier = getCookie(event, 'nuxt-auth-util-verifier')
91+
setCookie(event, 'nuxt-auth-util-verifier', '', { maxAge: -1 })
92+
res['code_verifier'] = pkceVerifier
93+
}
94+
if (checks?.includes('state')) {
95+
const stateInCookie = getCookie(event, 'nuxt-auth-util-state')
96+
setCookie(event, 'nuxt-auth-util-state', '', { maxAge: -1 })
97+
if (checks?.includes('state')) {
98+
if (!state || !stateInCookie) {
99+
const error = createError({
100+
statusCode: 401,
101+
message: 'Login failed: state is missing',
102+
})
103+
throw error
104+
}
105+
if (state !== stateInCookie) {
106+
const error = createError({
107+
statusCode: 401,
108+
message: 'Login failed: state does not match',
109+
})
110+
throw error
111+
}
112+
}
113+
}
114+
return res
115+
},
116+
}

src/runtime/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export type {
77
WebAuthnComposable,
88
WebAuthnUser,
99
} from './webauthn'
10+
export type { OAuthChecks } from './security'

src/runtime/types/security.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type OAuthChecks = 'pkce' | 'state'

0 commit comments

Comments
 (0)