Browse Source

wip make oauth more generic

dev-oauth
Bernd Storath 1 week ago
parent
commit
9beb9c191a
  1. 88
      src/server/api/auth/[provider]/callback.get.ts
  2. 34
      src/server/api/auth/[provider]/index.get.ts
  3. 130
      src/server/api/auth/google/callback.get.ts
  4. 33
      src/server/api/auth/google/index.get.ts
  5. 1
      src/server/database/migrations/0005_google_oauth.sql
  6. 2
      src/server/database/migrations/0005_quiet_sentinels.sql
  7. 15
      src/server/database/migrations/meta/0005_snapshot.json
  8. 4
      src/server/database/migrations/meta/_journal.json
  9. 3
      src/server/database/repositories/user/schema.ts
  10. 44
      src/server/database/repositories/user/service.ts
  11. 41
      src/server/utils/oauth.ts
  12. 4
      src/server/utils/session.ts

88
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, '/');
});

34
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<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());
});

130
src/server/api/auth/google/callback.get.ts

@ -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, '/');
});

33
src/server/api/auth/google/index.get.ts

@ -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
src/server/database/migrations/0005_google_oauth.sql

@ -1 +0,0 @@
ALTER TABLE `users_table` ADD `google_id` text;

2
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;

15
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": {}
}
}
}

4
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
}
]

3
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<OAUTH_PROVIDER>(),
oauthId: text('oauth_id'),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),

44
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 };
}

41
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 };
}

4
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';

Loading…
Cancel
Save