From 9beb9c191aa87d84a8fbf3c56502aa5997d9c3f2 Mon Sep 17 00:00:00 2001 From: Bernd Storath Date: Tue, 26 May 2026 11:28:35 +0200 Subject: [PATCH] wip make oauth more generic --- .../api/auth/[provider]/callback.get.ts | 88 ++++++++++++ src/server/api/auth/[provider]/index.get.ts | 34 +++++ src/server/api/auth/google/callback.get.ts | 130 ------------------ src/server/api/auth/google/index.get.ts | 33 ----- .../database/migrations/0005_google_oauth.sql | 1 - .../migrations/0005_quiet_sentinels.sql | 2 + .../migrations/meta/0005_snapshot.json | 15 +- .../database/migrations/meta/_journal.json | 4 +- .../database/repositories/user/schema.ts | 3 +- .../database/repositories/user/service.ts | 44 +++--- src/server/utils/oauth.ts | 41 ++++++ src/server/utils/session.ts | 4 +- 12 files changed, 210 insertions(+), 189 deletions(-) create mode 100644 src/server/api/auth/[provider]/callback.get.ts create mode 100644 src/server/api/auth/[provider]/index.get.ts delete mode 100644 src/server/api/auth/google/callback.get.ts delete mode 100644 src/server/api/auth/google/index.get.ts delete mode 100644 src/server/database/migrations/0005_google_oauth.sql create mode 100644 src/server/database/migrations/0005_quiet_sentinels.sql create mode 100644 src/server/utils/oauth.ts diff --git a/src/server/api/auth/[provider]/callback.get.ts b/src/server/api/auth/[provider]/callback.get.ts new file mode 100644 index 00000000..5a3b30ee --- /dev/null +++ b/src/server/api/auth/[provider]/callback.get.ts @@ -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, '/'); +}); diff --git a/src/server/api/auth/[provider]/index.get.ts b/src/server/api/auth/[provider]/index.get.ts new file mode 100644 index 00000000..c4a14258 --- /dev/null +++ b/src/server/api/auth/[provider]/index.get.ts @@ -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 = { + 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()); +}); diff --git a/src/server/api/auth/google/callback.get.ts b/src/server/api/auth/google/callback.get.ts deleted file mode 100644 index e6960cf6..00000000 --- a/src/server/api/auth/google/callback.get.ts +++ /dev/null @@ -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( - '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( - '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, '/'); -}); diff --git a/src/server/api/auth/google/index.get.ts b/src/server/api/auth/google/index.get.ts deleted file mode 100644 index d1f29fef..00000000 --- a/src/server/api/auth/google/index.get.ts +++ /dev/null @@ -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()}` - ); -}); diff --git a/src/server/database/migrations/0005_google_oauth.sql b/src/server/database/migrations/0005_google_oauth.sql deleted file mode 100644 index 5d8ec1e7..00000000 --- a/src/server/database/migrations/0005_google_oauth.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `users_table` ADD `google_id` text; \ No newline at end of file diff --git a/src/server/database/migrations/0005_quiet_sentinels.sql b/src/server/database/migrations/0005_quiet_sentinels.sql new file mode 100644 index 00000000..42e41e35 --- /dev/null +++ b/src/server/database/migrations/0005_quiet_sentinels.sql @@ -0,0 +1,2 @@ +ALTER TABLE `users_table` ADD `oauth_provider` text;--> statement-breakpoint +ALTER TABLE `users_table` ADD `oauth_id` text; \ No newline at end of file diff --git a/src/server/database/migrations/meta/0005_snapshot.json b/src/server/database/migrations/meta/0005_snapshot.json index 2c4a0779..9c799dab 100644 --- a/src/server/database/migrations/meta/0005_snapshot.json +++ b/src/server/database/migrations/meta/0005_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "id": "1d6d806f-441e-4f18-b84b-3e9232e45359", "prevId": "0f072f91-cd10-4702-ae7b-245255d69d1e", "tables": { "clients_table": { @@ -794,8 +794,15 @@ "notNull": true, "autoincrement": false }, - "google_id": { - "name": "google_id", + "oauth_provider": { + "name": "oauth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_id": { + "name": "oauth_id", "type": "text", "primaryKey": false, "notNull": false, @@ -991,4 +998,4 @@ "internal": { "indexes": {} } -} +} \ No newline at end of file diff --git a/src/server/database/migrations/meta/_journal.json b/src/server/database/migrations/meta/_journal.json index 8c9d5e7a..dd0f8170 100644 --- a/src/server/database/migrations/meta/_journal.json +++ b/src/server/database/migrations/meta/_journal.json @@ -40,8 +40,8 @@ { "idx": 5, "version": "6", - "when": 1716100000000, - "tag": "0005_google_oauth", + "when": 1779787680891, + "tag": "0005_quiet_sentinels", "breakpoints": true } ] diff --git a/src/server/database/repositories/user/schema.ts b/src/server/database/repositories/user/schema.ts index 750906a1..aea4158e 100644 --- a/src/server/database/repositories/user/schema.ts +++ b/src/server/database/repositories/user/schema.ts @@ -13,7 +13,8 @@ export const user = sqliteTable('users_table', { totpKey: text('totp_key'), totpVerified: int('totp_verified', { mode: 'boolean' }).notNull(), enabled: int({ mode: 'boolean' }).notNull(), - googleId: text('google_id'), + oauthProvider: text('oauth_provider').$type(), + oauthId: text('oauth_id'), createdAt: text('created_at') .notNull() .default(sql`(CURRENT_TIMESTAMP)`), diff --git a/src/server/database/repositories/user/service.ts b/src/server/database/repositories/user/service.ts index e8677544..4a44635c 100644 --- a/src/server/database/repositories/user/service.ts +++ b/src/server/database/repositories/user/service.ts @@ -1,8 +1,9 @@ -import { eq, sql } from 'drizzle-orm'; +import { eq, sql, and } from 'drizzle-orm'; import { TOTP } from 'otpauth'; import { user } from './schema'; import type { UserType } from './types'; import type { DBType } from '#db/sqlite'; +import type { OAUTH_PROVIDER } from '~~/server/utils/oauth'; type LoginResult = | { @@ -30,9 +31,12 @@ function createPreparedStatement(db: DBType) { where: eq(user.username, sql.placeholder('username')), }) .prepare(), - findByGoogleId: db.query.user + findByProviderId: db.query.user .findFirst({ - where: eq(user.googleId, sql.placeholder('googleId')), + where: and( + eq(user.oauthProvider, sql.placeholder('oauthProvider')), + eq(user.oauthId, sql.placeholder('oauthId')) + ), }) .prepare(), findByEmail: db.query.user @@ -91,17 +95,25 @@ export class UserService { return this.#statements.findByUsername.execute({ username }); } - async getByGoogleId(googleId: string) { - return this.#statements.findByGoogleId.execute({ googleId }); + async getByProviderId(provider: OAUTH_PROVIDER, oauthId: string) { + return this.#statements.findByProviderId.execute({ + oauthProvider: provider, + oauthId, + }); } async getByEmail(email: string) { return this.#statements.findByEmail.execute({ email }); } - async findOrCreateByGoogle(googleId: string, email: string, name: string) { - // First try to find by googleId - let existingUser = await this.getByGoogleId(googleId); + async findOrCreateByProvider( + provider: OAUTH_PROVIDER, + oauthId: string, + email: string, + name: string + ) { + // Try to find by id + let existingUser = await this.getByProviderId(provider, oauthId); if (existingUser) { if (!existingUser.enabled) { return { success: false as const, error: 'USER_DISABLED' as const }; @@ -109,7 +121,7 @@ export class UserService { return { success: true as const, user: existingUser }; } - // Try to find by email and link the Google account + // Try to find by email existingUser = await this.getByEmail(email); if (existingUser) { if (!existingUser.enabled) { @@ -117,28 +129,26 @@ export class UserService { } await this.#db .update(user) - .set({ googleId }) + .set({ oauthProvider: provider, oauthId: oauthId }) .where(eq(user.id, existingUser.id)) .execute(); return { success: true as const, user: existingUser }; } - // Create new user with Google account - const randomPassword = crypto.randomUUID(); - const hash = await hashPassword(randomPassword); - + // Create new user await this.#db.insert(user).values({ username: email, - password: hash, + password: '--- no password ---', email, name, role: roles.ADMIN, totpVerified: false, enabled: true, - googleId, + oauthProvider: provider, + oauthId, }); - const newUser = await this.getByGoogleId(googleId); + const newUser = await this.getByProviderId(provider, oauthId); if (!newUser) { return { success: false as const, error: 'UNEXPECTED_ERROR' as const }; } diff --git a/src/server/utils/oauth.ts b/src/server/utils/oauth.ts new file mode 100644 index 00000000..0b81588e --- /dev/null +++ b/src/server/utils/oauth.ts @@ -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 }; +} diff --git a/src/server/utils/session.ts b/src/server/utils/session.ts index 3f3454ac..c638a7f7 100644 --- a/src/server/utils/session.ts +++ b/src/server/utils/session.ts @@ -3,7 +3,9 @@ import type { UserType } from '#db/repositories/user/types'; export type WGSession = Partial<{ userId: ID; - oauthState: string; + oauth_verifier: string; + oauth_nonce: string; + oauth_state: string; }>; const name = 'wg-easy';