Skip to content

Commit 9992865

Browse files
authored
fix(auth): improve OAuth state token validation (#160)
1 parent 55bedd4 commit 9992865

File tree

4 files changed

+62
-85
lines changed

4 files changed

+62
-85
lines changed

src/module/src/runtime/server/routes/auth/github.get.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { withQuery } from 'ufo'
44
import { defu } from 'defu'
55
import type { Endpoints } from '@octokit/types'
66
import { useRuntimeConfig } from '#imports'
7-
import { handleState, requestAccessToken } from '../../../utils/auth'
7+
import { generateOAuthState, requestAccessToken, validateOAuthState } from '../../../utils/auth'
88

99
export interface OAuthGitHubConfig {
1010
/**
@@ -98,9 +98,10 @@ export default eventHandler(async (event: H3Event) => {
9898

9999
config.redirectURL = config.redirectURL || `${requestURL.protocol}//${requestURL.host}${requestURL.pathname}`
100100

101-
const state = await handleState(event)
102-
103101
if (!query.code) {
102+
// Initial authorization request (generate and store state)
103+
const state = await generateOAuthState(event)
104+
104105
config.scope = config.scope || []
105106
if (config.emailRequired && !config.scope.includes('user:email')) {
106107
config.scope.push('user:email')
@@ -121,16 +122,8 @@ export default eventHandler(async (event: H3Event) => {
121122
)
122123
}
123124

124-
if (query.state !== state) {
125-
throw createError({
126-
statusCode: 500,
127-
message: 'Invalid state',
128-
data: {
129-
query,
130-
state,
131-
},
132-
})
133-
}
125+
// validate OAuth state and delete the cookie or throw an error
126+
validateOAuthState(event, query.state as string)
134127

135128
const token = await requestAccessToken(config.tokenURL as string, {
136129
body: {

src/module/src/runtime/server/routes/auth/gitlab.get.ts

Lines changed: 4 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { FetchError } from 'ofetch'
2-
import { getRandomValues } from 'uncrypto'
32
import type { H3Event } from 'h3'
43
import { eventHandler, getQuery, sendRedirect, createError, getRequestURL, setCookie, deleteCookie, getCookie, useSession } from 'h3'
54
import { withQuery } from 'ufo'
65
import { defu } from 'defu'
76
import type { UserSchema } from '@gitbeaker/core'
87
import { useRuntimeConfig } from '#imports'
8+
import { generateOAuthState, validateOAuthState } from '../../../utils/auth'
99

1010
export interface OAuthGitLabConfig {
1111
/**
@@ -122,7 +122,7 @@ export default eventHandler(async (event: H3Event) => {
122122

123123
if (!query.code) {
124124
// Initial authorization request (generate and store state)
125-
const state = await generateState(event)
125+
const state = await generateOAuthState(event)
126126

127127
config.scope = config.scope || []
128128
if (!config.scope.includes('api')) {
@@ -142,31 +142,8 @@ export default eventHandler(async (event: H3Event) => {
142142
)
143143
}
144144

145-
// Callback with code (validate and consume state)
146-
const storedState = getCookie(event, 'studio-oauth-state')
147-
148-
if (!storedState) {
149-
throw createError({
150-
statusCode: 400,
151-
message: 'OAuth state cookie not found. Please try logging in again.',
152-
data: {
153-
hint: 'State cookie may have expired or been cleared',
154-
},
155-
})
156-
}
157-
158-
if (query.state !== storedState) {
159-
throw createError({
160-
statusCode: 400,
161-
message: 'Invalid state - OAuth state mismatch',
162-
data: {
163-
hint: 'This may be caused by browser refresh, navigation, or expired session',
164-
},
165-
})
166-
}
167-
168-
// State validated, delete the cookie
169-
deleteCookie(event, 'studio-oauth-state')
145+
// validate OAuth state and delete the cookie or throw an error
146+
validateOAuthState(event, query.state as string)
170147

171148
const token = await requestAccessToken(config.tokenURL as string, {
172149
body: {
@@ -260,22 +237,3 @@ async function requestAccessToken(url: string, options: RequestAccessTokenOption
260237
return { error: 'Unknown error' }
261238
}
262239
}
263-
264-
async function generateState(event: H3Event) {
265-
const newState = Array.from(getRandomValues(new Uint8Array(32)))
266-
.map(b => b.toString(16).padStart(2, '0'))
267-
.join('')
268-
269-
const requestURL = getRequestURL(event)
270-
// Use secure cookies over HTTPS, required for locally testing purposes
271-
const isSecure = requestURL.protocol === 'https:'
272-
273-
setCookie(event, 'studio-oauth-state', newState, {
274-
httpOnly: true,
275-
secure: isSecure,
276-
sameSite: 'lax',
277-
maxAge: 60 * 15, // 15 minutes
278-
})
279-
280-
return newState
281-
}

src/module/src/runtime/server/routes/auth/google.get.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { eventHandler, createError, getQuery, sendRedirect, useSession, getReque
22
import { withQuery } from 'ufo'
33
import { defu } from 'defu'
44
import { useRuntimeConfig } from '#imports'
5-
import { handleState, requestAccessToken } from '../../../utils/auth'
5+
import { generateOAuthState, requestAccessToken, validateOAuthState } from '../../../utils/auth'
66

77
export interface GoogleUser {
88
sub: string
@@ -123,9 +123,10 @@ export default eventHandler(async (event: H3Event) => {
123123

124124
config.redirectURL = config.redirectURL || `${requestURL.protocol}//${requestURL.host}${requestURL.pathname}`
125125

126-
const state = await handleState(event)
127-
128126
if (!query.code) {
127+
// Initial authorization request (generate and store state)
128+
const state = await generateOAuthState(event)
129+
129130
config.scope = config.scope || ['email', 'profile']
130131
// Redirect to Google OAuth page
131132
return sendRedirect(
@@ -141,16 +142,8 @@ export default eventHandler(async (event: H3Event) => {
141142
)
142143
}
143144

144-
if (query.state !== state) {
145-
throw createError({
146-
statusCode: 500,
147-
message: 'Invalid state',
148-
data: {
149-
query,
150-
state,
151-
},
152-
})
153-
}
145+
// validate OAuth state and delete the cookie or throw an error
146+
validateOAuthState(event, query.state as string)
154147

155148
const token = await requestAccessToken(config.tokenURL as string, {
156149
body: {

src/module/src/runtime/utils/auth.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getRandomValues } from 'uncrypto'
2-
import { getCookie, deleteCookie, setCookie, type H3Event } from 'h3'
2+
import { getCookie, deleteCookie, setCookie, type H3Event, getRequestURL, createError } from 'h3'
33
import { FetchError } from 'ofetch'
44

55
export interface RequestAccessTokenResponse {
@@ -44,16 +44,53 @@ export async function requestAccessToken(url: string, options: RequestAccessToke
4444
})
4545
}
4646

47-
export async function handleState(event: H3Event) {
48-
let state = getCookie(event, 'nuxt-auth-state')
49-
if (state) {
50-
deleteCookie(event, 'nuxt-auth-state')
51-
return state
47+
export async function generateOAuthState(event: H3Event) {
48+
const newState = getRandomBytes(32)
49+
50+
const requestURL = getRequestURL(event)
51+
// Use secure cookies over HTTPS, required for locally testing purposes
52+
const isSecure = requestURL.protocol === 'https:'
53+
54+
setCookie(event, 'studio-oauth-state', newState, {
55+
httpOnly: true,
56+
secure: isSecure,
57+
sameSite: 'lax',
58+
maxAge: 60 * 15, // 15 minutes
59+
})
60+
61+
return newState
62+
}
63+
64+
export function validateOAuthState(event: H3Event, receivedState: string) {
65+
// Callback with code (validate and consume state)
66+
const storedState = getCookie(event, 'studio-oauth-state')
67+
68+
if (!storedState) {
69+
throw createError({
70+
statusCode: 400,
71+
message: 'OAuth state cookie not found. Please try logging in again.',
72+
data: {
73+
hint: 'State cookie may have expired or been cleared',
74+
},
75+
})
5276
}
5377

54-
state = encodeBase64Url(getRandomBytes(8))
55-
setCookie(event, 'nuxt-auth-state', state)
56-
return state
78+
if (receivedState !== storedState) {
79+
throw createError({
80+
statusCode: 400,
81+
message: 'Invalid state - OAuth state mismatch',
82+
data: {
83+
hint: 'This may be caused by browser refresh, navigation, or expired session',
84+
},
85+
})
86+
}
87+
88+
// State validated, delete the cookie
89+
deleteCookie(event, 'studio-oauth-state')
90+
}
91+
92+
function getRandomBytes(size: number = 32) {
93+
return encodeBase64Url(getRandomValues(new Uint8Array(size)))
5794
}
5895

5996
function encodeBase64Url(input: Uint8Array): string {
@@ -62,7 +99,3 @@ function encodeBase64Url(input: Uint8Array): string {
6299
.replace(/\//g, '_')
63100
.replace(/=+$/g, '')
64101
}
65-
66-
function getRandomBytes(size: number = 32) {
67-
return getRandomValues(new Uint8Array(size))
68-
}

0 commit comments

Comments
 (0)