mirror of https://github.com/wg-easy/wg-easy
12 changed files with 210 additions and 189 deletions
@ -0,0 +1,88 @@ |
|||||
|
import * as client from 'openid-client'; |
||||
|
|
||||
|
export default defineEventHandler(async (event) => { |
||||
|
const { config, provider } = await buildOauthConfig(event); |
||||
|
|
||||
|
const session = await useWGSession(event); |
||||
|
if ( |
||||
|
!session.data.oauth_nonce || |
||||
|
!session.data.oauth_verifier || |
||||
|
!session.data.oauth_state |
||||
|
) { |
||||
|
throw createError({ |
||||
|
statusCode: 400, |
||||
|
statusMessage: 'Missing OAuth State', |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const currentUrl = getRequestURL(event); |
||||
|
|
||||
|
const tokens = await client.authorizationCodeGrant(config, currentUrl, { |
||||
|
pkceCodeVerifier: session.data.oauth_verifier, |
||||
|
expectedNonce: session.data.oauth_nonce, |
||||
|
expectedState: session.data.oauth_state, |
||||
|
idTokenExpected: true, |
||||
|
}); |
||||
|
|
||||
|
const subject = tokens.claims()?.sub; |
||||
|
if (!subject) { |
||||
|
throw createError({ |
||||
|
statusCode: 400, |
||||
|
statusMessage: 'Cant get subject', |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const userInfo = await client.fetchUserInfo( |
||||
|
config, |
||||
|
tokens.access_token, |
||||
|
subject |
||||
|
); |
||||
|
|
||||
|
if (!userInfo.email) { |
||||
|
throw createError({ |
||||
|
statusCode: 400, |
||||
|
statusMessage: 'No email set', |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
if (!userInfo.email_verified) { |
||||
|
throw createError({ |
||||
|
statusCode: 401, |
||||
|
statusMessage: 'Email is not verified', |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const result = await Database.users.findOrCreateByProvider( |
||||
|
provider, |
||||
|
userInfo.sub, |
||||
|
userInfo.email, |
||||
|
userInfo.name || userInfo.email |
||||
|
); |
||||
|
|
||||
|
if (!result.success) { |
||||
|
if (result.error === 'USER_DISABLED') { |
||||
|
throw createError({ |
||||
|
statusCode: 401, |
||||
|
statusMessage: 'User disabled', |
||||
|
}); |
||||
|
} |
||||
|
throw createError({ |
||||
|
statusCode: 500, |
||||
|
statusMessage: 'Unexpected error', |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Create session
|
||||
|
await session.update({ |
||||
|
userId: result.user.id, |
||||
|
oauth_nonce: undefined, |
||||
|
oauth_state: undefined, |
||||
|
oauth_verifier: undefined, |
||||
|
}); |
||||
|
|
||||
|
SERVER_DEBUG( |
||||
|
`New OAuth Session for ${provider} ${result.user.id} (${result.user.username})` |
||||
|
); |
||||
|
|
||||
|
return sendRedirect(event, '/'); |
||||
|
}); |
||||
@ -0,0 +1,34 @@ |
|||||
|
import * as client from 'openid-client'; |
||||
|
|
||||
|
export default defineEventHandler(async (event) => { |
||||
|
const { config, provider, providerConfig } = await buildOauthConfig(event); |
||||
|
|
||||
|
const host = getRequestHost(event); |
||||
|
const protocol = WG_ENV.INSECURE ? 'http' : 'https'; |
||||
|
const redirectUri = `${protocol}://${host}/api/auth/${provider}/callback`; |
||||
|
|
||||
|
const codeVerifier = client.randomPKCECodeVerifier(); |
||||
|
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); |
||||
|
const nonce = client.randomNonce(); |
||||
|
const state = client.randomState(); |
||||
|
|
||||
|
const parameters: Record<string, string> = { |
||||
|
redirect_uri: redirectUri, |
||||
|
scope: providerConfig.scope, |
||||
|
code_challenge: codeChallenge, |
||||
|
code_challenge_method: 'S256', |
||||
|
nonce: nonce, |
||||
|
state: state, |
||||
|
}; |
||||
|
|
||||
|
const session = await useWGSession(event); |
||||
|
await session.update({ |
||||
|
oauth_nonce: nonce, |
||||
|
oauth_verifier: codeVerifier, |
||||
|
oauth_state: state, |
||||
|
}); |
||||
|
|
||||
|
const redirectTo = client.buildAuthorizationUrl(config, parameters); |
||||
|
|
||||
|
return sendRedirect(event, redirectTo.toString()); |
||||
|
}); |
||||
@ -1,130 +0,0 @@ |
|||||
import { z } from 'zod'; |
|
||||
|
|
||||
const CallbackQuerySchema = z.object({ |
|
||||
code: z.string().min(1), |
|
||||
state: z.string().min(1), |
|
||||
}); |
|
||||
|
|
||||
interface GoogleTokenResponse { |
|
||||
access_token: string; |
|
||||
id_token: string; |
|
||||
token_type: string; |
|
||||
expires_in: number; |
|
||||
} |
|
||||
|
|
||||
interface GoogleUserInfo { |
|
||||
sub: string; |
|
||||
email: string; |
|
||||
email_verified: boolean; |
|
||||
name: string; |
|
||||
picture?: string; |
|
||||
} |
|
||||
|
|
||||
export default defineEventHandler(async (event) => { |
|
||||
if (!OAUTH_GOOGLE_ENV.ENABLED) { |
|
||||
throw createError({ |
|
||||
statusCode: 404, |
|
||||
statusMessage: 'Google OAuth is not enabled', |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
const query = await getValidatedQuery( |
|
||||
event, |
|
||||
validateZod(CallbackQuerySchema, event) |
|
||||
); |
|
||||
|
|
||||
// Verify state to prevent CSRF
|
|
||||
const session = await useWGSession(event); |
|
||||
if (!session.data.oauthState || session.data.oauthState !== query.state) { |
|
||||
throw createError({ |
|
||||
statusCode: 400, |
|
||||
statusMessage: 'Invalid OAuth state', |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// Clear the state
|
|
||||
await session.update({ oauthState: undefined }); |
|
||||
|
|
||||
const host = getRequestHost(event); |
|
||||
const protocol = WG_ENV.INSECURE ? 'http' : 'https'; |
|
||||
const redirectUri = `${protocol}://${host}/api/auth/google/callback`; |
|
||||
|
|
||||
// Exchange code for tokens
|
|
||||
const tokenResponse = await $fetch<GoogleTokenResponse>( |
|
||||
'https://oauth2.googleapis.com/token', |
|
||||
{ |
|
||||
method: 'POST', |
|
||||
body: { |
|
||||
code: query.code, |
|
||||
client_id: OAUTH_GOOGLE_ENV.CLIENT_ID, |
|
||||
client_secret: OAUTH_GOOGLE_ENV.CLIENT_SECRET, |
|
||||
redirect_uri: redirectUri, |
|
||||
grant_type: 'authorization_code', |
|
||||
}, |
|
||||
} |
|
||||
); |
|
||||
|
|
||||
if (!tokenResponse.access_token) { |
|
||||
throw createError({ |
|
||||
statusCode: 401, |
|
||||
statusMessage: 'Failed to obtain access token from Google', |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// Get user info from Google
|
|
||||
const userInfo = await $fetch<GoogleUserInfo>( |
|
||||
'https://www.googleapis.com/oauth2/v3/userinfo', |
|
||||
{ |
|
||||
headers: { |
|
||||
Authorization: `Bearer ${tokenResponse.access_token}`, |
|
||||
}, |
|
||||
} |
|
||||
); |
|
||||
|
|
||||
if (!userInfo.email_verified) { |
|
||||
throw createError({ |
|
||||
statusCode: 401, |
|
||||
statusMessage: 'Google email is not verified', |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// Check allowed domain if configured
|
|
||||
if (OAUTH_GOOGLE_ENV.ALLOWED_DOMAIN) { |
|
||||
const emailDomain = userInfo.email.split('@')[1]; |
|
||||
if (emailDomain !== OAUTH_GOOGLE_ENV.ALLOWED_DOMAIN) { |
|
||||
throw createError({ |
|
||||
statusCode: 403, |
|
||||
statusMessage: 'Email domain is not allowed', |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Find or create user
|
|
||||
const result = await Database.users.findOrCreateByGoogle( |
|
||||
userInfo.sub, |
|
||||
userInfo.email, |
|
||||
userInfo.name |
|
||||
); |
|
||||
|
|
||||
if (!result.success) { |
|
||||
if (result.error === 'USER_DISABLED') { |
|
||||
throw createError({ |
|
||||
statusCode: 401, |
|
||||
statusMessage: 'User disabled', |
|
||||
}); |
|
||||
} |
|
||||
throw createError({ |
|
||||
statusCode: 500, |
|
||||
statusMessage: 'Unexpected error', |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// Create session
|
|
||||
await session.update({ userId: result.user.id }); |
|
||||
|
|
||||
SERVER_DEBUG( |
|
||||
`New Google OAuth Session for ${result.user.id} (${result.user.username})` |
|
||||
); |
|
||||
|
|
||||
return sendRedirect(event, '/'); |
|
||||
}); |
|
||||
@ -1,33 +0,0 @@ |
|||||
export default defineEventHandler(async (event) => { |
|
||||
if (!OAUTH_GOOGLE_ENV.ENABLED) { |
|
||||
throw createError({ |
|
||||
statusCode: 404, |
|
||||
statusMessage: 'Google OAuth is not enabled', |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
const host = getRequestHost(event); |
|
||||
const protocol = WG_ENV.INSECURE ? 'http' : 'https'; |
|
||||
const redirectUri = `${protocol}://${host}/api/auth/google/callback`; |
|
||||
|
|
||||
const state = crypto.randomUUID(); |
|
||||
|
|
||||
// Store state in session to prevent CSRF
|
|
||||
const session = await useWGSession(event); |
|
||||
await session.update({ oauthState: state }); |
|
||||
|
|
||||
const params = new URLSearchParams({ |
|
||||
client_id: OAUTH_GOOGLE_ENV.CLIENT_ID, |
|
||||
redirect_uri: redirectUri, |
|
||||
response_type: 'code', |
|
||||
scope: 'openid email profile', |
|
||||
state, |
|
||||
access_type: 'online', |
|
||||
prompt: 'select_account', |
|
||||
}); |
|
||||
|
|
||||
return sendRedirect( |
|
||||
event, |
|
||||
`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}` |
|
||||
); |
|
||||
}); |
|
||||
@ -1 +0,0 @@ |
|||||
ALTER TABLE `users_table` ADD `google_id` text; |
|
||||
@ -0,0 +1,2 @@ |
|||||
|
ALTER TABLE `users_table` ADD `oauth_provider` text;--> statement-breakpoint |
||||
|
ALTER TABLE `users_table` ADD `oauth_id` text; |
||||
@ -0,0 +1,41 @@ |
|||||
|
import type { H3Event } from 'h3'; |
||||
|
import { discovery } from 'openid-client'; |
||||
|
|
||||
|
const OAUTH_PROVIDERS = { |
||||
|
google: { |
||||
|
server: 'https://accounts.google.com', |
||||
|
scope: 'openid email', |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
export type OAUTH_PROVIDER = keyof typeof OAUTH_PROVIDERS; |
||||
|
|
||||
|
export function isValidOauthProvider( |
||||
|
provider: string |
||||
|
): provider is OAUTH_PROVIDER { |
||||
|
if (provider in OAUTH_PROVIDERS) { |
||||
|
return true; |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
export async function buildOauthConfig(event: H3Event) { |
||||
|
const provider = getRouterParam(event, 'provider'); |
||||
|
if (!provider || !isValidOauthProvider(provider)) { |
||||
|
throw createError({ |
||||
|
statusCode: 400, |
||||
|
statusMessage: 'Invalid provider', |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const oauthProvider = OAUTH_PROVIDERS[provider]; |
||||
|
const config = await discovery( |
||||
|
new URL(oauthProvider.server), |
||||
|
OAUTH_GOOGLE_ENV.CLIENT_ID, |
||||
|
{ |
||||
|
client_secret: OAUTH_GOOGLE_ENV.CLIENT_SECRET, |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
return { config, providerConfig: oauthProvider, provider }; |
||||
|
} |
||||
Loading…
Reference in new issue