Skip to content
Merged
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
19 changes: 6 additions & 13 deletions src/module/src/runtime/server/routes/auth/github.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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')
Expand All @@ -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: {
Expand Down
50 changes: 4 additions & 46 deletions src/module/src/runtime/server/routes/auth/gitlab.get.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand Down Expand Up @@ -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')) {
Expand All @@ -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: {
Expand Down Expand Up @@ -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
}
19 changes: 6 additions & 13 deletions src/module/src/runtime/server/routes/auth/google.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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: {
Expand Down
59 changes: 46 additions & 13 deletions src/module/src/runtime/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -62,7 +99,3 @@ function encodeBase64Url(input: Uint8Array): string {
.replace(/\//g, '_')
.replace(/=+$/g, '')
}

function getRandomBytes(size: number = 32) {
return getRandomValues(new Uint8Array(size))
}
Loading