feat: oauth integration (#2659)

* feat: add Google OAuth login support (#2625)

* 🔧 Add login via google

* 🔧 Update code style and docs

* Add fix for db migrate

* 🔧 Update docker-compose

* 🔧 Fix sqlite"

* 🔧 Update docker-compose

* ⚰️ Remove environments

* 🔧 Fix: remove ensureGoogleIdColumn workaround from sqlite.ts

* 🔧 Remove space

* move oauth section

* add openid client

* wip make oauth more generic

* properly allow multiple providers

* fix type import

* github login flow

* adjust github logo with theme

* move docs to own page

* nullable password, prevent timing attack

this prevents timing attacks by always checking hash even if there is none
prevents using basic auth if 2fa is enabled

* support generic oidc

* add ability to set password for oidc users

this allows oidc users to add password login
cant be removed after

* move password login route

move password login route from /api/session to /api/auth/password
align with oauth

* unique index on oauth

* link/unlink logic

* improve docs

* support allowed domains

* support auto register

* refactoring

* disable pw auth

* move 2fa to its own page

* 2fa for oauth, rework 2fa system

* fix design, fix link

Closes #2650

* add auto launch

* improve docs

* improvements

---------

Co-authored-by: Daniel Molenda <dm@fotc.com>
This commit is contained in:
Bernd Storath
2026-06-12 13:38:32 +02:00
committed by GitHub
co-authored by Daniel Molenda
parent c70ad1d08b
commit ac547bbf7c
44 changed files with 2848 additions and 222 deletions
@@ -0,0 +1,87 @@
export default defineEventHandler(async (event) => {
const { config, provider, providerConfig } = 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 userInfo = await getUserInfo(
event,
config,
{
oauth_nonce: session.data.oauth_nonce,
oauth_verifier: session.data.oauth_verifier,
oauth_state: session.data.oauth_state,
},
providerConfig
);
const result = await Database.users.loginWithOAuth(
provider,
userInfo.sub,
userInfo.preferred_username || userInfo.email,
userInfo.email,
userInfo.name || 'User'
);
if (!result.success) {
switch (result.error) {
case 'TOTP_REQUIRED':
await session.update({
pendingLogin: {
type: 'oauth',
userId: result.userId,
remember: false,
},
oauth_nonce: undefined,
oauth_state: undefined,
oauth_verifier: undefined,
});
return sendRedirect(event, '/login/2fa');
case 'USER_DISABLED':
throw createError({
statusCode: 401,
statusMessage: 'User disabled',
});
case 'USER_ALREADY_LINKED':
throw createError({
statusCode: 401,
statusMessage:
'User already linked with different account or provider',
});
case 'AUTO_REGISTER_DISABLED':
throw createError({
statusCode: 401,
statusMessage: 'Auto registration is disabled',
});
case 'UNEXPECTED_ERROR':
throw createError({
statusCode: 500,
statusMessage: 'Unexpected error',
});
}
assertUnreachable(result);
}
// Create session
const data = await session.update({
userId: result.user.id,
oauth_nonce: undefined,
oauth_state: undefined,
oauth_verifier: undefined,
});
SERVER_DEBUG(
`New OAuth Session: ${data.id} for ${result.user.id} (${result.user.username}) with ${provider}`
);
return sendRedirect(event, '/');
});
@@ -0,0 +1,50 @@
import * as client from 'openid-client';
import { z } from 'zod';
const OauthQuerySchema = z.object({
link: z.coerce.boolean().optional(),
});
export default defineEventHandler(async (event) => {
const params = await getValidatedQuery(
event,
validateZod(OauthQuerySchema, event)
);
const { config, provider, providerConfig } = await buildOauthConfig(event);
const host = getRequestHost(event);
const protocol = WG_ENV.INSECURE ? 'http' : 'https';
const baseUri = `${protocol}://${host}/api/auth/${provider}`;
let redirectUri = `${baseUri}/callback`;
if (params.link) {
redirectUri = `${baseUri}/link`;
}
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const nonce = client.randomNonce();
const state = client.randomState();
const parameters: Record<string, string> = {
...providerConfig.params,
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());
});
@@ -0,0 +1,36 @@
export default definePermissionEventHandler(
'me',
'update',
async ({ event, user, checkPermissions }) => {
checkPermissions(user);
const { config, provider, providerConfig } = 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 userInfo = await getUserInfo(
event,
config,
{
oauth_nonce: session.data.oauth_nonce,
oauth_verifier: session.data.oauth_verifier,
oauth_state: session.data.oauth_state,
},
providerConfig
);
await Database.users.linkOauth(user.id, provider, userInfo.sub);
return sendRedirect(event, '/me');
}
);
+10
View File
@@ -0,0 +1,10 @@
export default defineEventHandler(async (event) => {
const session = await useWGSession(event, false);
await session.update({
pendingLogin: undefined,
oauth_nonce: undefined,
oauth_state: undefined,
oauth_verifier: undefined,
});
return { success: true as const };
});
+18
View File
@@ -0,0 +1,18 @@
export default defineEventHandler(() => {
return {
providers: WG_ENV.OAUTH_PROVIDERS?.reduce(
(acc, curr) => {
acc[curr] = {
enabled: true,
friendlyName: OAUTH_PROVIDERS[curr].friendlyName,
};
return acc;
},
{} as Record<OAUTH_PROVIDER, { enabled: true; friendlyName: string }>
),
oauthEnabled:
WG_ENV.OAUTH_PROVIDERS !== undefined && WG_ENV.OAUTH_PROVIDERS.length > 0,
passwordDisabled: WG_ENV.DISABLE_PASSWORD_AUTH,
autoLaunchProvider: WG_ENV.OAUTH_AUTO_LAUNCH,
};
});
@@ -1,12 +1,21 @@
import { UserLoginSchema } from '#db/repositories/user/types';
export default defineEventHandler(async (event) => {
const { username, password, remember, totpCode } = await readValidatedBody(
if (WG_ENV.DISABLE_PASSWORD_AUTH) {
throw createError({
statusCode: 403,
statusMessage: 'Password authentication is disabled',
});
}
const { username, password, remember } = await readValidatedBody(
event,
validateZod(UserLoginSchema, event)
);
const result = await Database.users.login(username, password, totpCode);
const result = await Database.users.login(username, password);
const session = await useWGSession(event, remember);
// TODO: add localization support
@@ -18,6 +27,13 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Invalid username or password',
});
case 'TOTP_REQUIRED':
await session.update({
pendingLogin: {
type: 'password',
userId: result.userId,
remember,
},
});
return { status: 'TOTP_REQUIRED' as const };
case 'INVALID_TOTP_CODE':
return { status: 'INVALID_TOTP_CODE' as const };
@@ -32,13 +48,11 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Unexpected error',
});
}
assertUnreachable(result.error);
assertUnreachable(result);
}
const user = result.user;
const session = await useWGSession(event, remember);
const data = await session.update({
userId: user.id,
});
+14
View File
@@ -0,0 +1,14 @@
export default defineEventHandler(async (event) => {
const session = await useWGSession(event);
if (!session.data.pendingLogin) {
throw createError({
statusCode: 401,
statusMessage: 'No pending authentication',
});
}
return {
type: session.data.pendingLogin.type,
};
});
+11
View File
@@ -0,0 +1,11 @@
export default definePermissionEventHandler(
'me',
'update',
async ({ user, checkPermissions }) => {
checkPermissions(user);
await Database.users.unlinkOauth(user.id);
return { success: true };
}
);
+50
View File
@@ -0,0 +1,50 @@
import { z } from 'zod';
const Verify2faSchema = z.object({
totpCode: z.string().min(6).max(6),
});
export default defineEventHandler(async (event) => {
const { totpCode } = await readValidatedBody(
event,
validateZod(Verify2faSchema, event)
);
const session = await useWGSession(event);
const pendingLogin = session.data.pendingLogin;
if (!pendingLogin) {
throw createError({
statusCode: 401,
statusMessage: 'No pending authentication',
});
}
const totpStatus = await Database.users.validateTotpCode(
pendingLogin.userId,
totpCode
);
switch (totpStatus) {
case 'INVALID_TOTP_CODE':
return { status: 'INVALID_TOTP_CODE' as const };
case 'USER_DISABLED':
throw createError({
statusCode: 401,
statusMessage: 'User disabled',
});
case 'success':
break;
default:
assertUnreachable(totpStatus);
}
await session.update({
userId: pendingLogin.userId,
pendingLogin: undefined,
oauth_nonce: undefined,
oauth_state: undefined,
oauth_verifier: undefined,
});
return { status: 'success' as const };
});
+9 -3
View File
@@ -1,9 +1,7 @@
import type { SharedPublicUser } from '~~/shared/utils/permissions';
export default defineEventHandler(async (event) => {
const session = await useWGSession(event);
if (!session.data.userId) {
if (!session.data.userId || session.data.pendingLogin) {
// not logged in
throw createError({
statusCode: 401,
@@ -18,6 +16,12 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Not found in Database',
});
}
if (!user.enabled) {
throw createError({
statusCode: 403,
statusMessage: 'User is disabled',
});
}
return {
id: user.id,
@@ -26,5 +30,7 @@ export default defineEventHandler(async (event) => {
name: user.name,
email: user.email,
totpVerified: user.totpVerified,
oauthProvider: user.oauthProvider,
hasPassword: user.password !== null,
} satisfies SharedPublicUser;
});
@@ -0,0 +1,23 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_users_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text NOT NULL,
`password` text,
`email` text,
`name` text NOT NULL,
`role` integer NOT NULL,
`totp_key` text,
`totp_verified` integer NOT NULL,
`enabled` integer NOT NULL,
`oauth_provider` text,
`oauth_id` text,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_users_table`("id", "username", "password", "email", "name", "role", "totp_key", "totp_verified", "enabled", "created_at", "updated_at") SELECT "id", "username", "password", "email", "name", "role", "totp_key", "totp_verified", "enabled", "created_at", "updated_at" FROM `users_table`;--> statement-breakpoint
DROP TABLE `users_table`;--> statement-breakpoint
ALTER TABLE `__new_users_table` RENAME TO `users_table`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `users_table_username_unique` ON `users_table` (`username`);--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_provider_id_unique` ON `users_table` (`oauth_provider`,`oauth_id`);
File diff suppressed because it is too large Load Diff
@@ -36,6 +36,13 @@
"when": 1771352889394,
"tag": "0004_optimal_mandrill",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1780035570366,
"tag": "0005_clumsy_korg",
"breakpoints": true
}
]
}
+31 -19
View File
@@ -1,26 +1,38 @@
import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { int, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { client } from '../../schema';
export const user = sqliteTable('users_table', {
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(),
password: text().notNull(),
email: text(),
name: text().notNull(),
role: int().$type<Role>().notNull(),
totpKey: text('totp_key'),
totpVerified: int('totp_verified', { mode: 'boolean' }).notNull(),
enabled: int({ mode: 'boolean' }).notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
});
export const user = sqliteTable(
'users_table',
{
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(),
/** `password == null` means password login disabled */
password: text(),
email: text(),
name: text().notNull(),
role: int().$type<Role>().notNull(),
totpKey: text('totp_key'),
totpVerified: int('totp_verified', { mode: 'boolean' }).notNull(),
enabled: int({ mode: 'boolean' }).notNull(),
oauthProvider: text('oauth_provider').$type<OAUTH_PROVIDER>(),
oauthId: text('oauth_id'),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex('oauth_provider_id_unique').on(
table.oauthProvider,
table.oauthId
),
]
);
export const usersRelations = relations(user, ({ many }) => ({
clients: many(client),
+240 -33
View File
@@ -1,4 +1,4 @@
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';
@@ -13,10 +13,33 @@ type LoginResult =
success: false;
error:
| 'INCORRECT_CREDENTIALS'
| 'TOTP_REQUIRED'
| 'USER_DISABLED'
| 'INVALID_TOTP_CODE'
| 'UNEXPECTED_ERROR';
}
| {
success: false;
error: 'TOTP_REQUIRED';
userId: ID;
};
type LoginWithOAuthResult =
| {
success: true;
user: UserType;
}
| {
success: false;
error:
| 'USER_DISABLED'
| 'USER_ALREADY_LINKED'
| 'UNEXPECTED_ERROR'
| 'AUTO_REGISTER_DISABLED';
}
| {
success: false;
error: 'TOTP_REQUIRED';
userId: ID;
};
function createPreparedStatement(db: DBType) {
@@ -113,7 +136,11 @@ export class UserService {
return this.#statements.update.execute({ id, name, email });
}
async updatePassword(id: ID, currentPassword: string, newPassword: string) {
async updatePassword(
id: ID,
currentPassword: string | null,
newPassword: string
) {
const hash = await hashPassword(newPassword);
return this.#db.transaction(async (tx) => {
@@ -126,13 +153,20 @@ export class UserService {
throw new Error('User not found');
}
const passwordValid = await isPasswordValid(
currentPassword,
txUser.password
);
// only check password if already set
if (txUser.password !== null) {
if (!currentPassword) {
throw new Error('Invalid password');
}
if (!passwordValid) {
throw new Error('Invalid password');
const passwordValid = await isPasswordValid(
currentPassword,
txUser.password
);
if (!passwordValid) {
throw new Error('Invalid password');
}
}
await tx
@@ -147,42 +181,32 @@ export class UserService {
return this.#statements.updateKey.execute({ id, key });
}
login(username: string, password: string, code: string | undefined) {
login(username: string, password: string) {
return this.#db.transaction(async (tx): Promise<LoginResult> => {
const txUser = await tx.query.user
.findFirst({ where: eq(user.username, username) })
.execute();
if (!txUser) {
// always check to avoid timing attack
const userHashPassword = txUser?.password ?? null;
const passwordValid = await isPasswordValid(password, userHashPassword);
if (!txUser || !passwordValid) {
return { success: false, error: 'INCORRECT_CREDENTIALS' };
}
const passwordValid = await isPasswordValid(password, txUser.password);
if (!passwordValid) {
return { success: false, error: 'INCORRECT_CREDENTIALS' };
}
if (txUser.totpVerified) {
if (!code) {
return { success: false, error: 'TOTP_REQUIRED' };
} else {
const totpKey = txUser.totpKey;
if (!totpKey) {
return { success: false, error: 'UNEXPECTED_ERROR' };
}
const totp = this.#createTotp({ username: txUser.username, totpKey });
if (totp.validate({ token: code, window: 1 }) === null) {
return { success: false, error: 'INVALID_TOTP_CODE' };
}
}
}
if (!txUser.enabled) {
return { success: false, error: 'USER_DISABLED' };
}
if (txUser.totpVerified) {
return {
success: false,
error: 'TOTP_REQUIRED',
userId: txUser.id,
};
}
return { success: true, user: txUser };
});
}
@@ -241,4 +265,187 @@ export class UserService {
.execute();
});
}
async validateTotpCode(id: ID, code: string) {
const txUser = await this.#db.query.user
.findFirst({ where: eq(user.id, id) })
.execute();
if (!txUser || !txUser.totpVerified || !txUser.totpKey) {
return 'INVALID_TOTP_CODE' as const;
}
const totp = this.#createTotp({
username: txUser.username,
totpKey: txUser.totpKey,
});
const isValid = totp.validate({ token: code, window: 1 }) !== null;
if (!isValid) {
return 'INVALID_TOTP_CODE' as const;
}
if (!txUser.enabled) {
return 'USER_DISABLED' as const;
}
return 'success' as const;
}
/**
* Login or register user with OAuth provider.
* If user with the same email already exists, link account with OAuth provider.
* Otherwise, create new user.
*/
async loginWithOAuth(
provider: OAUTH_PROVIDER,
oauthId: string,
username: string,
email: string,
name: string
): Promise<LoginWithOAuthResult> {
return this.#db.transaction(async (tx) => {
const userById = await tx.query.user
.findFirst({
where: and(
eq(user.oauthProvider, provider),
eq(user.oauthId, oauthId)
),
})
.execute();
if (userById) {
if (!userById.enabled) {
return { success: false, error: 'USER_DISABLED' };
}
if (userById.totpVerified) {
return {
success: false,
error: 'TOTP_REQUIRED',
userId: userById.id,
};
}
return { success: true, user: userById };
}
const userByEmail = await tx.query.user
.findFirst({
where: eq(user.email, email),
})
.execute();
if (userByEmail) {
if (!userByEmail.enabled) {
return { success: false, error: 'USER_DISABLED' };
}
if (userByEmail.oauthProvider && userByEmail.oauthId) {
return {
success: false,
error: 'USER_ALREADY_LINKED',
};
}
await tx
.update(user)
.set({ oauthProvider: provider, oauthId: oauthId })
.where(eq(user.id, userByEmail.id))
.execute();
if (userByEmail.totpVerified) {
return {
success: false,
error: 'TOTP_REQUIRED',
userId: userByEmail.id,
};
}
// TODO: return updated user
return { success: true, user: userByEmail };
}
if (!WG_ENV.OAUTH_AUTO_REGISTER) {
return { success: false, error: 'AUTO_REGISTER_DISABLED' };
}
// Create new user
const newUsers = await tx
.insert(user)
.values({
username,
password: null,
email,
name,
role: roles.ADMIN,
totpVerified: false,
enabled: true,
oauthProvider: provider,
oauthId,
})
.returning();
const newUser = newUsers[0];
if (!newUser) {
return { success: false as const, error: 'UNEXPECTED_ERROR' as const };
}
return { success: true as const, user: newUser };
});
}
unlinkOauth(id: ID) {
return this.#db.transaction(async (tx) => {
const txUser = await tx.query.user
.findFirst({ where: eq(user.id, id) })
.execute();
if (!txUser) {
throw new Error('User not found');
}
// can't unlink if no way to log back in
if (!txUser.password) {
throw new Error('Password login not enabled');
}
await tx
.update(user)
.set({ oauthProvider: null, oauthId: null })
.where(eq(user.id, id))
.execute();
});
}
async linkOauth(id: ID, provider: OAUTH_PROVIDER, oauthId: string) {
return this.#db.transaction(async (tx) => {
const txUser = await tx.query.user
.findFirst({ where: eq(user.id, id) })
.execute();
if (!txUser) {
throw new Error('User not found');
}
if (txUser.oauthProvider || txUser.oauthId) {
throw new Error('User already linked with an OAuth provider');
}
const existingUser = await tx.query.user
.findFirst({
where: and(
eq(user.oauthProvider, provider),
eq(user.oauthId, oauthId)
),
})
.execute();
if (existingUser) {
throw new Error('OAuth account already linked with another user');
}
await tx
.update(user)
.set({ oauthProvider: provider, oauthId: oauthId })
.where(eq(user.id, id))
.execute();
});
}
}
@@ -25,7 +25,6 @@ export const UserLoginSchema = z.object({
username: username,
password: password,
remember: remember,
totpCode: totpCode.optional(),
});
export const UserSetupSchema = z
@@ -57,7 +56,7 @@ export const UserUpdateSchema = z.object({
export const UserUpdatePasswordSchema = z
.object({
currentPassword: password,
currentPassword: password.nullable(),
newPassword: password,
confirmPassword: password,
})
+10 -8
View File
@@ -1,12 +1,14 @@
export default defineNitroPlugin((nitroApp) => {
console.log(`====================================================`);
console.log(` wg-easy - https://github.com/wg-easy/wg-easy `);
console.log(`====================================================`);
console.log(`| wg-easy: ${RELEASE.padEnd(38)} |`);
console.log(`| Node: ${process.version.padEnd(38)} |`);
console.log(`| Platform: ${process.platform.padEnd(38)} |`);
console.log(`| Arch: ${process.arch.padEnd(38)} |`);
console.log(`====================================================`);
console.log(`
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ wg-easy - https://github.com/wg-easy/wg-easy ┃
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
┃ wg-easy: ${RELEASE.padEnd(38)}
┃ Node: ${process.version.padEnd(38)}
┃ Platform: ${process.platform.padEnd(38)}
┃ Arch: ${process.arch.padEnd(38)}
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
`);
nitroApp.hooks.hook('close', async () => {
console.log('Shutting down');
await WireGuard.Shutdown();
+28
View File
@@ -30,6 +30,11 @@ const detectAwg = async (): Promise<'awg' | 'wg'> => {
} else return 'wg';
};
const oauthProviders = process.env.OAUTH_PROVIDERS?.split(',')
.map((v) => v.trim())
.filter((v) => isValidOauthProvider(v))
.filter((v) => isConfiguredOauthProvider(OAUTH_PROVIDERS[v]));
export const WG_ENV = {
/** UI is hosted on HTTP instead of HTTPS */
INSECURE: process.env.INSECURE === 'true',
@@ -39,8 +44,31 @@ export const WG_ENV = {
DISABLE_IPV6: process.env.DISABLE_IPV6 === 'true',
WG_EXECUTABLE: await detectAwg(),
DISABLE_VERSION_CHECK: process.env.DISABLE_VERSION_CHECK === 'true',
/** List of enabled OAuth providers */
OAUTH_PROVIDERS: oauthProviders,
/** List of allowed OAuth domains */
OAUTH_ALLOWED_DOMAINS: process.env.OAUTH_ALLOWED_DOMAINS?.split(',').map(
(v) => v.trim()
),
/** Automatically register users that log in with an OAuth provider */
OAUTH_AUTO_REGISTER: process.env.OAUTH_AUTO_REGISTER === 'true',
/** Which OAuth provider to automatically launch */
OAUTH_AUTO_LAUNCH:
oauthProviders?.find((p) => p === process.env.OAUTH_AUTO_LAUNCH) ?? null,
/** Disable password authentication */
DISABLE_PASSWORD_AUTH: process.env.DISABLE_PASSWORD_AUTH === 'true',
};
if (WG_ENV.OAUTH_PROVIDERS && WG_ENV.OAUTH_PROVIDERS.length > 0) {
SERVER_DEBUG(`
Enabled OAuth providers: ${WG_ENV.OAUTH_PROVIDERS.join(', ')}
Allowed OAuth domains: ${WG_ENV.OAUTH_ALLOWED_DOMAINS?.join(', ') ?? 'All'}
OAuth auto register: ${WG_ENV.OAUTH_AUTO_REGISTER ? 'Enabled' : 'Disabled'}
Password authentication: ${WG_ENV.DISABLE_PASSWORD_AUTH ? 'Disabled' : 'Enabled'}
Auto launch OAuth provider: ${WG_ENV.OAUTH_AUTO_LAUNCH ?? 'None'}
`);
}
export const WG_INITIAL_ENV = {
ENABLED: process.env.INIT_ENABLED === 'true',
USERNAME: process.env.INIT_USERNAME,
+258
View File
@@ -0,0 +1,258 @@
import type { H3Event } from 'h3';
import * as client from 'openid-client';
type OAuthConfig = {
friendlyName: string;
server: string;
scope: string;
clientId: string | undefined;
clientSecret: string | undefined;
params: Record<string, string>;
isOIDC?: false;
userInfoFlow?: 'github';
};
const GoogleConfig: OAuthConfig = {
friendlyName: 'Google',
server: 'https://accounts.google.com',
scope: 'openid email profile',
clientId: process.env.OAUTH_GOOGLE_CLIENT_ID,
clientSecret: process.env.OAUTH_GOOGLE_CLIENT_SECRET,
params: {
access_type: 'online',
prompt: 'select_account',
},
};
const GithubConfig: OAuthConfig = {
friendlyName: 'GitHub',
server: 'https://github.com/login/oauth',
scope: 'read:user user:email',
clientId: process.env.OAUTH_GITHUB_CLIENT_ID,
clientSecret: process.env.OAUTH_GITHUB_CLIENT_SECRET,
params: {
allow_signup: 'false',
prompt: 'select_account',
},
isOIDC: false,
userInfoFlow: 'github',
};
const OidcConfig: OAuthConfig = {
friendlyName: process.env.OAUTH_OIDC_NAME ?? 'OIDC',
server: process.env.OAUTH_OIDC_SERVER ?? '',
scope: 'openid email profile',
clientId: process.env.OAUTH_OIDC_CLIENT_ID,
clientSecret: process.env.OAUTH_OIDC_CLIENT_SECRET,
params: {},
};
export const OAUTH_PROVIDERS = {
google: GoogleConfig,
github: GithubConfig,
oidc: OidcConfig,
};
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 function isConfiguredOauthProvider(
oauthProvider: (typeof OAUTH_PROVIDERS)[OAUTH_PROVIDER]
): oauthProvider is (typeof OAUTH_PROVIDERS)[OAUTH_PROVIDER] & {
clientId: string;
clientSecret: string;
} {
if (!oauthProvider.clientId || !oauthProvider.clientSecret) {
return false;
}
return true;
}
function isEnabledProvider(provider: OAUTH_PROVIDER) {
return WG_ENV.OAUTH_PROVIDERS?.includes(provider);
}
// TODO: simplify logic between WG_ENV.OAUTH_PROVIDERS and buildOauthConfig
export async function buildOauthConfig(event: H3Event) {
const provider = getRouterParam(event, 'provider');
if (!provider || !isValidOauthProvider(provider)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid provider',
});
}
if (!isEnabledProvider(provider)) {
throw createError({
statusCode: 403,
statusMessage: 'Provider is not enabled',
});
}
const oauthProvider = OAUTH_PROVIDERS[provider];
if (!isConfiguredOauthProvider(oauthProvider)) {
throw createError({
statusCode: 500,
statusMessage: 'Provider is not configured',
});
}
const config = await client.discovery(
new URL(oauthProvider.server),
oauthProvider.clientId,
{
client_secret: oauthProvider.clientSecret,
}
);
return { config, providerConfig: oauthProvider, provider };
}
export async function githubUserInfoFlow(accessToken: string) {
const OAUTH_GITHUB_FLOW = {
userinfo_endpoint: 'https://api.github.com/user',
email_endpoint: 'https://api.github.com/user/emails',
};
type OAUTH_GITHUB_USERINFO = {
id: number;
login: string;
avatar_url: string;
email: string | null;
name: string | null;
};
type OAUTH_GITHUB_EMAIL = {
email: string;
primary: boolean;
verified: boolean;
visibility: string | null;
}[];
const response = await $fetch<OAUTH_GITHUB_USERINFO>(
OAUTH_GITHUB_FLOW.userinfo_endpoint,
{
headers: {
'User-Agent': 'wg-easy',
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!response.email) {
const emailResponse = await $fetch<OAUTH_GITHUB_EMAIL>(
OAUTH_GITHUB_FLOW.email_endpoint,
{
headers: {
'User-Agent': 'wg-easy',
Authorization: `Bearer ${accessToken}`,
},
}
);
const primaryEmail = emailResponse.find((v) => v.primary && v.verified);
response.email = primaryEmail?.email || null;
}
return {
sub: response.id.toString(),
email: response.email ?? undefined,
email_verified: true,
preferred_username: response.login,
name: response.name || response.login,
};
}
type OauthState = {
oauth_nonce: string;
oauth_verifier: string;
oauth_state: string;
};
export async function getUserInfo(
event: H3Event,
config: client.Configuration,
state: OauthState,
providerConfig: OAuthConfig
) {
const currentUrl = getRequestURL(event);
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: state.oauth_verifier,
expectedNonce:
providerConfig.isOIDC === false ? undefined : state.oauth_nonce,
expectedState: state.oauth_state,
idTokenExpected: providerConfig.isOIDC ?? true,
});
type SubjectType = string | undefined | typeof client.skipSubjectCheck;
let subject: SubjectType = tokens.claims()?.sub;
if (providerConfig.isOIDC === false) {
subject = client.skipSubjectCheck;
}
if (!subject) {
throw createError({
statusCode: 400,
statusMessage: "Can't get subject",
});
}
let userInfo: client.UserInfoResponse;
if (providerConfig.userInfoFlow === 'github') {
userInfo = await githubUserInfoFlow(tokens.access_token);
} else {
userInfo = await client.fetchUserInfo(config, tokens.access_token, subject);
}
assertHasOauthProps(userInfo);
if (!isAllowedDomain(userInfo.email)) {
throw createError({
statusCode: 401,
statusMessage: 'Email domain not allowed',
});
}
return userInfo;
}
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>>;
function assertHasOauthProps<T extends client.UserInfoResponse>(
userInfo: T
): asserts userInfo is T & RequireKeys<T, 'sub' | 'email' | 'email_verified'> {
if (!userInfo.sub) {
throw createError({
statusCode: 400,
statusMessage: 'No sub set',
});
}
if (!userInfo.email) {
throw createError({
statusCode: 400,
statusMessage: 'No email set',
});
}
if (!userInfo.email_verified) {
throw createError({
statusCode: 401,
statusMessage: 'Email is not verified',
});
}
}
function isAllowedDomain(email: string) {
const emailDomain = email.slice(email.lastIndexOf('@') + 1);
if (
WG_ENV.OAUTH_ALLOWED_DOMAINS &&
!WG_ENV.OAUTH_ALLOWED_DOMAINS.includes(emailDomain)
) {
return false;
}
return true;
}
+12 -3
View File
@@ -3,13 +3,22 @@
import argon2 from 'argon2';
import { deserialize } from '@phc/format';
const DUMMY_HASH =
'$argon2id$v=19$m=65536,t=3,p=4$jsh6z1/SbZHYAiO/Ww9HZw$ikzkoXWqc2b0Pc4O8ZNJjp1xKZSb7SNM/3dPMNUPk9Y';
/**
* Checks if `password` matches the hash.
* Checks if `password` matches the `hash`.
*
* Checks against `DUMMY_HASH` and returns false if `hash` is null
*/
export function isPasswordValid(
export async function isPasswordValid(
password: string,
hash: string
hash: string | null
): Promise<boolean> {
if (hash === null) {
await argon2.verify(DUMMY_HASH, password);
return false;
}
return argon2.verify(hash, password);
}
+13 -11
View File
@@ -3,6 +3,15 @@ import type { UserType } from '#db/repositories/user/types';
export type WGSession = Partial<{
userId: ID;
// TODO: add pending login expiration
pendingLogin: {
type: 'password' | 'oauth';
userId: ID;
remember: boolean;
};
oauth_verifier: string;
oauth_nonce: string;
oauth_state: string;
}>;
const name = 'wg-easy';
@@ -70,21 +79,14 @@ export async function getCurrentUser(event: H3Event) {
});
}
// TODO: timing can be used to enumerate usernames
const foundUser = await Database.users.getByUsername(username);
if (!foundUser) {
throw createError({
statusCode: 401,
statusMessage: 'Session failed',
});
}
const userHashPassword = foundUser.password;
// always check to avoid timing attack
const userHashPassword = foundUser?.password ?? null;
const passwordValid = await isPasswordValid(password, userHashPassword);
if (!passwordValid) {
// can't login through basic auth if 2fa enabled
if (!foundUser || !passwordValid || foundUser.totpVerified) {
throw createError({
statusCode: 401,
statusMessage: 'Session failed',