diff --git a/.env.example b/.env.example index 9b86496a..bef7f9c7 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,11 @@ BODY_SIZE_LIMIT="10mb" # Security Configuration APP_SECRET="" # Secret key for encrypting sensitive data (generate with: openssl rand -hex 32) HTTP_MODE="http" # Set to "https" when served over TLS, so auth cookies get the secure flag + +# OIDC Authentication (optional - for SSO with Authentik or any OIDC provider) +OIDC_ENABLED=false +OIDC_ISSUER="" # OIDC issuer URL (e.g., https://authentik.example.com/application/o/tracktor/) +OIDC_CLIENT_ID="" +OIDC_CLIENT_SECRET="" +OIDC_REDIRECT_URI="" # Callback URL (e.g., http://localhost:5173/api/auth/oidc/callback) +OIDC_SCOPES="openid profile email" # Space-separated OIDC scopes diff --git a/docs/openwiki/domain/data-models.md b/docs/openwiki/domain/data-models.md index 52fb3cdf..11a0fc5f 100644 --- a/docs/openwiki/domain/data-models.md +++ b/docs/openwiki/domain/data-models.md @@ -128,7 +128,7 @@ Important keys: Tables: -- `users` — `id`, `username`, `passwordHash`, timestamps. +- `users` — `id`, `username`, `passwordHash`, timestamps, `oidc_id`, `oidc_provider`. - `sessions` — `id`, `userId`, `expiresAt`, timestamps. - `auth` — legacy table kept for migration compatibility. diff --git a/docs/openwiki/operations/runbook.md b/docs/openwiki/operations/runbook.md index de387b09..b9ffe472 100644 --- a/docs/openwiki/operations/runbook.md +++ b/docs/openwiki/operations/runbook.md @@ -30,6 +30,12 @@ Defined and typed in `src/lib/config/env.server.ts`. Public variables must be pr | `HTTP_MODE` | `http` | Set to `https` for secure cookies. | | `APP_VERSION` | branch name (dev) / package version (prod) | Override displayed version. | | `TRACKTOR_API_BASE_URL` | — | Optional external API base URL. | +| `OIDC_ENABLED` | `false` | Enables OIDC login | +| `OIDC_ISSUER` | `""` | Sets OIDC issuer | +| `OIDC_CLIENT_ID` | `""` | OIDC client ID | +| `OIDC_CLIENT_SECRET` | `""` | OIDC client secret | +| `OIDC_REDIRECT_URI` | `""` | OIDC redirect uri | +| `OIDC_SCOPES` | `openid profile email` | Scopes to request to OIDC | > Do not commit secrets. The repo includes `.env.example` with placeholder values. diff --git a/docs/openwiki/workflows/authentication.md b/docs/openwiki/workflows/authentication.md index 5f122950..242c7565 100644 --- a/docs/openwiki/workflows/authentication.md +++ b/docs/openwiki/workflows/authentication.md @@ -62,10 +62,15 @@ This is intended for local development or single-user trusted deployments, not p - changing the username, - changing the password after verifying the current password. +## OIDC authentication + +Set `OIDC_ENABLED=true` and all the `OIDC_` related variables to the corresponding values. + ## Source references - Auth schema: `src/server/db/schema/auth.ts` - Auth service: `src/server/services/authService.ts` +- Oidc service: `src/server/services/oidcService.ts` - Session utilities: `src/server/utils/session.ts` - Auth middleware: `src/server/middlewares/auth.ts` - Auth API route: `src/routes/api/auth/+server.ts` diff --git a/i18n/messages/ar.json b/i18n/messages/ar.json index b6788748..baf8cd32 100644 --- a/i18n/messages/ar.json +++ b/i18n/messages/ar.json @@ -18,6 +18,9 @@ "auth_login_loading": "جاري تسجيل الدخول...", "auth_signup_loading": "جاري إنشاء الحساب...", "auth_password_mismatch": "كلمات المرور غير متطابقة!!!", + "auth_login_or": "أو", + "auth_sso_login_button": "تسجيل الدخول عبر SSO", + "auth_sso_login_loading": "جارٍ إعادة التوجيه...", "settings_tab_personalization": "التخصيص", "settings_tab_interface": "الواجهة", "settings_tab_features": "الميزات", diff --git a/i18n/messages/de.json b/i18n/messages/de.json index 2862677f..57c3f7bc 100644 --- a/i18n/messages/de.json +++ b/i18n/messages/de.json @@ -18,6 +18,9 @@ "auth_login_loading": "Anmeldung läuft...", "auth_signup_loading": "Konto wird erstellt...", "auth_password_mismatch": "Passwörter stimmen nicht überein!!!", + "auth_login_or": "oder", + "auth_sso_login_button": "Mit SSO anmelden", + "auth_sso_login_loading": "Weiterleitung...", "settings_tab_personalization": "Personalisierung", "settings_tab_interface": "Oberfläche", "settings_tab_features": "Funktionen", diff --git a/i18n/messages/en.json b/i18n/messages/en.json index fb0349ca..68c6cb56 100644 --- a/i18n/messages/en.json +++ b/i18n/messages/en.json @@ -23,6 +23,9 @@ "auth_login_loading": "Signing in...", "auth_signup_loading": "Creating account...", "auth_password_mismatch": "Passwords do not match!!!", + "auth_login_or": "or", + "auth_sso_login_button": "Login with SSO", + "auth_sso_login_loading": "Redirecting...", "auth_login_title": "Welcome back", "auth_login_subtitle": "Sign in to keep your vehicles, fuel, and paperwork on track.", "settings_tab_personalization": "Personalization", diff --git a/i18n/messages/es.json b/i18n/messages/es.json index 29b5f60e..86846d40 100644 --- a/i18n/messages/es.json +++ b/i18n/messages/es.json @@ -18,6 +18,9 @@ "auth_login_loading": "Iniciando sesión...", "auth_signup_loading": "Creando cuenta...", "auth_password_mismatch": "¡Las contraseñas no coinciden!", + "auth_login_or": "o", + "auth_sso_login_button": "Iniciar sesión con SSO", + "auth_sso_login_loading": "Redirigiendo...", "settings_tab_personalization": "Personalización", "settings_tab_interface": "Interfaz", "settings_tab_features": "Funciones", diff --git a/i18n/messages/fi.json b/i18n/messages/fi.json index 1b140bd8..6f9bb62c 100644 --- a/i18n/messages/fi.json +++ b/i18n/messages/fi.json @@ -19,6 +19,9 @@ "auth_login_loading": "Kirjaudutaan sisään...", "auth_signup_loading": "Luodaan tiliä...", "auth_password_mismatch": "Salasanat eivät täsmää!", + "auth_login_or": "tai", + "auth_sso_login_button": "Kirjaudu sisään SSO:lla", + "auth_sso_login_loading": "Uudelleenohjataan...", "settings_tab_personalization": "Mukauta", "settings_tab_interface": "Käyttöliittymä", "settings_tab_features": "Ominaisuudet", diff --git a/i18n/messages/fr.json b/i18n/messages/fr.json index 65537fa6..d17193e7 100644 --- a/i18n/messages/fr.json +++ b/i18n/messages/fr.json @@ -18,6 +18,9 @@ "auth_login_loading": "Connexion en cours...", "auth_signup_loading": "Création du compte...", "auth_password_mismatch": "Les mots de passe ne correspondent pas !!!", + "auth_login_or": "ou", + "auth_sso_login_button": "Se connecter avec SSO", + "auth_sso_login_loading": "Redirection...", "settings_tab_personalization": "Personnalisation", "settings_tab_interface": "Interface", "settings_tab_features": "Fonctionnalités", diff --git a/i18n/messages/hi.json b/i18n/messages/hi.json index 0795d5e1..1f44ea01 100644 --- a/i18n/messages/hi.json +++ b/i18n/messages/hi.json @@ -18,6 +18,9 @@ "auth_login_loading": "साइन इन हो रहा है...", "auth_signup_loading": "खाता बनाया जा रहा है...", "auth_password_mismatch": "पासवर्ड मेल नहीं खाते!!!", + "auth_login_or": "या", + "auth_sso_login_button": "SSO से लॉगिन करें", + "auth_sso_login_loading": "पुनर्निर्देशित किया जा रहा है...", "settings_tab_personalization": "व्यक्तिकरण", "settings_tab_interface": "इंटरफ़ेस", "settings_tab_features": "फ़ीचर्स", diff --git a/i18n/messages/hu.json b/i18n/messages/hu.json index 82c60cd8..e331edf8 100644 --- a/i18n/messages/hu.json +++ b/i18n/messages/hu.json @@ -19,6 +19,9 @@ "auth_login_loading": "Bejelentkezés...", "auth_signup_loading": "Fiók létrehozása...", "auth_password_mismatch": "A jelszavak nem egyeznek!", + "auth_login_or": "vagy", + "auth_sso_login_button": "Bejelentkezés SSO-val", + "auth_sso_login_loading": "Átirányítás...", "settings_tab_personalization": "Testreszabás", "settings_tab_interface": "Kezelőfelület", "settings_tab_features": "Funkciók", diff --git a/i18n/messages/it.json b/i18n/messages/it.json index 1c2384b4..9809571c 100644 --- a/i18n/messages/it.json +++ b/i18n/messages/it.json @@ -18,6 +18,9 @@ "auth_login_loading": "Accesso in corso...", "auth_signup_loading": "Creazione account...", "auth_password_mismatch": "Le password non corrispondono!!!", + "auth_login_or": "o", + "auth_sso_login_button": "Accedi con SSO", + "auth_sso_login_loading": "Reindirizzamento...", "settings_tab_personalization": "Personalizzazione", "settings_tab_interface": "Interfaccia", "settings_tab_features": "Funzionalità", diff --git a/i18n/messages/ro.json b/i18n/messages/ro.json index e6cd73ec..f77ec0b5 100644 --- a/i18n/messages/ro.json +++ b/i18n/messages/ro.json @@ -19,6 +19,9 @@ "auth_login_loading": "Se autentifică...", "auth_signup_loading": "Se creează contul...", "auth_password_mismatch": "Parolele nu se potrivesc!!!", + "auth_login_or": "sau", + "auth_sso_login_button": "Autentificare cu SSO", + "auth_sso_login_loading": "Redirecționare...", "settings_tab_personalization": "Personalizare", "settings_tab_interface": "Interfață", "settings_tab_features": "Funcționalități", diff --git a/package.json b/package.json index 7d7b43c7..18a6ed12 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "mode-watcher": "^1.1.0", "node-cron": "^4.6.0", "nodemailer": "^9.0.4", + "openid-client": "^6.8.4", "pdfkit": "^0.19.1", "svelte-dnd-action": "^0.9.78", "zod": "^4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e54befe4..37c8e62d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: nodemailer: specifier: ^9.0.4 version: 9.0.4 + openid-client: + specifier: ^6.8.4 + version: 6.8.4 pdfkit: specifier: ^0.19.1 version: 0.19.1 @@ -2356,6 +2359,9 @@ packages: joi@17.13.4: resolution: {integrity: sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==} + jose@6.2.4: + resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} @@ -2689,9 +2695,15 @@ packages: nub@0.0.0: resolution: {integrity: sha512-dK0Ss9C34R/vV0FfYJXuqDAqHlaW9fvWVufq9MmGF2umCuDbd5GRfRD9fpi/LiM0l4ZXf8IBB+RYmZExqCrf0w==} + oauth4webapi@3.8.6: + resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + openid-client@6.8.4: + resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -5223,6 +5235,8 @@ snapshots: '@sideway/pinpoint': 2.0.0 optional: true + jose@6.2.4: {} + js-base64@3.7.8: {} js-md5@0.8.3: {} @@ -5525,8 +5539,15 @@ snapshots: nub@0.0.0: {} + oauth4webapi@3.8.6: {} + obug@2.1.1: {} + openid-client@6.8.4: + dependencies: + jose: 6.2.4 + oauth4webapi: 3.8.6 + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/src/app.d.ts b/src/app.d.ts index 2f1d244f..d5ec2c5b 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -36,6 +36,12 @@ declare namespace NodeJS { APP_VERSION?: string; TRACKTOR_DISABLE_AUTH: string; TRACKTOR_API_BASE_URL?: string; + OIDC_ENABLED: string; + OIDC_ISSUER: string; + OIDC_CLIENT_ID: string; + OIDC_CLIENT_SECRET: string; + OIDC_REDIRECT_URI: string; + OIDC_SCOPES: string; } } diff --git a/src/lib/components/feature/auth/login-form.svelte b/src/lib/components/feature/auth/login-form.svelte index f18f9375..834259ce 100644 --- a/src/lib/components/feature/auth/login-form.svelte +++ b/src/lib/components/feature/auth/login-form.svelte @@ -5,6 +5,7 @@ import { goto } from '$app/navigation'; import UserIcon from '@lucide/svelte/icons/circle-user-round'; import RectangleEllipsis from '@lucide/svelte/icons/rectangle-ellipsis'; + import LogIn from '@lucide/svelte/icons/log-in'; import SubmitButton from '$appui/SubmitButton.svelte'; import * as m from '$lib/paraglide/messages'; @@ -12,6 +13,7 @@ let username = $state(''); let password = $state(''); let processing = $state(false); + let oidcProcessing = $state(false); $effect(() => { // Check if users exist when component mounts @@ -82,3 +84,25 @@ + +{#if authStore.oidcEnabled && !authStore.isAuthDisabled} +
+
+ +
+
+ {m.auth_login_or()} +
+
+ +{/if} diff --git a/src/lib/config/env.server.ts b/src/lib/config/env.server.ts index 740f3923..ee2ecf99 100644 --- a/src/lib/config/env.server.ts +++ b/src/lib/config/env.server.ts @@ -49,7 +49,14 @@ export const serverEnv = { HTTP_MODE: privateEnv.HTTP_MODE || 'http', APP_VERSION: privateEnv.APP_VERSION, BASE_URL: privateEnv.BASE_URL || '', - APP_SECRET: privateEnv.APP_SECRET || '' + APP_SECRET: privateEnv.APP_SECRET || '', + + OIDC_ENABLED: privateEnv.OIDC_ENABLED === 'true', + OIDC_ISSUER: privateEnv.OIDC_ISSUER || '', + OIDC_CLIENT_ID: privateEnv.OIDC_CLIENT_ID || '', + OIDC_CLIENT_SECRET: privateEnv.OIDC_CLIENT_SECRET || '', + OIDC_REDIRECT_URI: privateEnv.OIDC_REDIRECT_URI || '', + OIDC_SCOPES: privateEnv.OIDC_SCOPES || 'openid profile email' } as const; /** diff --git a/src/lib/stores/auth.svelte.ts b/src/lib/stores/auth.svelte.ts index 11d59bb5..64f4c6ef 100644 --- a/src/lib/stores/auth.svelte.ts +++ b/src/lib/stores/auth.svelte.ts @@ -14,6 +14,7 @@ class AuthStore { isLoggedIn = $state(false); hasUsers = $state(false); isAuthDisabled = $state(env.DISABLE_AUTH); + oidcEnabled = $state(false); constructor() { this.isLoggedIn = env.DISABLE_AUTH; @@ -30,6 +31,7 @@ class AuthStore { try { const { data: res } = await apiClient.get('/auth'); this.isAuthDisabled = !!res.data?.isAuthDisabled; + this.oidcEnabled = !!res.data?.oidcEnabled; this.hasUsers = res.data?.hasUsers ?? false; if (this.isAuthDisabled) { @@ -53,6 +55,10 @@ class AuthStore { } }; + oidcLogin = () => { + window.location.href = '/api/auth/oidc/login'; + }; + login = async (username: string, password: string) => { try { const { data: res } = await apiClient.post('/auth', { username, password }); diff --git a/src/routes/api/auth/+server.ts b/src/routes/api/auth/+server.ts index ed4c1139..249c50a0 100644 --- a/src/routes/api/auth/+server.ts +++ b/src/routes/api/auth/+server.ts @@ -54,6 +54,7 @@ export const GET: RequestHandler = async (event) => { return jsonResponse({ ...result, isAuthDisabled, + oidcEnabled: env.OIDC_ENABLED, user, isAuthenticated: isAuthDisabled || !!user }); diff --git a/src/routes/api/auth/oidc/callback/+server.ts b/src/routes/api/auth/oidc/callback/+server.ts new file mode 100644 index 00000000..3321b98d --- /dev/null +++ b/src/routes/api/auth/oidc/callback/+server.ts @@ -0,0 +1,51 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$lib/config/env.server'; +import { handleCallback } from '$server/services/oidcService'; + +export const GET: RequestHandler = async (event) => { + if (!env.OIDC_ENABLED) { + throw redirect(307, '/login'); + } + + const stored = event.cookies.get('oidc_state'); + if (!stored) { + throw redirect(307, '/login'); + } + + let state: string; + let nonce: string; + let codeVerifier: string; + + try { + const parsed = JSON.parse(stored); + state = parsed.state; + nonce = parsed.nonce; + codeVerifier = parsed.codeVerifier; + } catch { + throw redirect(307, '/login'); + } + + event.cookies.delete('oidc_state', { + path: '/' + }); + + const callbackUrl = event.request.url; + + try { + const result = await handleCallback(callbackUrl, state, nonce, codeVerifier); + + event.cookies.set('session', result.sessionToken, { + path: '/', + httpOnly: true, + secure: env.HTTP_MODE === 'https', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30 + }); + + throw redirect(302, '/dashboard'); + } catch (err) { + console.error('OIDC callback error:', err); + throw redirect(307, '/login'); + } +}; diff --git a/src/routes/api/auth/oidc/login/+server.ts b/src/routes/api/auth/oidc/login/+server.ts new file mode 100644 index 00000000..f91b6fb7 --- /dev/null +++ b/src/routes/api/auth/oidc/login/+server.ts @@ -0,0 +1,31 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$lib/config/env.server'; +import { + generateState, + generateNonce, + getAuthorizationUrl, + generateCodeVerifier +} from '$server/services/oidcService'; + +export const GET: RequestHandler = async (event) => { + if (!env.OIDC_ENABLED) { + throw redirect(307, '/login'); + } + + const state = generateState(); + const nonce = generateNonce(); + const codeVerifier = generateCodeVerifier(); + + event.cookies.set('oidc_state', JSON.stringify({ state, nonce, codeVerifier }), { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: env.HTTP_MODE === 'https', + maxAge: 60 * 10 + }); + + const authUrl = await getAuthorizationUrl(state, nonce, codeVerifier); + + throw redirect(302, authUrl); +}; diff --git a/src/server/db/schema/auth.ts b/src/server/db/schema/auth.ts index 5aa607e3..b98d41cb 100644 --- a/src/server/db/schema/auth.ts +++ b/src/server/db/schema/auth.ts @@ -7,6 +7,8 @@ export const usersTable = table('users', { id: t.text().primaryKey(), username: t.text().notNull().unique(), passwordHash: t.text().notNull(), + oidcId: t.text().unique(), + oidcProvider: t.text(), ...timestamps }); diff --git a/src/server/services/authService.ts b/src/server/services/authService.ts index c6376c41..68dec5a2 100644 --- a/src/server/services/authService.ts +++ b/src/server/services/authService.ts @@ -99,6 +99,45 @@ export const validateSession = async (sessionToken: string): Promise<{ user: Use return { user: result.user }; }; +export const findUserByOidcId = async (oidcId: string) => { + return db.query.usersTable.findFirst({ + where: (users, { eq }) => eq(users.oidcId, oidcId) + }); +}; + +export const createUserFromOidc = async ( + username: string, + oidcId: string, + oidcProvider: string +) => { + const userId = crypto.randomUUID(); + const randomPassword = crypto.randomUUID(); + + await db.insert(schema.usersTable).values({ + id: userId, + username, + passwordHash: await hashPassword(randomPassword), + oidcId, + oidcProvider + }); + + return { id: userId, username }; +}; + +export const getFirstAvailableUsername = async (...usernames: string[]) => { + for (let username of usernames) { + const user = await db.query.usersTable.findFirst({ + where: (users, { eq }) => eq(users.username, username) + }); + + if (!user) { + return username; + } + } + + return null; +}; + export const getUsersCount = async () => { const [user] = await db.select({ id: schema.usersTable.id }).from(schema.usersTable).limit(1); return { diff --git a/src/server/services/oidcService.ts b/src/server/services/oidcService.ts new file mode 100644 index 00000000..9efa0ccb --- /dev/null +++ b/src/server/services/oidcService.ts @@ -0,0 +1,94 @@ +import * as client from 'openid-client'; +import type { Configuration } from 'openid-client'; +import { env } from '$lib/config/env.server'; +import * as authService from './authService'; +import { generateSessionToken, createSession } from '../utils/session'; + +let configPromise: Promise | null = null; +let cachedIssuerUrl: string | null = null; + +const getConfig = async () => { + if (configPromise && cachedIssuerUrl === env.OIDC_ISSUER) { + return configPromise; + } + + const server = new URL(env.OIDC_ISSUER); + + configPromise = client + .discovery(server, env.OIDC_CLIENT_ID, { + client_secret: env.OIDC_CLIENT_SECRET + }) + .catch((err) => { + configPromise = null; + throw err; + }); + + cachedIssuerUrl = env.OIDC_ISSUER; + + return configPromise; +}; + +export const generateState = () => client.randomState(); +export const generateNonce = () => client.randomNonce(); +export const generateCodeVerifier = () => client.randomPKCECodeVerifier(); + +export const getAuthorizationUrl = async (state: string, nonce: string, codeVerifier: string) => { + const config = await getConfig(); + const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); + const redirectUri = env.OIDC_REDIRECT_URI; + + return client + .buildAuthorizationUrl(config, { + scope: env.OIDC_SCOPES, + redirect_uri: redirectUri, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state, + nonce + }) + .toString(); +}; + +export const handleCallback = async ( + callbackUrl: string, + state: string, + nonce: string, + codeVerifier: string +) => { + const config = await getConfig(); + + const tokens = await client.authorizationCodeGrant(config, new URL(callbackUrl), { + pkceCodeVerifier: codeVerifier, + expectedState: state, + expectedNonce: nonce + }); + + const claims = tokens.claims(); + + if (!claims) { + throw new Error('OIDC: No ID token claims returned'); + } + + const oidcId = claims.sub; + const oidcProvider = env.OIDC_ISSUER; + + const existingUser = await authService.findUserByOidcId(oidcId); + + if (existingUser) { + const sessionToken = generateSessionToken(); + await createSession(sessionToken, existingUser.id); + return { sessionToken, user: { id: existingUser.id, username: existingUser.username } }; + } + + const username = + (await authService.getFirstAvailableUsername( + claims.preferred_username as string, + claims.email as string + )) ?? `oidc_${oidcId.slice(0, 8)}`; + + const newUser = await authService.createUserFromOidc(username, oidcId, oidcProvider); + const sessionToken = generateSessionToken(); + await createSession(sessionToken, newUser.id); + + return { sessionToken, user: { id: newUser.id, username: newUser.username } }; +};