-
Notifications
You must be signed in to change notification settings - Fork 90
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
base: main
Are you sure you want to change the base?
Changes from 5 commits
e9e31da
bc64538
29f3106
915eef6
7113f98
abf4754
5405e60
c6f2c2d
ff45896
119ca71
6cd7498
68ec7de
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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, sameSite: 'lax' }) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the cookie settings should configurable or reuse the cookie settings from the module There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't reuse the cookie settings because they were under the session key, I don't know if it would be confusing to reuse that or not. But I agree that a shared cookie config somewhere would be nice. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree it would be a bit confusing. Maybe an optional config for pkce cookie? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added a cookie setting (runtimeConfig), under |
||
} | ||
if (checks?.includes('state')) { | ||
res['state'] = generateState() | ||
setCookie(event, 'nuxt-auth-util-state', res['state'], { maxAge: 60 * 15, secure: true, httpOnly: true, sameSite: 'lax' }) | ||
} | ||
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' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This message is still specific to Auth0 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, I updated this |
||
}) | ||
throw error | ||
} | ||
if (state !== stateInCookie) { | ||
const error = createError({ | ||
statusCode: 401, | ||
message: 'Auth0 login failed: state does not match' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This message is still specific to Auth0 |
||
}) | ||
throw error | ||
} | ||
} | ||
} | ||
return res | ||
}, | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
console.log leftover