diff --git a/docker-compose.yml b/docker-compose.yml
index 1dc53c2a..208ed61d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,6 +8,11 @@ services:
# - PORT=51821
# - HOST=0.0.0.0
# - INSECURE=false
+ # Google OAuth (optional):
+ # - OAUTH_GOOGLE_ENABLED=true
+ # - OAUTH_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
+ # - OAUTH_GOOGLE_CLIENT_SECRET=your-client-secret
+ # - OAUTH_GOOGLE_ALLOWED_DOMAIN=example.com
image: ghcr.io/wg-easy/wg-easy:15
container_name: wg-easy
diff --git a/src/app/pages/login.vue b/src/app/pages/login.vue
index 1d716fb4..be1fbab6 100644
--- a/src/app/pages/login.vue
+++ b/src/app/pages/login.vue
@@ -2,9 +2,8 @@
-
+
+
+
+
+
+
+
@@ -77,6 +121,8 @@ const password = ref('');
const totpRequired = ref(false);
const totp = ref('');
+const { data: authMethods } = await useFetch('/api/auth/methods');
+
const _submit = useSubmit(
'/api/session',
{
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 86120f19..4c065256 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -72,6 +72,8 @@
},
"login": {
"signIn": "Sign In",
+ "signInWithGoogle": "Sign in with Google",
+ "or": "or",
"rememberMe": "Remember me",
"rememberMeDesc": "Stay logged after closing the browser",
"insecure": "You can't log in with an insecure connection. Use HTTPS.",
diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json
index 2d572eaa..ebc989c3 100644
--- a/src/i18n/locales/pl.json
+++ b/src/i18n/locales/pl.json
@@ -72,6 +72,8 @@
},
"login": {
"signIn": "Zaloguj się",
+ "signInWithGoogle": "Zaloguj się przez Google",
+ "or": "lub",
"rememberMe": "Zapamiętaj mnie",
"rememberMeDesc": "Pozostań zalogowany po zamknięciu przeglądarki",
"insecure": "Nie możesz zalogować się przez niezabezpieczone połączenie. Użyj HTTPS.",
diff --git a/src/server/api/auth/google/callback.get.ts b/src/server/api/auth/google/callback.get.ts
new file mode 100644
index 00000000..e6960cf6
--- /dev/null
+++ b/src/server/api/auth/google/callback.get.ts
@@ -0,0 +1,130 @@
+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
new file mode 100644
index 00000000..d1f29fef
--- /dev/null
+++ b/src/server/api/auth/google/index.get.ts
@@ -0,0 +1,33 @@
+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/api/auth/methods.get.ts b/src/server/api/auth/methods.get.ts
new file mode 100644
index 00000000..f6d94be5
--- /dev/null
+++ b/src/server/api/auth/methods.get.ts
@@ -0,0 +1,5 @@
+export default defineEventHandler(() => {
+ return {
+ google: OAUTH_GOOGLE_ENV.ENABLED,
+ };
+});
diff --git a/src/server/database/migrations/0005_google_oauth.sql b/src/server/database/migrations/0005_google_oauth.sql
new file mode 100644
index 00000000..5d8ec1e7
--- /dev/null
+++ b/src/server/database/migrations/0005_google_oauth.sql
@@ -0,0 +1 @@
+ALTER TABLE `users_table` ADD `google_id` text;
\ 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 fefdc1c9..8c9d5e7a 100644
--- a/src/server/database/migrations/meta/_journal.json
+++ b/src/server/database/migrations/meta/_journal.json
@@ -36,6 +36,13 @@
"when": 1771352889394,
"tag": "0004_optimal_mandrill",
"breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "6",
+ "when": 1716100000000,
+ "tag": "0005_google_oauth",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/server/database/repositories/user/schema.ts b/src/server/database/repositories/user/schema.ts
index 77eb13e2..750906a1 100644
--- a/src/server/database/repositories/user/schema.ts
+++ b/src/server/database/repositories/user/schema.ts
@@ -13,6 +13,7 @@ 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'),
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 f130da20..3e80cbee 100644
--- a/src/server/database/repositories/user/service.ts
+++ b/src/server/database/repositories/user/service.ts
@@ -30,6 +30,16 @@ function createPreparedStatement(db: DBType) {
where: eq(user.username, sql.placeholder('username')),
})
.prepare(),
+ findByGoogleId: db.query.user
+ .findFirst({
+ where: eq(user.googleId, sql.placeholder('googleId')),
+ })
+ .prepare(),
+ findByEmail: db.query.user
+ .findFirst({
+ where: eq(user.email, sql.placeholder('email')),
+ })
+ .prepare(),
update: db
.update(user)
.set({
@@ -70,6 +80,61 @@ export class UserService {
return this.#statements.findByUsername.execute({ username });
}
+ async getByGoogleId(googleId: string) {
+ return this.#statements.findByGoogleId.execute({ googleId });
+ }
+
+ 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);
+ if (existingUser) {
+ if (!existingUser.enabled) {
+ return { success: false as const, error: 'USER_DISABLED' as const };
+ }
+ return { success: true as const, user: existingUser };
+ }
+
+ // Try to find by email and link the Google account
+ existingUser = await this.getByEmail(email);
+ if (existingUser) {
+ if (!existingUser.enabled) {
+ return { success: false as const, error: 'USER_DISABLED' as const };
+ }
+ await this.#db
+ .update(user)
+ .set({ googleId })
+ .where(eq(user.id, existingUser.id))
+ .execute();
+ return { success: true as const, user: existingUser };
+ }
+
+ // Create new user with Google account
+ const userCount = await this.#db.$count(user);
+ const randomPassword = crypto.randomUUID();
+ const hash = await hashPassword(randomPassword);
+
+ await this.#db.insert(user).values({
+ username: email,
+ password: hash,
+ email,
+ name,
+ role: userCount === 0 ? roles.ADMIN : roles.CLIENT,
+ totpVerified: false,
+ enabled: true,
+ googleId,
+ });
+
+ const newUser = await this.getByGoogleId(googleId);
+ if (!newUser) {
+ return { success: false as const, error: 'UNEXPECTED_ERROR' as const };
+ }
+ return { success: true as const, user: newUser };
+ }
+
async create(username: string, password: string) {
const hash = await hashPassword(password);
diff --git a/src/server/utils/config.ts b/src/server/utils/config.ts
index 1c5b2081..93de32f5 100644
--- a/src/server/utils/config.ts
+++ b/src/server/utils/config.ts
@@ -40,6 +40,17 @@ export const WG_ENV = {
WG_EXECUTABLE: await detectAwg(),
};
+export const OAUTH_GOOGLE_ENV = {
+ /** Enable Google OAuth login */
+ ENABLED: process.env.OAUTH_GOOGLE_ENABLED === 'true',
+ /** Google OAuth Client ID */
+ CLIENT_ID: process.env.OAUTH_GOOGLE_CLIENT_ID || '',
+ /** Google OAuth Client Secret */
+ CLIENT_SECRET: process.env.OAUTH_GOOGLE_CLIENT_SECRET || '',
+ /** Allowed email domain (optional, e.g. "example.com") */
+ ALLOWED_DOMAIN: process.env.OAUTH_GOOGLE_ALLOWED_DOMAIN || '',
+};
+
export const WG_INITIAL_ENV = {
ENABLED: process.env.INIT_ENABLED === 'true',
USERNAME: process.env.INIT_USERNAME,
diff --git a/src/server/utils/session.ts b/src/server/utils/session.ts
index 1a144cea..3f3454ac 100644
--- a/src/server/utils/session.ts
+++ b/src/server/utils/session.ts
@@ -3,6 +3,7 @@ import type { UserType } from '#db/repositories/user/types';
export type WGSession = Partial<{
userId: ID;
+ oauthState: string;
}>;
const name = 'wg-easy';