Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/module/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ interface ModuleOptions {
* @default process.env.STUDIO_GITHUB_CLIENT_SECRET
*/
clientSecret?: string
/**
* A GitHub Personal Access Token (PAT) to bypass OAuth.
* @default process.env.STUDIO_GITHUB_PAT
*/
pat?: string
}
}
/**
Expand Down Expand Up @@ -116,6 +121,7 @@ export default defineNuxtModule<ModuleOptions>({
github: {
clientId: process.env.STUDIO_GITHUB_CLIENT_ID,
clientSecret: process.env.STUDIO_GITHUB_CLIENT_SECRET,
pat: process.env.STUDIO_GITHUB_PAT,
},
},
i18n: {
Expand Down Expand Up @@ -160,6 +166,7 @@ export default defineNuxtModule<ModuleOptions>({
sessionSecret: createHash('md5').update([
options.auth?.github?.clientId,
options.auth?.github?.clientSecret,
options.auth?.github?.pat,
].join('')).digest('hex'),
// @ts-expect-error todo fix github type issue
github: options.auth?.github,
Expand Down
14 changes: 11 additions & 3 deletions src/module/src/runtime/server/routes/admin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { eventHandler, getQuery, sendRedirect, setCookie } from 'h3'

export default eventHandler((event) => {
import { eventHandler, getQuery, sendRedirect, setCookie, useSession } from 'h3'
import { useRuntimeConfig } from '#imports'

export default eventHandler(async (event) => {
const session = await useSession(event, {
name: 'studio-session',
password: useRuntimeConfig(event).studio?.auth?.sessionSecret,
})
if (session.data?.user) {
return sendRedirect(event, '/')
}
const { redirect } = getQuery(event)
if (redirect) {
setCookie(event, 'studio-redirect', String(redirect), {
Expand Down
95 changes: 91 additions & 4 deletions src/module/src/runtime/server/routes/auth/github.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export interface OAuthGitHubConfig {
* @default process.env.STUDIO_GITHUB_CLIENT_SECRET
*/
clientSecret?: string
/**
* A GitHub Personal Access Token (PAT) to bypass OAuth.
* @default process.env.STUDIO_GITHUB_PAT
*/
pat?: string
/**
* GitHub OAuth Scope
* @default []
Expand Down Expand Up @@ -84,6 +89,7 @@ export default eventHandler(async (event: H3Event) => {
const config = defu(studioConfig?.auth?.github, {
clientId: process.env.STUDIO_GITHUB_CLIENT_ID,
clientSecret: process.env.STUDIO_GITHUB_CLIENT_SECRET,
pat: process.env.STUDIO_GITHUB_PAT,
redirectURL: process.env.STUDIO_GITHUB_REDIRECT_URL,
authorizationURL: 'https://github.com/login/oauth/authorize',
tokenURL: 'https://github.com/login/oauth/access_token',
Expand All @@ -102,10 +108,91 @@ export default eventHandler(async (event: H3Event) => {
})
}

if (!config.clientId || !config.clientSecret) {
// Check auth strategies
const hasOAuth = config.clientId && config.clientSecret
const hasPat = !!config.pat

if (hasPat && hasOAuth) {
console.warn('Nuxt Studio: Both PAT and OAuth (clientId/clientSecret) are configured. Defaulting to OAuth flow.')
}

// PAT-only flow
// If *only* PAT is provided (no OAuth), log in directly.
if (hasPat && !hasOAuth) {
const accessToken = config.pat!
let user: Endpoints['GET /user']['response']['data']
try {
user = await $fetch(`${config.apiURL}/user`, {
headers: {
'User-Agent': `Nuxt-Studio-PAT-Sync`,
'Authorization': `token ${accessToken}`,
},
})
}
catch (e) {
throw createError({ statusCode: 500, message: 'Failed to fetch user with GitHub PAT', data: e })
}

// if no public email, check the private ones
if (!user.email && config.emailRequired) {
try {
const emails: Endpoints['GET /user/emails']['response']['data'] = await $fetch(`${config.apiURL}/user/emails`, {
headers: {
'User-Agent': `Nuxt-Studio-PAT-Sync`,
'Authorization': `token ${accessToken}`,
},
})
const primaryEmail = emails.find((email: { primary: boolean }) => email.primary)
if (primaryEmail) {
user.email = primaryEmail.email
}
else {
console.warn('Nuxt Studio: Could not find primary email for PAT user.')
}
}
catch (e) {
console.error('Nuxt Studio: Failed to fetch emails for PAT user.', e)
Comment on lines +150 to +154
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
console.warn('Nuxt Studio: Could not find primary email for PAT user.')
}
}
catch (e) {
console.error('Nuxt Studio: Failed to fetch emails for PAT user.', e)
throw createError({
statusCode: 500,
message: 'Could not get GitHub user email',
data: { accessToken: '***' },
})
}
}
catch (e) {
throw createError({ statusCode: 500, message: 'Failed to fetch emails for PAT user.', data: e })

The PAT authentication flow doesn't enforce the emailRequired configuration, allowing users to be created with empty emails while the OAuth flow correctly rejects such logins.

View Details

Analysis

Inconsistent emailRequired enforcement in GitHub PAT authentication flow

What fails: PAT authentication flow allows session creation with empty email when emailRequired=true, while OAuth flow correctly rejects such logins.

How to reproduce:

  1. Configure GitHub authentication with PAT (Personal Access Token)
  2. Set emailRequired=true in the configuration
  3. Authenticate with a GitHub account that has no primary email set
  4. With PAT flow: User logs in successfully with empty email string
  5. With OAuth flow: User receives 500 error "Could not get GitHub user email"

Result:

  • PAT flow: Creates session with email: '' (line 171 before fix: email: user.email ?? '')
  • OAuth flow: Throws error with status 500 and message "Could not get GitHub user email" (lines 273-279)

Expected: Both flows should enforce the same emailRequired contract - rejecting authentication when no email can be found.

Root cause: In the PAT flow (lines 137-159 before fix), when emailRequired=true and no primary email is found:

  • Line 153: Only logs a warning instead of throwing an error
  • Line 154: catch block only logs an error instead of throwing
  • This allows the session to be created with an empty email

The OAuth flow (lines 269-279) correctly throws an error with createError() when the same condition occurs.

Fix applied: Modified PAT flow to match OAuth flow behavior by throwing errors instead of logging warnings when no primary email is found and emailRequired=true.

}
}

// Success: Create session
const session = await useSession(event, {
name: 'studio-session',
password: useRuntimeConfig(event).studio?.auth?.sessionSecret,
})

await session.update(defu({
user: {
contentUser: true,
githubId: user.id,
githubToken: accessToken,
name: user.name ?? user.login,
avatar: user.avatar_url ?? '',
email: user.email ?? '',
provider: 'github-pat',
},
}, session.data))

const redirect = decodeURIComponent(getCookie(event, 'studio-redirect') || '')
deleteCookie(event, 'studio-redirect')

// Set a cookie to indicate that the session is active
setCookie(event, 'studio-session-check', 'true', { httpOnly: false })

// make sure the redirect is a valid relative path (avoid also // which is not a valid URL)
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
return sendRedirect(event, redirect)
}

return sendRedirect(event, '/')
}

// If we're here, it means PAT-only flow didn't run.
// We must now be in OAuth flow, so check for OAuth creds.
if (!hasOAuth) {
throw createError({
statusCode: 500,
message: 'Missing GitHub client ID or secret',
message: 'Missing GitHub client ID/secret OR GitHub PAT',
data: config,
})
}
Expand Down Expand Up @@ -151,8 +238,8 @@ export default eventHandler(async (event: H3Event) => {
const token = await requestAccessToken(config.tokenURL as string, {
body: {
grant_type: 'authorization_code',
client_id: config.clientId,
client_secret: config.clientSecret,
client_id: config.clientId ?? '',
client_secret: config.clientSecret ?? '',
redirect_uri: config.redirectURL,
code: query.code,
},
Expand Down
Loading