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,
};
});
+65
View File
@@ -0,0 +1,65 @@
import { UserLoginSchema } from '#db/repositories/user/types';
export default defineEventHandler(async (event) => {
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);
const session = await useWGSession(event, remember);
// TODO: add localization support
if (!result.success) {
switch (result.error) {
case 'INCORRECT_CREDENTIALS':
throw createError({
statusCode: 401,
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 };
case 'USER_DISABLED':
throw createError({
statusCode: 401,
statusMessage: 'User disabled',
});
case 'UNEXPECTED_ERROR':
throw createError({
statusCode: 500,
statusMessage: 'Unexpected error',
});
}
assertUnreachable(result);
}
const user = result.user;
const data = await session.update({
userId: user.id,
});
// TODO?: create audit log
SERVER_DEBUG(`New Session: ${data.id} for ${user.id} (${user.username})`);
return { status: 'success' as const };
});
+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 };
});