diff --git a/src/module/src/runtime/server/routes/auth/github.get.ts b/src/module/src/runtime/server/routes/auth/github.get.ts index 47a9062a..15be080e 100644 --- a/src/module/src/runtime/server/routes/auth/github.get.ts +++ b/src/module/src/runtime/server/routes/auth/github.get.ts @@ -4,7 +4,7 @@ import { withQuery } from 'ufo' import { defu } from 'defu' import type { Endpoints } from '@octokit/types' import { useRuntimeConfig } from '#imports' -import { handleState, requestAccessToken } from '../../../utils/auth' +import { generateOAuthState, requestAccessToken, validateOAuthState } from '../../../utils/auth' export interface OAuthGitHubConfig { /** @@ -98,9 +98,10 @@ export default eventHandler(async (event: H3Event) => { config.redirectURL = config.redirectURL || `${requestURL.protocol}//${requestURL.host}${requestURL.pathname}` - const state = await handleState(event) - if (!query.code) { + // Initial authorization request (generate and store state) + const state = await generateOAuthState(event) + config.scope = config.scope || [] if (config.emailRequired && !config.scope.includes('user:email')) { config.scope.push('user:email') @@ -121,16 +122,8 @@ export default eventHandler(async (event: H3Event) => { ) } - if (query.state !== state) { - throw createError({ - statusCode: 500, - message: 'Invalid state', - data: { - query, - state, - }, - }) - } + // validate OAuth state and delete the cookie or throw an error + validateOAuthState(event, query.state as string) const token = await requestAccessToken(config.tokenURL as string, { body: { diff --git a/src/module/src/runtime/server/routes/auth/gitlab.get.ts b/src/module/src/runtime/server/routes/auth/gitlab.get.ts index 97340543..861c6841 100644 --- a/src/module/src/runtime/server/routes/auth/gitlab.get.ts +++ b/src/module/src/runtime/server/routes/auth/gitlab.get.ts @@ -1,11 +1,11 @@ import { FetchError } from 'ofetch' -import { getRandomValues } from 'uncrypto' import type { H3Event } from 'h3' import { eventHandler, getQuery, sendRedirect, createError, getRequestURL, setCookie, deleteCookie, getCookie, useSession } from 'h3' import { withQuery } from 'ufo' import { defu } from 'defu' import type { UserSchema } from '@gitbeaker/core' import { useRuntimeConfig } from '#imports' +import { generateOAuthState, validateOAuthState } from '../../../utils/auth' export interface OAuthGitLabConfig { /** @@ -122,7 +122,7 @@ export default eventHandler(async (event: H3Event) => { if (!query.code) { // Initial authorization request (generate and store state) - const state = await generateState(event) + const state = await generateOAuthState(event) config.scope = config.scope || [] if (!config.scope.includes('api')) { @@ -142,31 +142,8 @@ export default eventHandler(async (event: H3Event) => { ) } - // Callback with code (validate and consume state) - const storedState = getCookie(event, 'studio-oauth-state') - - if (!storedState) { - throw createError({ - statusCode: 400, - message: 'OAuth state cookie not found. Please try logging in again.', - data: { - hint: 'State cookie may have expired or been cleared', - }, - }) - } - - if (query.state !== storedState) { - throw createError({ - statusCode: 400, - message: 'Invalid state - OAuth state mismatch', - data: { - hint: 'This may be caused by browser refresh, navigation, or expired session', - }, - }) - } - - // State validated, delete the cookie - deleteCookie(event, 'studio-oauth-state') + // validate OAuth state and delete the cookie or throw an error + validateOAuthState(event, query.state as string) const token = await requestAccessToken(config.tokenURL as string, { body: { @@ -260,22 +237,3 @@ async function requestAccessToken(url: string, options: RequestAccessTokenOption return { error: 'Unknown error' } } } - -async function generateState(event: H3Event) { - const newState = Array.from(getRandomValues(new Uint8Array(32))) - .map(b => b.toString(16).padStart(2, '0')) - .join('') - - const requestURL = getRequestURL(event) - // Use secure cookies over HTTPS, required for locally testing purposes - const isSecure = requestURL.protocol === 'https:' - - setCookie(event, 'studio-oauth-state', newState, { - httpOnly: true, - secure: isSecure, - sameSite: 'lax', - maxAge: 60 * 15, // 15 minutes - }) - - return newState -} diff --git a/src/module/src/runtime/server/routes/auth/google.get.ts b/src/module/src/runtime/server/routes/auth/google.get.ts index 0a51067d..9522c629 100644 --- a/src/module/src/runtime/server/routes/auth/google.get.ts +++ b/src/module/src/runtime/server/routes/auth/google.get.ts @@ -2,7 +2,7 @@ import { eventHandler, createError, getQuery, sendRedirect, useSession, getReque import { withQuery } from 'ufo' import { defu } from 'defu' import { useRuntimeConfig } from '#imports' -import { handleState, requestAccessToken } from '../../../utils/auth' +import { generateOAuthState, requestAccessToken, validateOAuthState } from '../../../utils/auth' export interface GoogleUser { sub: string @@ -123,9 +123,10 @@ export default eventHandler(async (event: H3Event) => { config.redirectURL = config.redirectURL || `${requestURL.protocol}//${requestURL.host}${requestURL.pathname}` - const state = await handleState(event) - if (!query.code) { + // Initial authorization request (generate and store state) + const state = await generateOAuthState(event) + config.scope = config.scope || ['email', 'profile'] // Redirect to Google OAuth page return sendRedirect( @@ -141,16 +142,8 @@ export default eventHandler(async (event: H3Event) => { ) } - if (query.state !== state) { - throw createError({ - statusCode: 500, - message: 'Invalid state', - data: { - query, - state, - }, - }) - } + // validate OAuth state and delete the cookie or throw an error + validateOAuthState(event, query.state as string) const token = await requestAccessToken(config.tokenURL as string, { body: { diff --git a/src/module/src/runtime/utils/auth.ts b/src/module/src/runtime/utils/auth.ts index ced2ac7e..99a0e49b 100644 --- a/src/module/src/runtime/utils/auth.ts +++ b/src/module/src/runtime/utils/auth.ts @@ -1,5 +1,5 @@ import { getRandomValues } from 'uncrypto' -import { getCookie, deleteCookie, setCookie, type H3Event } from 'h3' +import { getCookie, deleteCookie, setCookie, type H3Event, getRequestURL, createError } from 'h3' import { FetchError } from 'ofetch' export interface RequestAccessTokenResponse { @@ -44,16 +44,53 @@ export async function requestAccessToken(url: string, options: RequestAccessToke }) } -export async function handleState(event: H3Event) { - let state = getCookie(event, 'nuxt-auth-state') - if (state) { - deleteCookie(event, 'nuxt-auth-state') - return state +export async function generateOAuthState(event: H3Event) { + const newState = getRandomBytes(32) + + const requestURL = getRequestURL(event) + // Use secure cookies over HTTPS, required for locally testing purposes + const isSecure = requestURL.protocol === 'https:' + + setCookie(event, 'studio-oauth-state', newState, { + httpOnly: true, + secure: isSecure, + sameSite: 'lax', + maxAge: 60 * 15, // 15 minutes + }) + + return newState +} + +export function validateOAuthState(event: H3Event, receivedState: string) { + // Callback with code (validate and consume state) + const storedState = getCookie(event, 'studio-oauth-state') + + if (!storedState) { + throw createError({ + statusCode: 400, + message: 'OAuth state cookie not found. Please try logging in again.', + data: { + hint: 'State cookie may have expired or been cleared', + }, + }) } - state = encodeBase64Url(getRandomBytes(8)) - setCookie(event, 'nuxt-auth-state', state) - return state + if (receivedState !== storedState) { + throw createError({ + statusCode: 400, + message: 'Invalid state - OAuth state mismatch', + data: { + hint: 'This may be caused by browser refresh, navigation, or expired session', + }, + }) + } + + // State validated, delete the cookie + deleteCookie(event, 'studio-oauth-state') +} + +function getRandomBytes(size: number = 32) { + return encodeBase64Url(getRandomValues(new Uint8Array(size))) } function encodeBase64Url(input: Uint8Array): string { @@ -62,7 +99,3 @@ function encodeBase64Url(input: Uint8Array): string { .replace(/\//g, '_') .replace(/=+$/g, '') } - -function getRandomBytes(size: number = 32) { - return getRandomValues(new Uint8Array(size)) -}