From 93cb44a31e7d0da902a47a56169bbf7343a409d5 Mon Sep 17 00:00:00 2001 From: zigzag Date: Mon, 16 Feb 2026 00:42:05 -0500 Subject: [PATCH 1/7] feat: implement OIDC authentication - Added login and callback routes - Added automatic user account creation - Added role and group syncing - Fixed issuer URL and SSL certificate issues --- apps/client/src/screens/connect/index.tsx | 44 +++++ apps/server/package.json | 1 + apps/server/src/config.ts | 31 ++- apps/server/src/http/index.ts | 9 + apps/server/src/http/info.ts | 4 +- apps/server/src/http/login.ts | 8 + apps/server/src/http/oidc.ts | 221 ++++++++++++++++++++++ bun.lock | 16 +- package.json | 3 + packages/shared/src/types.ts | 1 + 10 files changed, 332 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/http/oidc.ts diff --git a/apps/client/src/screens/connect/index.tsx b/apps/client/src/screens/connect/index.tsx index 8b252ad42..a90d591cb 100644 --- a/apps/client/src/screens/connect/index.tsx +++ b/apps/client/src/screens/connect/index.tsx @@ -15,6 +15,7 @@ import { setLocalStorageItem, setSessionStorageItem } from '@/helpers/storage'; +import { useStrictEffect } from '@/hooks/use-strict-effect'; import { useForm } from '@/hooks/use-form'; import { memo, useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -54,6 +55,38 @@ const Connect = memo(() => { [onChange] ); + const onOidcLoginClick = useCallback(() => { + const url = getUrlFromServer(); + window.location.href = `${url}/auth/login`; + }, []); + + const handleOidcCallback = useCallback(async (token: string) => { + setLoading(true); + try { + setSessionStorageItem(SessionStorageKey.TOKEN, token); + await connect(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(`Could not connect with OIDC: ${errorMessage}`); + } finally { + setLoading(false); + } + }, []); + + useStrictEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get('token'); + + if (token) { + // Remove token from URL + const newUrl = window.location.pathname; + window.history.replaceState({}, document.title, newUrl); + + handleOidcCallback(token); + } + }, [handleOidcCallback]); + const onConnectClick = useCallback(async () => { setLoading(true); @@ -179,6 +212,17 @@ const Connect = memo(() => { Connect + {info?.oidcEnabled && ( + + )} + {!info?.allowNewUsers && ( <> {!inviteCode && ( diff --git a/apps/server/package.json b/apps/server/package.json index c93402bf0..5687d0da9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -52,6 +52,7 @@ "link-preview-js": "^3.1.0", "linkify-it": "^5.0.0", "mediasoup": "^3.19.17", + "openid-client": "^6.8.2", "queue": "^7.0.0", "sanitize-html": "^2.17.0", "semver": "^7.7.3", diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 11b613fb3..3ec0cf597 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -18,7 +18,16 @@ const zConfig = z.object({ server: z.object({ port: z.coerce.number().int().positive(), debug: z.coerce.boolean(), - autoupdate: z.coerce.boolean() + autoupdate: z.coerce.boolean(), + disableLocalSignup: z.coerce.boolean() + }), + oidc: z.object({ + issuer: z.string(), + clientId: z.string(), + clientSecret: z.string(), + redirectUrl: z.string(), + rolesMapping: z.string(), + requiredGroup: z.string().optional() }), webRtc: z.object({ port: z.coerce.number().int().positive(), @@ -46,7 +55,16 @@ const defaultConfig: TConfig = { server: { port: 4991, debug: IS_DEVELOPMENT, - autoupdate: false + autoupdate: false, + disableLocalSignup: false + }, + oidc: { + issuer: '', + clientId: '', + clientSecret: '', + redirectUrl: '', + rolesMapping: '{}', + requiredGroup: '' }, webRtc: { port: 40000, @@ -106,6 +124,15 @@ config = applyEnvOverrides(config, { 'server.port': 'SHARKORD_PORT', 'server.debug': 'SHARKORD_DEBUG', 'server.autoupdate': 'SHARKORD_AUTOUPDATE', + 'server.disableLocalSignup': 'SHARKORD_DISABLE_LOCAL_SIGNUP', + + 'oidc.issuer': 'OIDC_ISSUER', + 'oidc.clientId': 'OIDC_CLIENT_ID', + 'oidc.clientSecret': 'OIDC_CLIENT_SECRET', + 'oidc.redirectUrl': 'OIDC_REDIRECT_URL', + 'oidc.rolesMapping': 'OIDC_ROLES_MAPPING', + 'oidc.requiredGroup': 'OIDC_REQUIRED_GROUP', + 'webRtc.port': 'SHARKORD_WEBRTC_PORT', 'webRtc.announcedAddress': 'SHARKORD_WEBRTC_ANNOUNCED_ADDRESS' }); diff --git a/apps/server/src/http/index.ts b/apps/server/src/http/index.ts index 22de52bd0..9e837ebae 100644 --- a/apps/server/src/http/index.ts +++ b/apps/server/src/http/index.ts @@ -13,6 +13,7 @@ import { healthRouteHandler } from './healthz'; import { infoRouteHandler } from './info'; import { interfaceRouteHandler } from './interface'; import { loginRouteHandler } from './login'; +import { oidcCallback, oidcLogin } from './oidc'; import { publicRouteHandler } from './public'; import { uploadFileRouteHandler } from './upload'; import { HttpValidationError } from './utils'; @@ -53,6 +54,14 @@ const createHttpServer = async (port: number = config.server.port) => { return await infoRouteHandler(req, res); } + if (req.method === 'GET' && req.url === '/auth/login') { + return await oidcLogin(req, res); + } + + if (req.method === 'GET' && req.url?.startsWith('/auth/callback')) { + return await oidcCallback(req, res); + } + if (req.method === 'POST' && req.url === '/upload') { return await uploadFileRouteHandler(req, res); } diff --git a/apps/server/src/http/info.ts b/apps/server/src/http/info.ts index e78328c8b..7f4ff816f 100644 --- a/apps/server/src/http/info.ts +++ b/apps/server/src/http/info.ts @@ -1,5 +1,6 @@ import type { TServerInfo } from '@sharkord/shared'; import http from 'http'; +import { config } from '../config'; import { getSettings } from '../db/queries/server'; import { SERVER_VERSION } from '../utils/env'; @@ -15,7 +16,8 @@ const infoRouteHandler = async ( name: settings.name, description: settings.description, logo: settings.logo, - allowNewUsers: settings.allowNewUsers + allowNewUsers: settings.allowNewUsers, + oidcEnabled: !!config.oidc.issuer && !!config.oidc.clientId }; res.writeHead(200, { 'Content-Type': 'application/json' }); diff --git a/apps/server/src/http/login.ts b/apps/server/src/http/login.ts index 5699e0c07..754d5fd48 100644 --- a/apps/server/src/http/login.ts +++ b/apps/server/src/http/login.ts @@ -3,6 +3,7 @@ import { eq, sql } from 'drizzle-orm'; import http from 'http'; import jwt from 'jsonwebtoken'; import z from 'zod'; +import { config } from '../config'; import { db } from '../db'; import { publishUser } from '../db/publishers'; import { isInviteValid } from '../db/queries/invites'; @@ -84,6 +85,13 @@ const loginRouteHandler = async ( const connectionInfo = getWsInfo(undefined, req); if (!existingUser) { + if (config.server.disableLocalSignup) { + throw new HttpValidationError( + 'identity', + 'Registration is only allowed via OIDC' + ); + } + if (!settings.allowNewUsers) { const inviteError = await isInviteValid(data.invite); diff --git a/apps/server/src/http/oidc.ts b/apps/server/src/http/oidc.ts new file mode 100644 index 000000000..535ba2bbb --- /dev/null +++ b/apps/server/src/http/oidc.ts @@ -0,0 +1,221 @@ +import * as client from 'openid-client'; +import { config } from '../config'; +import http from 'http'; +import jwt from 'jsonwebtoken'; +import { db } from '../db'; +import { userRoles, users } from '../db/schema'; +import { eq } from 'drizzle-orm'; +import { sha256 } from '@sharkord/shared'; +import { getDefaultRole, getRoles } from '../db/queries/roles'; +import { getServerToken } from '../db/queries/server'; +import { publishUser } from '../db/publishers'; +import { getUserByIdentity } from '../db/queries/users'; +import { randomUUID } from 'crypto'; + +// Allow self-signed certs (Delete in production if using real SSL) +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +/** + * Setup OIDC Discovery + */ +const getOidcConfig = async () => { + let issuerUrl = config.oidc.issuer; + + // Clean up URL + const suffix = '/.well-known/openid-configuration'; + if (issuerUrl.endsWith(suffix)) { + issuerUrl = issuerUrl.substring(0, issuerUrl.length - suffix.length); + } + + // Authentik Requirement: Issuer URL must end with a slash + if (!issuerUrl.endsWith('/')) { + issuerUrl += '/'; + } + + const issuer = new URL(issuerUrl); + + return client.discovery( + issuer, + config.oidc.clientId, + config.oidc.clientSecret, + undefined, + { + [client.customFetch]: async (url: string, options: any) => { + return fetch(url, { + ...options, + tls: { rejectUnauthorized: false } + }); + } + } + ); +}; + +const getCurrentUrl = (req: http.IncomingMessage) => { + const protocol = req.headers['x-forwarded-proto'] || 'http'; + const host = req.headers.host; + const url = new URL(req.url ?? '', `${protocol}://${host}`); + + if (config.oidc.redirectUrl.startsWith('https:')) { + url.protocol = 'https:'; + } + return url; +}; + +// --- HANDLERS --- + +const oidcLogin = async (req: http.IncomingMessage, res: http.ServerResponse) => { + try { + const as = await getOidcConfig(); + const redirectTo = client.buildAuthorizationUrl(as, { + redirect_uri: config.oidc.redirectUrl, + scope: 'openid profile email groups roles', + }); + + res.writeHead(302, { Location: redirectTo.href }); + res.end(); + } catch (error) { + console.error('OIDC Login Error:', error); + res.writeHead(500); + res.end('Internal Server Error'); + } +}; + +const oidcCallback = async (req: http.IncomingMessage, res: http.ServerResponse) => { + try { + const as = await getOidcConfig(); + const currentUrl = getCurrentUrl(req); + + // 1. Standard Token Exchange + const response = await client.authorizationCodeGrant( + as, + currentUrl, + { + idTokenExpected: true, + expectedState: client.skipStateCheck + }, + { + redirect_uri: config.oidc.redirectUrl + }, + { + [client.allowInsecureRequests as unknown as string]: true + } + ); + + // 2. Extract Claims + // We use 'any' here to allow merging UserInfo later without TS complaining + // that the types don't match the strict 'IDToken' interface. + let claims: any = response.claims(); + + // 3. Robust Fallback + // If ID Token is thin, fetch full profile from UserInfo endpoint + if (!claims || (!claims.email && !claims.sub)) { + // We pass '' as fallback for sub to satisfy TS string requirement + const userInfo = await client.fetchUserInfo(as, response.access_token, claims?.sub || ''); + claims = { ...claims, ...userInfo }; + } + + if (!claims || (!claims.sub && !claims.email)) { + throw new Error('No identity claims found in ID Token or UserInfo'); + } + + // --- USER SYNC & LOGIC --- + + // Check Required Group + if (config.oidc.requiredGroup) { + const userGroups = (claims.groups as string[]) ?? (claims.roles as string[]) ?? []; + const hasRequiredGroup = userGroups.some(g => g.toLowerCase() === config.oidc.requiredGroup!.toLowerCase()); + + if (!hasRequiredGroup) { + res.writeHead(403); + res.end(`Access Denied: Missing required group '${config.oidc.requiredGroup}'`); + return; + } + } + + const identity = claims.email ?? claims.preferred_username ?? claims.sub; + + // Explicitly cast to string to satisfy TS + let user = await getUserByIdentity(identity as string); + + // Create User + if (!user) { + const randomPassword = randomUUID(); + const hashedPassword = await sha256(randomPassword); + const defaultRole = await getDefaultRole(); + + if (!defaultRole) throw new Error('Default role not found'); + + const newUser = await db + .insert(users) + .values({ + name: (claims.name as string) ?? 'OIDC User', + identity: identity as string, + createdAt: Date.now(), + password: hashedPassword, + }) + .returning() + .get(); + + await db.insert(userRoles).values({ + roleId: defaultRole.id, + userId: newUser.id, + createdAt: Date.now(), + }); + + publishUser(newUser.id, 'create'); + + // RE-FETCH FULL USER OBJECT + // We must do this because 'newUser' is a raw table row, but 'user' needs + // to be the Joined User type (with roles, etc.) for the code below. + user = await getUserByIdentity(identity as string); + } + + if (!user) { + throw new Error('User could not be found or created'); + } + + // Role Mapping + const rolesMapping = JSON.parse(config.oidc.rolesMapping || '{}'); + if (Object.keys(rolesMapping).length > 0) { + const oidcRoles = ((claims.groups as string[]) ?? (claims.roles as string[]) ?? []).map((role: string) => role.toLowerCase()); + const allDbRoles = await getRoles(); + const userRoleIds = new Set(); + + for (const oidcRoleName of oidcRoles) { + const sharkordRoleName = rolesMapping[oidcRoleName]; + if (sharkordRoleName) { + const dbRole = allDbRoles.find(r => r.name.toLowerCase() === sharkordRoleName.toLowerCase()); + if (dbRole) userRoleIds.add(dbRole.id); + } + } + + if (userRoleIds.size > 0) { + await db.delete(userRoles).where(eq(userRoles.userId, user.id)); + await db.insert(userRoles).values(Array.from(userRoleIds).map(roleId => ({ + userId: user.id, + roleId, + createdAt: Date.now() + }))); + } + } + + // Issue App Token & Redirect + const token = jwt.sign({ userId: user.id }, await getServerToken(), { + expiresIn: '86400s' // 1 day + }); + + const clientUrl = req.headers.referer || req.headers.origin || `http://${req.headers.host}`; + const redirectUrl = new URL(clientUrl); + redirectUrl.searchParams.set('token', token); + + res.writeHead(302, { Location: redirectUrl.toString() }); + res.end(); + + } catch (error) { + console.error('OIDC Callback Error:', error); + res.writeHead(500); + res.end('Internal Server Error'); + } +}; + +export { oidcLogin, oidcCallback }; \ No newline at end of file diff --git a/bun.lock b/bun.lock index 8a6a95c93..0c14d564c 100644 --- a/bun.lock +++ b/bun.lock @@ -4,13 +4,16 @@ "workspaces": { "": { "name": "sharkord", + "dependencies": { + "openid-client": "^6.8.2", + }, "devDependencies": { "knip": "^5.80.0", }, }, "apps/client": { "name": "client", - "version": "0.0.3", + "version": "0.0.4", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -86,7 +89,7 @@ }, "apps/server": { "name": "@sharkord/server", - "version": "0.0.3", + "version": "0.0.4", "dependencies": { "@sharkord/plugin-sdk": "workspace:*", "@sharkord/shared": "workspace:*", @@ -102,6 +105,7 @@ "link-preview-js": "^3.1.0", "linkify-it": "^5.0.0", "mediasoup": "^3.19.17", + "openid-client": "^6.8.2", "queue": "^7.0.0", "sanitize-html": "^2.17.0", "semver": "^7.7.3", @@ -148,7 +152,7 @@ }, "packages/shared": { "name": "@sharkord/shared", - "version": "0.0.3", + "version": "0.0.4", "dependencies": { "drizzle-orm": "^0.44.6", "js-sha256": "^0.11.1", @@ -959,6 +963,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "js-sha256": ["js-sha256@0.11.1", "", {}, "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1075,10 +1081,14 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "oauth4webapi": ["oauth4webapi@3.8.4", "", {}, "sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], + "openid-client": ["openid-client@6.8.2", "", { "dependencies": { "jose": "^6.1.3", "oauth4webapi": "^3.8.4" } }, "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], diff --git a/package.json b/package.json index b3a03867b..31c13c698 100644 --- a/package.json +++ b/package.json @@ -19,5 +19,8 @@ }, "devDependencies": { "knip": "^5.80.0" + }, + "dependencies": { + "openid-client": "^6.8.2" } } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 43601b3cc..e5978b3d6 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -92,6 +92,7 @@ export type TServerInfo = Pick< > & { logo: TFile | null; version: string; + oidcEnabled: boolean; }; export type TArtifact = { From 23672370986b7f400114951b41cfc91f48436956 Mon Sep 17 00:00:00 2001 From: zigzag Date: Mon, 16 Feb 2026 17:52:36 -0500 Subject: [PATCH 2/7] Actual OIDC Implementation --- apps/client/src/screens/connect/index.tsx | 35 ++- apps/server/src/config.ts | 74 +++-- apps/server/src/http/oidc.ts | 351 ++++++++++++---------- 3 files changed, 261 insertions(+), 199 deletions(-) diff --git a/apps/client/src/screens/connect/index.tsx b/apps/client/src/screens/connect/index.tsx index a90d591cb..5161ab890 100644 --- a/apps/client/src/screens/connect/index.tsx +++ b/apps/client/src/screens/connect/index.tsx @@ -60,11 +60,27 @@ const Connect = memo(() => { window.location.href = `${url}/auth/login`; }, []); - const handleOidcCallback = useCallback(async (token: string) => { + const handleOidcSuccess = useCallback(async () => { setLoading(true); try { - setSessionStorageItem(SessionStorageKey.TOKEN, token); + const cookies = document.cookie.split('; ').reduce((acc, current) => { + const [name, value] = current.split('='); + acc[name] = value; + return acc; + }, {} as Record); + + const token = cookies['sharkord_token']; + + if (token) { + setSessionStorageItem(SessionStorageKey.TOKEN, token); + + document.cookie = "sharkord_token=; Max-Age=0; path=/; SameSite=Lax; Secure"; + } else { + throw new Error("No authentication token found in cookies."); + } + await connect(); + toast.success("Logged in with OIDC"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -76,16 +92,15 @@ const Connect = memo(() => { useStrictEffect(() => { const urlParams = new URLSearchParams(window.location.search); - const token = urlParams.get('token'); + const oidcStatus = urlParams.get('oidc_status'); - if (token) { - // Remove token from URL + if (oidcStatus === 'success') { const newUrl = window.location.pathname; window.history.replaceState({}, document.title, newUrl); - - handleOidcCallback(token); + + handleOidcSuccess(); } - }, [handleOidcCallback]); + }, [handleOidcSuccess]); const onConnectClick = useCallback(async () => { setLoading(true); @@ -250,7 +265,7 @@ const Connect = memo(() => {
- v{VITE_APP_VERSION} + v{import.meta.env.VITE_APP_VERSION} { ); }); -export { Connect }; +export { Connect }; \ No newline at end of file diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 3ec0cf597..d9cfcf15d 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -14,6 +14,20 @@ const [SERVER_PUBLIC_IP, SERVER_PRIVATE_IP] = await Promise.all([ getPrivateIp() ]); +/** + * Helper: Transforms a JSON string from the INI/Env into a typed Object or Array. + * Falling back to the provided default if parsing fails. + */ +const jsonTransform = (fallback: T) => + z.preprocess((val) => { + if (typeof val !== 'string') return val; + try { + return JSON.parse(val); + } catch { + return fallback; + } + }, z.any()).transform((val) => val as T); + const zConfig = z.object({ server: z.object({ port: z.coerce.number().int().positive(), @@ -22,12 +36,15 @@ const zConfig = z.object({ disableLocalSignup: z.coerce.boolean() }), oidc: z.object({ + oidcEnabled: z.coerce.boolean(), issuer: z.string(), clientId: z.string(), clientSecret: z.string(), redirectUrl: z.string(), - rolesMapping: z.string(), - requiredGroup: z.string().optional() + rolesMapping: jsonTransform>({}), + requiredGroup: z.string().optional(), + allowedOrigins: jsonTransform([]), + caPath: z.string().optional() }), webRtc: z.object({ port: z.coerce.number().int().positive(), @@ -49,7 +66,8 @@ const zConfig = z.object({ }) }); -type TConfig = z.infer; +// use z.output to get the types AFTER the JSON transformations +type TConfig = z.output; const defaultConfig: TConfig = { server: { @@ -59,12 +77,15 @@ const defaultConfig: TConfig = { disableLocalSignup: false }, oidc: { - issuer: '', + oidcEnabled: false, + issuer: 'https://auth.example.com/.well-known/openid-configuration', clientId: '', clientSecret: '', - redirectUrl: '', - rolesMapping: '{}', - requiredGroup: '' + redirectUrl: 'https://sharkord.example.com/auth/callback', + rolesMapping: {"Group1":"Role1"}, + requiredGroup: 'ExampleOIDCGroup', + allowedOrigins: ['https://sharkord.example.com'], + caPath: '' }, webRtc: { port: 40000, @@ -86,6 +107,15 @@ const defaultConfig: TConfig = { } }; +const prepareForSave = (data: TConfig) => ({ + ...data, + oidc: { + ...data.oidc, + rolesMapping: JSON.stringify(data.oidc.rolesMapping), + allowedOrigins: JSON.stringify(data.oidc.allowedOrigins) + } +}); + let config: TConfig = structuredClone(defaultConfig); await ensureServerDirs(); @@ -93,30 +123,21 @@ await ensureServerDirs(); const configExists = await fs.exists(CONFIG_INI_PATH); if (!configExists) { - // config does not exist, create it with the default config - await fs.writeFile(CONFIG_INI_PATH, stringify(config)); + await fs.writeFile(CONFIG_INI_PATH, stringify(prepareForSave(config))); } else { try { - // config exists, we need to make sure it is up to date with the schema - // to make this easy, we will read the existing config, merge it with the default config, and write it back to the file - // this way we don't have to worry about migrating old config files when we add/remove config options - const existingConfigText = await fs.readFile(CONFIG_INI_PATH, { - encoding: 'utf-8' - }); - - const existingConfig = parse(existingConfigText) as Partial; - const mergedConfig = deepMerge(config, existingConfig); - + const existingConfigText = await fs.readFile(CONFIG_INI_PATH, { encoding: 'utf-8' }); + const existingConfig = parse(existingConfigText); + + const mergedConfig = deepMerge(defaultConfig, existingConfig); config = zConfig.parse(mergedConfig); - await fs.writeFile(CONFIG_INI_PATH, stringify(config)); + await fs.writeFile(CONFIG_INI_PATH, stringify(prepareForSave(config))); } catch (error) { - // something went wrong, just log the error and overwrite the config file with the default config console.error( - `Error reading or parsing config.ini. Overwriting with default config. Error: ${getErrorMessage(error)}` + `Error parsing config.ini. Resetting to defaults. Error: ${getErrorMessage(error)}` ); - - await fs.writeFile(CONFIG_INI_PATH, stringify(config)); + await fs.writeFile(CONFIG_INI_PATH, stringify(prepareForSave(config))); } } @@ -126,12 +147,15 @@ config = applyEnvOverrides(config, { 'server.autoupdate': 'SHARKORD_AUTOUPDATE', 'server.disableLocalSignup': 'SHARKORD_DISABLE_LOCAL_SIGNUP', + 'oidc.oidcEnabled': 'OIDC_ENABLED', 'oidc.issuer': 'OIDC_ISSUER', 'oidc.clientId': 'OIDC_CLIENT_ID', 'oidc.clientSecret': 'OIDC_CLIENT_SECRET', 'oidc.redirectUrl': 'OIDC_REDIRECT_URL', 'oidc.rolesMapping': 'OIDC_ROLES_MAPPING', 'oidc.requiredGroup': 'OIDC_REQUIRED_GROUP', + 'oidc.allowedOrigins': 'OIDC_ALLOWED_ORIGINS', + 'oidc.caPath': 'OIDC_CA_PATH', 'webRtc.port': 'SHARKORD_WEBRTC_PORT', 'webRtc.announcedAddress': 'SHARKORD_WEBRTC_ANNOUNCED_ADDRESS' @@ -139,4 +163,4 @@ config = applyEnvOverrides(config, { config = Object.freeze(config); -export { config, SERVER_PRIVATE_IP, SERVER_PUBLIC_IP }; +export { config, SERVER_PRIVATE_IP, SERVER_PUBLIC_IP }; \ No newline at end of file diff --git a/apps/server/src/http/oidc.ts b/apps/server/src/http/oidc.ts index 535ba2bbb..dbc51791b 100644 --- a/apps/server/src/http/oidc.ts +++ b/apps/server/src/http/oidc.ts @@ -5,217 +5,240 @@ import jwt from 'jsonwebtoken'; import { db } from '../db'; import { userRoles, users } from '../db/schema'; import { eq } from 'drizzle-orm'; -import { sha256 } from '@sharkord/shared'; import { getDefaultRole, getRoles } from '../db/queries/roles'; import { getServerToken } from '../db/queries/server'; import { publishUser } from '../db/publishers'; import { getUserByIdentity } from '../db/queries/users'; -import { randomUUID } from 'crypto'; +import { randomBytes, createHash, timingSafeEqual } from 'crypto'; +import fs from 'fs/promises'; -// Allow self-signed certs (Delete in production if using real SSL) -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; +const getBaseUrl = (req: http.IncomingMessage) => { + const protocol = (req.headers['x-forwarded-proto'] as string) || 'http'; + const host = req.headers.host; + return `${protocol}://${host}`; +}; -/** - * Setup OIDC Discovery - */ -const getOidcConfig = async () => { - let issuerUrl = config.oidc.issuer; - - // Clean up URL - const suffix = '/.well-known/openid-configuration'; - if (issuerUrl.endsWith(suffix)) { - issuerUrl = issuerUrl.substring(0, issuerUrl.length - suffix.length); - } +const safeCompare = (a: string, b: string) => { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + return bufA.length === bufB.length && timingSafeEqual(bufA, bufB); +}; + +export const getOidcConfig = async () => { + const issuerUrl = new URL(config.oidc.issuer); - // Authentik Requirement: Issuer URL must end with a slash - if (!issuerUrl.endsWith('/')) { - issuerUrl += '/'; + const isLocal = issuerUrl.hostname === 'localhost' || issuerUrl.hostname === '127.0.0.1'; + if (!isLocal && issuerUrl.protocol !== 'https:') { + throw new Error(`Security Error: OIDC Issuer must use HTTPS for non-local host: ${issuerUrl.hostname}`); } - const issuer = new URL(issuerUrl); + const discoveryOptions: any = {}; - return client.discovery( - issuer, - config.oidc.clientId, - config.oidc.clientSecret, - undefined, - { - [client.customFetch]: async (url: string, options: any) => { + if (config.oidc.caPath) { + try { + const ca = await fs.readFile(config.oidc.caPath); + + discoveryOptions[client.customFetch] = (url: string, options: any) => { return fetch(url, { ...options, - tls: { rejectUnauthorized: false } + ca: ca, }); - } + }; + } catch (err) { + console.error(`OIDC Config Error: Failed to read CA file at ${config.oidc.caPath}.`); } + } + + return await client.discovery( + issuerUrl, + config.oidc.clientId, + config.oidc.clientSecret, + undefined, + discoveryOptions ); }; -const getCurrentUrl = (req: http.IncomingMessage) => { - const protocol = req.headers['x-forwarded-proto'] || 'http'; - const host = req.headers.host; - const url = new URL(req.url ?? '', `${protocol}://${host}`); - - if (config.oidc.redirectUrl.startsWith('https:')) { - url.protocol = 'https:'; +export const oidcLogin = async (req: http.IncomingMessage, res: http.ServerResponse) => { + if (config.oidc.oidcEnabled === false) { + return res.writeHead(404); } - return url; -}; - -// --- HANDLERS --- - -const oidcLogin = async (req: http.IncomingMessage, res: http.ServerResponse) => { + try { const as = await getOidcConfig(); - const redirectTo = client.buildAuthorizationUrl(as, { - redirect_uri: config.oidc.redirectUrl, - scope: 'openid profile email groups roles', - }); + + const code_verifier = client.randomPKCECodeVerifier(); + const code_challenge = await client.calculatePKCECodeChallenge(code_verifier); + const state = client.randomState(); + const nonce = client.randomNonce(); - res.writeHead(302, { Location: redirectTo.href }); - res.end(); + const sessionData = JSON.stringify({ code_verifier, state, nonce }); + + // Set OIDC session cookie + res.setHeader('Set-Cookie', `__Host-oidc_session=${encodeURIComponent(sessionData)}; HttpOnly; Secure; SameSite=Lax; Max-Age=300; Path=/`); + + const baseUrl = getBaseUrl(req); + const redirectUri = config.oidc.redirectUrl || `${baseUrl}/auth/callback`; + + const parameters: Record = { + redirect_uri: redirectUri, + scope: 'openid profile email groups', + code_challenge, + code_challenge_method: 'S256', + state, + nonce, + }; + + const redirectTo = client.buildAuthorizationUrl(as, parameters); + res.writeHead(302, { Location: redirectTo.href }).end(); } catch (error) { console.error('OIDC Login Error:', error); - res.writeHead(500); - res.end('Internal Server Error'); + res.writeHead(500).end('Internal Server Error'); } }; -const oidcCallback = async (req: http.IncomingMessage, res: http.ServerResponse) => { +export const oidcCallback = async (req: http.IncomingMessage, res: http.ServerResponse) => { + if (config.oidc.oidcEnabled === false) { + return res.writeHead(404); + } + try { const as = await getOidcConfig(); - const currentUrl = getCurrentUrl(req); - - // 1. Standard Token Exchange - const response = await client.authorizationCodeGrant( - as, - currentUrl, - { - idTokenExpected: true, - expectedState: client.skipStateCheck - }, - { - redirect_uri: config.oidc.redirectUrl - }, - { - [client.allowInsecureRequests as unknown as string]: true - } - ); + + const rawCookies = req.headers.cookie || ''; + const cookieMap = Object.fromEntries(rawCookies.split('; ').map(v => v.split('='))); + const sessionCookie = cookieMap['__Host-oidc_session']; - // 2. Extract Claims - // We use 'any' here to allow merging UserInfo later without TS complaining - // that the types don't match the strict 'IDToken' interface. - let claims: any = response.claims(); - - // 3. Robust Fallback - // If ID Token is thin, fetch full profile from UserInfo endpoint - if (!claims || (!claims.email && !claims.sub)) { - // We pass '' as fallback for sub to satisfy TS string requirement - const userInfo = await client.fetchUserInfo(as, response.access_token, claims?.sub || ''); - claims = { ...claims, ...userInfo }; + if (!sessionCookie) throw new Error('Missing OIDC session cookie'); + + let sessionData; + try { + sessionData = JSON.parse(decodeURIComponent(sessionCookie)); + } catch(e) { + throw new Error('Invalid session cookie format'); } + const { code_verifier, state: expectedState, nonce: expectedNonce } = sessionData; + + const baseUrl = getBaseUrl(req); + const safeUrl = (req.url || '').startsWith('/') ? req.url : '/'; + const url = new URL(safeUrl || '', baseUrl); - if (!claims || (!claims.sub && !claims.email)) { - throw new Error('No identity claims found in ID Token or UserInfo'); + const params = Object.fromEntries(url.searchParams); + + if (!params.state || !safeCompare(params.state, expectedState)) { + throw new Error('CSRF token mismatch'); } + + const tokenResponse = await client.authorizationCodeGrant(as, url, { + pkceCodeVerifier: code_verifier, + expectedState, + expectedNonce, + }); - // --- USER SYNC & LOGIC --- + const claims = tokenResponse.claims(); + if (!claims || !claims.sub || !claims.email) { + throw new Error('Invalid claims structure'); + } - // Check Required Group if (config.oidc.requiredGroup) { - const userGroups = (claims.groups as string[]) ?? (claims.roles as string[]) ?? []; - const hasRequiredGroup = userGroups.some(g => g.toLowerCase() === config.oidc.requiredGroup!.toLowerCase()); - - if (!hasRequiredGroup) { - res.writeHead(403); - res.end(`Access Denied: Missing required group '${config.oidc.requiredGroup}'`); - return; - } + const groups = (claims.groups as string[]) || []; + if (!groups.some(g => g.toLowerCase() === config.oidc.requiredGroup?.toLowerCase())) { + return res.writeHead(403).end('Forbidden'); + } } - const identity = claims.email ?? claims.preferred_username ?? claims.sub; + const identity = (claims.email || claims.sub) as string; + const user = await syncUserWithDatabase(identity, claims); + + const appToken = jwt.sign({ userId: user.id }, await getServerToken(), { expiresIn: '1d' }); - // Explicitly cast to string to satisfy TS - let user = await getUserByIdentity(identity as string); - - // Create User - if (!user) { - const randomPassword = randomUUID(); - const hashedPassword = await sha256(randomPassword); - const defaultRole = await getDefaultRole(); - - if (!defaultRole) throw new Error('Default role not found'); - - const newUser = await db - .insert(users) - .values({ - name: (claims.name as string) ?? 'OIDC User', - identity: identity as string, - createdAt: Date.now(), - password: hashedPassword, - }) - .returning() - .get(); - - await db.insert(userRoles).values({ - roleId: defaultRole.id, - userId: newUser.id, - createdAt: Date.now(), - }); + const target = new URL(baseUrl); + + // Set success flag so frontend knows to initiate connection + target.searchParams.set('oidc_status', 'success'); - publishUser(newUser.id, 'create'); - - // RE-FETCH FULL USER OBJECT - // We must do this because 'newUser' is a raw table row, but 'user' needs - // to be the Joined User type (with roles, etc.) for the code below. - user = await getUserByIdentity(identity as string); + const allowedRedirects = config.oidc.allowedOrigins.map((origin: string) => new URL(origin)); + if (!allowedRedirects.some((u: URL) => u.origin === target.origin)) { + throw new Error(`Invalid redirect origin: ${target.origin}`); } - if (!user) { - throw new Error('User could not be found or created'); - } + // Set App Token as HttpOnly Cookie AND Clear OIDC Session in one header + const authCookie = `sharkord_token=${appToken}; Path=/; SameSite=Lax; Secure; Max-Age=86400`; + const clearSession = '__Host-oidc_session=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax'; + + res.setHeader('Set-Cookie', [authCookie, clearSession]); + res.writeHead(302, { Location: target.toString() }).end(); + + } catch (error) { + console.error('OIDC Callback Error:', error); + res.writeHead(401).end('Authentication Failed'); + } +}; - // Role Mapping - const rolesMapping = JSON.parse(config.oidc.rolesMapping || '{}'); - if (Object.keys(rolesMapping).length > 0) { - const oidcRoles = ((claims.groups as string[]) ?? (claims.roles as string[]) ?? []).map((role: string) => role.toLowerCase()); - const allDbRoles = await getRoles(); - const userRoleIds = new Set(); - - for (const oidcRoleName of oidcRoles) { - const sharkordRoleName = rolesMapping[oidcRoleName]; - if (sharkordRoleName) { - const dbRole = allDbRoles.find(r => r.name.toLowerCase() === sharkordRoleName.toLowerCase()); - if (dbRole) userRoleIds.add(dbRole.id); - } - } - - if (userRoleIds.size > 0) { - await db.delete(userRoles).where(eq(userRoles.userId, user.id)); - await db.insert(userRoles).values(Array.from(userRoleIds).map(roleId => ({ - userId: user.id, - roleId, - createdAt: Date.now() - }))); - } +async function syncUserWithDatabase(identity: string, claims: any) { + let user = await getUserByIdentity(identity); + + if (!user) { + const defaultRole = await getDefaultRole(); + if (!defaultRole) throw new Error('Default role missing'); + + const randomPassword = createHash('sha256').update(randomBytes(32).toString('hex')).digest('hex'); + + const [insertedUser] = await db.insert(users).values({ + identity: identity, + password: randomPassword, + name: (claims.name as string) ?? identity.split('@')[0], + createdAt: Date.now(), + lastLoginAt: Date.now(), + banned: false, + }).returning(); + + if (!insertedUser) { + throw new Error('Failed to create user: Database insert returned no data.'); } - // Issue App Token & Redirect - const token = jwt.sign({ userId: user.id }, await getServerToken(), { - expiresIn: '86400s' // 1 day + await db.insert(userRoles).values({ + roleId: defaultRole.id, + userId: insertedUser.id, + createdAt: Date.now(), }); - const clientUrl = req.headers.referer || req.headers.origin || `http://${req.headers.host}`; - const redirectUrl = new URL(clientUrl); - redirectUrl.searchParams.set('token', token); + publishUser(insertedUser.id, 'create'); + + user = await getUserByIdentity(identity); + } - res.writeHead(302, { Location: redirectUrl.toString() }); - res.end(); + if (!user) throw new Error('User synchronization failed'); + + await applyRoleMappings(user.id, claims); + return user; +} - } catch (error) { - console.error('OIDC Callback Error:', error); - res.writeHead(500); - res.end('Internal Server Error'); +async function applyRoleMappings(userId: number, claims: any) { + const rolesMapping = config.oidc.rolesMapping; + + if (Object.keys(rolesMapping).length === 0) return; + + const oidcGroups = ((claims.groups as string[]) || []).map(g => g.toLowerCase()); + const allDbRoles = await getRoles(); + const targetRoleIds: number[] = []; + + for (const [oidcRole, localRole] of Object.entries(rolesMapping)) { + if (oidcGroups.includes(oidcRole.toLowerCase())) { + const dbRole = allDbRoles.find( + (r) => r.name.toLowerCase() === localRole.toLowerCase() + ); + if (dbRole) targetRoleIds.push(dbRole.id); + } } -}; -export { oidcLogin, oidcCallback }; \ No newline at end of file + if (targetRoleIds.length > 0) { + await db.delete(userRoles).where(eq(userRoles.userId, userId)); + await db.insert(userRoles).values( + targetRoleIds.map((roleId) => ({ + userId, + roleId, + createdAt: Date.now(), + })) + ); + } +} \ No newline at end of file From 3434c73db055531c28f7168d4ac8945f4ed8df38 Mon Sep 17 00:00:00 2001 From: zigzag Date: Mon, 23 Feb 2026 19:38:20 -0500 Subject: [PATCH 3/7] fix: OIDC - Add 'role added by method' metadata - Change allowedOrigins to csv instead of broken ini List - OIDC role mapping does not overwrite manually given roles - redirectUrl now using clients host but only if withing allowed origins - Fix loadDb to include the schema import, typing the BunSQLiteDatabase instance for full relational API support. - If OIDC is the only allowed login method then it automatically redirects to the oidc provider instead of waiting for user input --- apps/client/src/screens/connect/index.tsx | 86 ++++++++++--------- apps/server/src/config.ts | 49 ++++++----- apps/server/src/db/index.ts | 5 +- .../db/migrations/0001_spotty_wolfpack.sql | 1 + .../src/db/migrations/meta/_journal.json | 7 ++ apps/server/src/db/schema.ts | 5 +- apps/server/src/http/info.ts | 4 +- apps/server/src/http/oidc.ts | 62 ++++++++----- 8 files changed, 132 insertions(+), 87 deletions(-) create mode 100644 apps/server/src/db/migrations/0001_spotty_wolfpack.sql diff --git a/apps/client/src/screens/connect/index.tsx b/apps/client/src/screens/connect/index.tsx index 5161ab890..117e4e8e1 100644 --- a/apps/client/src/screens/connect/index.tsx +++ b/apps/client/src/screens/connect/index.tsx @@ -9,6 +9,7 @@ import { useInfo } from '@/features/server/hooks'; import { getFileUrl, getUrlFromServer } from '@/helpers/get-file-url'; import { getLocalStorageItem, + getSessionStorageItem, LocalStorageKey, removeLocalStorageItem, SessionStorageKey, @@ -102,6 +103,13 @@ const Connect = memo(() => { } }, [handleOidcSuccess]); + useStrictEffect(() => { + const token = getSessionStorageItem(SessionStorageKey.TOKEN); + if (info && info.oidcEnabled && !info.allowNewUsers && !token) { + onOidcLoginClick(); + } + }, [info, onOidcLoginClick]); + const onConnectClick = useCallback(async () => { setLoading(true); @@ -180,27 +188,29 @@ const Connect = memo(() => { )} -
- - - - - - - - - -
+ {!(info?.oidcEnabled && !info?.allowNewUsers) && ( +
+ + + + + + + + + +
+ )}
{!window.isSecureContext && ( @@ -218,14 +228,16 @@ const Connect = memo(() => { )} - + {!(info?.oidcEnabled && !info?.allowNewUsers) && ( + + )} {info?.oidcEnabled && ( )} - {!info?.allowNewUsers && ( - <> - {!inviteCode && ( - - New user registrations are currently disabled. If you do not - have an account yet, you need to be invited by an existing - user to join this server. - - )} - + {!info?.allowNewUsers && !inviteCode && ( + + New user registrations are currently disabled. If you do not + have an account yet, you need to be invited by an existing user + to join this server. + )} {inviteCode && ( diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d9cfcf15d..8fee0d673 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -14,10 +14,6 @@ const [SERVER_PUBLIC_IP, SERVER_PRIVATE_IP] = await Promise.all([ getPrivateIp() ]); -/** - * Helper: Transforms a JSON string from the INI/Env into a typed Object or Array. - * Falling back to the provided default if parsing fails. - */ const jsonTransform = (fallback: T) => z.preprocess((val) => { if (typeof val !== 'string') return val; @@ -28,6 +24,12 @@ const jsonTransform = (fallback: T) => } }, z.any()).transform((val) => val as T); +const commaSeparatedTransform = (fallback: string[]) => + z.preprocess((val) => { + if (typeof val !== 'string') return val; + return val.split(',').map((s) => s.trim()).filter(Boolean); + }, z.string().array()); + const zConfig = z.object({ server: z.object({ port: z.coerce.number().int().positive(), @@ -40,11 +42,10 @@ const zConfig = z.object({ issuer: z.string(), clientId: z.string(), clientSecret: z.string(), - redirectUrl: z.string(), rolesMapping: jsonTransform>({}), requiredGroup: z.string().optional(), - allowedOrigins: jsonTransform([]), - caPath: z.string().optional() + allowedOrigins: commaSeparatedTransform([]), + caCertPath: z.string().optional() }), webRtc: z.object({ port: z.coerce.number().int().positive(), @@ -66,10 +67,9 @@ const zConfig = z.object({ }) }); -// use z.output to get the types AFTER the JSON transformations type TConfig = z.output; -const defaultConfig: TConfig = { +const defaultConfig : TConfig = { server: { port: 4991, debug: IS_DEVELOPMENT, @@ -81,11 +81,10 @@ const defaultConfig: TConfig = { issuer: 'https://auth.example.com/.well-known/openid-configuration', clientId: '', clientSecret: '', - redirectUrl: 'https://sharkord.example.com/auth/callback', rolesMapping: {"Group1":"Role1"}, requiredGroup: 'ExampleOIDCGroup', - allowedOrigins: ['https://sharkord.example.com'], - caPath: '' + allowedOrigins: ['https://sharkord.example.com', 'https://sharkord2.example.com'], + caCertPath: '' }, webRtc: { port: 40000, @@ -107,16 +106,21 @@ const defaultConfig: TConfig = { } }; -const prepareForSave = (data: TConfig) => ({ - ...data, - oidc: { - ...data.oidc, - rolesMapping: JSON.stringify(data.oidc.rolesMapping), - allowedOrigins: JSON.stringify(data.oidc.allowedOrigins) - } -}); +const prepareForSave = (data: TConfig) => { + const { oidc, ...rest } = data; + const { allowedOrigins, rolesMapping, ...oidcRest } = oidc; + + return { + ...rest, + oidc: { + ...oidcRest, + rolesMapping: JSON.stringify(rolesMapping), + allowedOrigins: allowedOrigins.join(','), + } + }; +}; -let config: TConfig = structuredClone(defaultConfig); +let config: TConfig = zConfig.parse(defaultConfig); await ensureServerDirs(); @@ -151,11 +155,10 @@ config = applyEnvOverrides(config, { 'oidc.issuer': 'OIDC_ISSUER', 'oidc.clientId': 'OIDC_CLIENT_ID', 'oidc.clientSecret': 'OIDC_CLIENT_SECRET', - 'oidc.redirectUrl': 'OIDC_REDIRECT_URL', 'oidc.rolesMapping': 'OIDC_ROLES_MAPPING', 'oidc.requiredGroup': 'OIDC_REQUIRED_GROUP', 'oidc.allowedOrigins': 'OIDC_ALLOWED_ORIGINS', - 'oidc.caPath': 'OIDC_CA_PATH', + 'oidc.caCertPath': 'OIDC_CA_CERT_PATH', 'webRtc.port': 'SHARKORD_WEBRTC_PORT', 'webRtc.announcedAddress': 'SHARKORD_WEBRTC_ANNOUNCED_ADDRESS' diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts index a0e2e8d9e..38d6a4854 100644 --- a/apps/server/src/db/index.ts +++ b/apps/server/src/db/index.ts @@ -2,16 +2,17 @@ import { Database } from 'bun:sqlite'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import { BunSQLiteDatabase, drizzle } from 'drizzle-orm/bun-sqlite'; import { DB_PATH, DRIZZLE_PATH } from '../helpers/paths'; +import * as schema from './schema'; import { seedDatabase } from './seed'; -let db: BunSQLiteDatabase; +let db: BunSQLiteDatabase; const loadDb = async () => { const sqlite = new Database(DB_PATH, { create: true, strict: true }); sqlite.run('PRAGMA foreign_keys = ON;'); - db = drizzle({ client: sqlite }); + db = drizzle(sqlite, { schema }); await migrate(db, { migrationsFolder: DRIZZLE_PATH }); await seedDatabase(); diff --git a/apps/server/src/db/migrations/0001_spotty_wolfpack.sql b/apps/server/src/db/migrations/0001_spotty_wolfpack.sql new file mode 100644 index 000000000..6e05fa394 --- /dev/null +++ b/apps/server/src/db/migrations/0001_spotty_wolfpack.sql @@ -0,0 +1 @@ +ALTER TABLE `user_roles` ADD `added_by` text DEFAULT 'manual' NOT NULL; \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json index 1c6652f03..cd6e51e0a 100644 --- a/apps/server/src/db/migrations/meta/_journal.json +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1769619685540, "tag": "0000_rich_jetstream", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1771538238413, + "tag": "0001_spotty_wolfpack", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index d467e99b7..ab02eec51 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -155,7 +155,10 @@ const userRoles = sqliteTable( roleId: integer('role_id') .notNull() .references(() => roles.id, { onDelete: 'cascade' }), - createdAt: integer('created_at').notNull() + createdAt: integer('created_at').notNull(), + addedBy: text('added_by', { enum: ['manual', 'oidc', 'bot'] }) + .notNull() + .default('manual') }, (t) => [ primaryKey({ columns: [t.userId, t.roleId] }), diff --git a/apps/server/src/http/info.ts b/apps/server/src/http/info.ts index 7f4ff816f..63bee69dc 100644 --- a/apps/server/src/http/info.ts +++ b/apps/server/src/http/info.ts @@ -16,8 +16,8 @@ const infoRouteHandler = async ( name: settings.name, description: settings.description, logo: settings.logo, - allowNewUsers: settings.allowNewUsers, - oidcEnabled: !!config.oidc.issuer && !!config.oidc.clientId + allowNewUsers: config.server.disableLocalSignup ? false : settings.allowNewUsers, + oidcEnabled: config.oidc.oidcEnabled }; res.writeHead(200, { 'Content-Type': 'application/json' }); diff --git a/apps/server/src/http/oidc.ts b/apps/server/src/http/oidc.ts index dbc51791b..a06054ee0 100644 --- a/apps/server/src/http/oidc.ts +++ b/apps/server/src/http/oidc.ts @@ -4,7 +4,7 @@ import http from 'http'; import jwt from 'jsonwebtoken'; import { db } from '../db'; import { userRoles, users } from '../db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, and, inArray } from 'drizzle-orm'; import { getDefaultRole, getRoles } from '../db/queries/roles'; import { getServerToken } from '../db/queries/server'; import { publishUser } from '../db/publishers'; @@ -34,9 +34,9 @@ export const getOidcConfig = async () => { const discoveryOptions: any = {}; - if (config.oidc.caPath) { + if (config.oidc.caCertPath) { try { - const ca = await fs.readFile(config.oidc.caPath); + const ca = await fs.readFile(config.oidc.caCertPath); discoveryOptions[client.customFetch] = (url: string, options: any) => { return fetch(url, { @@ -45,7 +45,7 @@ export const getOidcConfig = async () => { }); }; } catch (err) { - console.error(`OIDC Config Error: Failed to read CA file at ${config.oidc.caPath}.`); + console.error(`OIDC Config Error: Failed to read CA file at ${config.oidc.caCertPath}.`); } } @@ -65,19 +65,29 @@ export const oidcLogin = async (req: http.IncomingMessage, res: http.ServerRespo try { const as = await getOidcConfig(); + + const referer = req.headers.referer; + if (!referer) { + return res.writeHead(400, 'Referer header is missing').end(); + } + + const refererOrigin = new URL(referer).origin; + if (!config.oidc.allowedOrigins.includes(refererOrigin)) { + return res.writeHead(400, 'Invalid origin').end(); + } const code_verifier = client.randomPKCECodeVerifier(); const code_challenge = await client.calculatePKCECodeChallenge(code_verifier); const state = client.randomState(); const nonce = client.randomNonce(); - const sessionData = JSON.stringify({ code_verifier, state, nonce }); + const sessionData = JSON.stringify({ code_verifier, state, nonce, redirectOrigin: refererOrigin }); // Set OIDC session cookie res.setHeader('Set-Cookie', `__Host-oidc_session=${encodeURIComponent(sessionData)}; HttpOnly; Secure; SameSite=Lax; Max-Age=300; Path=/`); const baseUrl = getBaseUrl(req); - const redirectUri = config.oidc.redirectUrl || `${baseUrl}/auth/callback`; + const redirectUri = `${baseUrl}/auth/callback`; const parameters: Record = { redirect_uri: redirectUri, @@ -116,7 +126,11 @@ export const oidcCallback = async (req: http.IncomingMessage, res: http.ServerRe } catch(e) { throw new Error('Invalid session cookie format'); } - const { code_verifier, state: expectedState, nonce: expectedNonce } = sessionData; + const { code_verifier, state: expectedState, nonce: expectedNonce, redirectOrigin } = sessionData; + + if (!redirectOrigin || !config.oidc.allowedOrigins.includes(redirectOrigin)) { + throw new Error('Invalid redirect origin in session'); + } const baseUrl = getBaseUrl(req); const safeUrl = (req.url || '').startsWith('/') ? req.url : '/'; @@ -151,16 +165,11 @@ export const oidcCallback = async (req: http.IncomingMessage, res: http.ServerRe const appToken = jwt.sign({ userId: user.id }, await getServerToken(), { expiresIn: '1d' }); - const target = new URL(baseUrl); + const target = new URL(redirectOrigin); // Set success flag so frontend knows to initiate connection target.searchParams.set('oidc_status', 'success'); - const allowedRedirects = config.oidc.allowedOrigins.map((origin: string) => new URL(origin)); - if (!allowedRedirects.some((u: URL) => u.origin === target.origin)) { - throw new Error(`Invalid redirect origin: ${target.origin}`); - } - // Set App Token as HttpOnly Cookie AND Clear OIDC Session in one header const authCookie = `sharkord_token=${appToken}; Path=/; SameSite=Lax; Secure; Max-Age=86400`; const clearSession = '__Host-oidc_session=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax'; @@ -215,29 +224,42 @@ async function syncUserWithDatabase(identity: string, claims: any) { async function applyRoleMappings(userId: number, claims: any) { const rolesMapping = config.oidc.rolesMapping; - if (Object.keys(rolesMapping).length === 0) return; - - const oidcGroups = ((claims.groups as string[]) || []).map(g => g.toLowerCase()); + + const oidcGroups = ((claims.groups as string[]) || []).map((g: string) => g.toLowerCase()); const allDbRoles = await getRoles(); const targetRoleIds: number[] = []; for (const [oidcRole, localRole] of Object.entries(rolesMapping)) { if (oidcGroups.includes(oidcRole.toLowerCase())) { const dbRole = allDbRoles.find( - (r) => r.name.toLowerCase() === localRole.toLowerCase() + (r: { id: number; name: string; }) => r.name.toLowerCase() === localRole.toLowerCase() ); if (dbRole) targetRoleIds.push(dbRole.id); } } + const userCurrentRoles = await db.query.userRoles.findMany({ where: eq(userRoles.userId, userId) }); + const oidcManagedRoleIds = userCurrentRoles + .filter((r) => r.addedBy === 'oidc') + .map((r) => r.roleId); + + const rolesToRemove = oidcManagedRoleIds.filter((id) => !targetRoleIds.includes(id)); + if (rolesToRemove.length > 0) { + await db.delete(userRoles).where(and( + eq(userRoles.userId, userId), + inArray(userRoles.roleId, rolesToRemove), + eq(userRoles.addedBy, 'oidc') + )); + } - if (targetRoleIds.length > 0) { - await db.delete(userRoles).where(eq(userRoles.userId, userId)); + const rolesToAdd = targetRoleIds.filter((id) => !userCurrentRoles.some((r) => r.roleId === id)); + if (rolesToAdd.length > 0) { await db.insert(userRoles).values( - targetRoleIds.map((roleId) => ({ + rolesToAdd.map(roleId => ({ userId, roleId, createdAt: Date.now(), + addedBy: 'oidc' as const })) ); } From 1416e37079bfde0386fbddeebf4e38695e5d1d6c Mon Sep 17 00:00:00 2001 From: zigzag Date: Tue, 24 Feb 2026 16:53:52 -0500 Subject: [PATCH 4/7] Add Enforce OIDC Roles --- apps/server/src/config.ts | 3 +++ apps/server/src/http/oidc.ts | 40 ++++++++++++++++++++++++++---------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 8fee0d673..50ac19708 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -39,6 +39,7 @@ const zConfig = z.object({ }), oidc: z.object({ oidcEnabled: z.coerce.boolean(), + enforceOidcRoles: z.coerce.boolean(), issuer: z.string(), clientId: z.string(), clientSecret: z.string(), @@ -78,6 +79,7 @@ const defaultConfig : TConfig = { }, oidc: { oidcEnabled: false, + enforceOidcRoles: true, issuer: 'https://auth.example.com/.well-known/openid-configuration', clientId: '', clientSecret: '', @@ -152,6 +154,7 @@ config = applyEnvOverrides(config, { 'server.disableLocalSignup': 'SHARKORD_DISABLE_LOCAL_SIGNUP', 'oidc.oidcEnabled': 'OIDC_ENABLED', + 'oidc.enforceOidcRoles': 'OIDC_ENFORCE_ROLES', 'oidc.issuer': 'OIDC_ISSUER', 'oidc.clientId': 'OIDC_CLIENT_ID', 'oidc.clientSecret': 'OIDC_CLIENT_SECRET', diff --git a/apps/server/src/http/oidc.ts b/apps/server/src/http/oidc.ts index a06054ee0..7c75d59e2 100644 --- a/apps/server/src/http/oidc.ts +++ b/apps/server/src/http/oidc.ts @@ -239,17 +239,35 @@ async function applyRoleMappings(userId: number, claims: any) { } } const userCurrentRoles = await db.query.userRoles.findMany({ where: eq(userRoles.userId, userId) }); - const oidcManagedRoleIds = userCurrentRoles - .filter((r) => r.addedBy === 'oidc') - .map((r) => r.roleId); - - const rolesToRemove = oidcManagedRoleIds.filter((id) => !targetRoleIds.includes(id)); - if (rolesToRemove.length > 0) { - await db.delete(userRoles).where(and( - eq(userRoles.userId, userId), - inArray(userRoles.roleId, rolesToRemove), - eq(userRoles.addedBy, 'oidc') - )); + + if (config.oidc.enforceOidcRoles) { + const mappedRoleNames = Object.values(rolesMapping).map(name => name.toLowerCase()); + const mappedDbRoles = allDbRoles.filter( + (r: { id: number; name: string; }) => mappedRoleNames.includes(r.name.toLowerCase()) + ); + const mappedDbRoleIds = mappedDbRoles.map(r => r.id); + const userCurrentRoleIds = userCurrentRoles.map(r => r.roleId); + + const rolesToRemove = userCurrentRoleIds.filter(id => mappedDbRoleIds.includes(id) && !targetRoleIds.includes(id)); + if (rolesToRemove.length > 0) { + await db.delete(userRoles).where(and( + eq(userRoles.userId, userId), + inArray(userRoles.roleId, rolesToRemove) + )); + } + } else { + const oidcManagedRoleIds = userCurrentRoles + .filter((r) => r.addedBy === 'oidc') + .map((r) => r.roleId); + + const rolesToRemove = oidcManagedRoleIds.filter((id) => !targetRoleIds.includes(id)); + if (rolesToRemove.length > 0) { + await db.delete(userRoles).where(and( + eq(userRoles.userId, userId), + inArray(userRoles.roleId, rolesToRemove), + eq(userRoles.addedBy, 'oidc') + )); + } } const rolesToAdd = targetRoleIds.filter((id) => !userCurrentRoles.some((r) => r.roleId === id)); From 53948069f0c5eb2db1bc3cb6a703fee0906a5c55 Mon Sep 17 00:00:00 2001 From: zigzag Date: Thu, 5 Mar 2026 12:31:37 -0500 Subject: [PATCH 5/7] fix oidc sql migration --- .../{0001_spotty_wolfpack.sql => 0007_roles_added_by.sql} | 0 apps/server/src/db/migrations/meta/_journal.json | 7 +++++++ 2 files changed, 7 insertions(+) rename apps/server/src/db/migrations/{0001_spotty_wolfpack.sql => 0007_roles_added_by.sql} (100%) diff --git a/apps/server/src/db/migrations/0001_spotty_wolfpack.sql b/apps/server/src/db/migrations/0007_roles_added_by.sql similarity index 100% rename from apps/server/src/db/migrations/0001_spotty_wolfpack.sql rename to apps/server/src/db/migrations/0007_roles_added_by.sql diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json index fcb797b92..e75ee06e5 100644 --- a/apps/server/src/db/migrations/meta/_journal.json +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1772733600000, "tag": "0006_lowercase_remaining_identities", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1772733600000, + "tag": "0007_roles_added_by", + "breakpoints": true } ] } From 8c3caf58f7d26bf8614c5b8a3d24ea751cef813d Mon Sep 17 00:00:00 2001 From: zigzag Date: Mon, 9 Mar 2026 15:43:15 -0400 Subject: [PATCH 6/7] fix(oidc): spec compliance, stable identity, configurable claims, and bug fixes - Only require sub in ID token per OIDC spec; fetch UserInfo endpoint as fallback for email, groups, username, and display name claims - Add oidcSub column to users table (migration 0008) as stable IdP identifier so email/username changes on the IdP don't create duplicate accounts - Look up users by oidcSub first, fall back to identity for pre-existing users; backfill oidcSub on first login after migration - Sync identity and display name on every OIDC login; always update lastLoginAt - Add migration 0009 for user_roles.added_by column required by role mapping - Fix /auth/callback route missing leading slash (route was unreachable) - Fix cookie parsing to correctly handle values containing = characters - Cache OIDC discovery document for 5 minutes to avoid hitting IdP on every request - Add groupsClaim config (env: OIDC_GROUPS_CLAIM, default: groups) - Add usernameClaim config (env: OIDC_USERNAME_CLAIM, default: preferred_username) - Add displayNameClaim config (env: OIDC_DISPLAY_NAME_CLAIM) and enforceOidcDisplayName (env: OIDC_ENFORCE_DISPLAY_NAME, default: true) - Add additionalScopes config (env: OIDC_ADDITIONAL_SCOPES) for custom claim scopes - Change requiredGroup to requiredGroups (env: OIDC_REQUIRED_GROUPS, comma-separated); user needs membership in any one of the listed groups - Include displayNameClaim in needsUserInfo check - Deduplicate targetRoleIds before insert to prevent PK violations - Clear noisy example defaults for rolesMapping, requiredGroups, allowedOrigins - Remove redundant isInviteValid call in login route --- apps/server/src/config.ts | 33 +++-- .../src/db/migrations/0007_roles_added_by.sql | 1 - .../src/db/migrations/0008_oidc_sub.sql | 3 + .../src/db/migrations/0009_roles_added_by.sql | 1 + .../src/db/migrations/meta/_journal.json | 14 ++ apps/server/src/db/queries/users.ts | 53 ++++++++ apps/server/src/db/schema.ts | 2 + apps/server/src/http/index.ts | 2 +- apps/server/src/http/login.ts | 4 - apps/server/src/http/oidc.ts | 127 ++++++++++++++---- 10 files changed, 198 insertions(+), 42 deletions(-) delete mode 100644 apps/server/src/db/migrations/0007_roles_added_by.sql create mode 100644 apps/server/src/db/migrations/0008_oidc_sub.sql create mode 100644 apps/server/src/db/migrations/0009_roles_added_by.sql diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e5d924dd6..0b81c826f 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -44,9 +44,14 @@ const zConfig = z.object({ clientId: z.string(), clientSecret: z.string(), rolesMapping: jsonTransform>({}), - requiredGroup: z.string().optional(), + requiredGroups: commaSeparatedTransform([]), allowedOrigins: commaSeparatedTransform([]), - caCertPath: z.string().optional() + caCertPath: z.string().optional(), + groupsClaim: z.string(), + usernameClaim: z.string(), + displayNameClaim: z.string(), + enforceOidcDisplayName: z.coerce.boolean(), + additionalScopes: commaSeparatedTransform([]) }), webRtc: z.object({ port: z.coerce.number().int().positive(), @@ -88,10 +93,15 @@ const defaultConfig : TConfig = { issuer: 'https://auth.example.com/.well-known/openid-configuration', clientId: '', clientSecret: '', - rolesMapping: {"Group1":"Role1"}, - requiredGroup: 'ExampleOIDCGroup', - allowedOrigins: ['https://sharkord.example.com', 'https://sharkord2.example.com'], - caCertPath: '' + rolesMapping: {}, + requiredGroups: [], + allowedOrigins: [], + caCertPath: '', + groupsClaim: 'groups', + usernameClaim: 'preferred_username', + displayNameClaim: '', + enforceOidcDisplayName: true, + additionalScopes: [] }, webRtc: { port: 40000, @@ -157,7 +167,7 @@ if (!configExists) { } } -config = applyEnvOverrides(config, { +config = zConfig.parse(applyEnvOverrides(config, { 'server.port': 'SHARKORD_PORT', 'server.debug': 'SHARKORD_DEBUG', 'server.autoupdate': 'SHARKORD_AUTOUPDATE', @@ -169,14 +179,19 @@ config = applyEnvOverrides(config, { 'oidc.clientId': 'OIDC_CLIENT_ID', 'oidc.clientSecret': 'OIDC_CLIENT_SECRET', 'oidc.rolesMapping': 'OIDC_ROLES_MAPPING', - 'oidc.requiredGroup': 'OIDC_REQUIRED_GROUP', + 'oidc.requiredGroups': 'OIDC_REQUIRED_GROUPS', 'oidc.allowedOrigins': 'OIDC_ALLOWED_ORIGINS', 'oidc.caCertPath': 'OIDC_CA_CERT_PATH', + 'oidc.groupsClaim': 'OIDC_GROUPS_CLAIM', + 'oidc.usernameClaim': 'OIDC_USERNAME_CLAIM', + 'oidc.displayNameClaim': 'OIDC_DISPLAY_NAME_CLAIM', + 'oidc.enforceOidcDisplayName': 'OIDC_ENFORCE_DISPLAY_NAME', + 'oidc.additionalScopes': 'OIDC_ADDITIONAL_SCOPES', 'webRtc.port': 'SHARKORD_WEBRTC_PORT', 'webRtc.announcedAddress': 'SHARKORD_WEBRTC_ANNOUNCED_ADDRESS', 'webRtc.maxBitrate': 'SHARKORD_WEBRTC_MAX_BITRATE' -}); +})); config = Object.freeze(config); diff --git a/apps/server/src/db/migrations/0007_roles_added_by.sql b/apps/server/src/db/migrations/0007_roles_added_by.sql deleted file mode 100644 index 6e05fa394..000000000 --- a/apps/server/src/db/migrations/0007_roles_added_by.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `user_roles` ADD `added_by` text DEFAULT 'manual' NOT NULL; \ No newline at end of file diff --git a/apps/server/src/db/migrations/0008_oidc_sub.sql b/apps/server/src/db/migrations/0008_oidc_sub.sql new file mode 100644 index 000000000..cb429133c --- /dev/null +++ b/apps/server/src/db/migrations/0008_oidc_sub.sql @@ -0,0 +1,3 @@ +ALTER TABLE `users` ADD `oidc_sub` text; +--> statement-breakpoint +CREATE UNIQUE INDEX `users_oidc_sub_idx` ON `users` (`oidc_sub`); diff --git a/apps/server/src/db/migrations/0009_roles_added_by.sql b/apps/server/src/db/migrations/0009_roles_added_by.sql new file mode 100644 index 000000000..501addca6 --- /dev/null +++ b/apps/server/src/db/migrations/0009_roles_added_by.sql @@ -0,0 +1 @@ +ALTER TABLE `user_roles` ADD `added_by` text NOT NULL DEFAULT 'manual'; diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json index 38e90616c..92b6f6a7f 100644 --- a/apps/server/src/db/migrations/meta/_journal.json +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -57,6 +57,20 @@ "when": 1772897866100, "tag": "0007_overrated_carnage", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1772897870000, + "tag": "0008_oidc_sub", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1772897874000, + "tag": "0009_roles_added_by", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/db/queries/users.ts b/apps/server/src/db/queries/users.ts index bc57af9e7..e57a07830 100644 --- a/apps/server/src/db/queries/users.ts +++ b/apps/server/src/db/queries/users.ts @@ -210,6 +210,7 @@ const getUserById = async ( banned: users.banned, banReason: users.banReason, bannedAt: users.bannedAt, + oidcSub: users.oidcSub, avatar: avatarFiles, banner: bannerFiles }) @@ -235,6 +236,54 @@ const getUserById = async ( }; }; +const getUserByOidcSub = async ( + oidcSub: string +): Promise => { + const avatarFiles = alias(files, 'avatarFiles'); + const bannerFiles = alias(files, 'bannerFiles'); + + const user = await db + .select({ + id: users.id, + identity: users.identity, + name: users.name, + avatarId: users.avatarId, + bannerId: users.bannerId, + bio: users.bio, + bannerColor: users.bannerColor, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + password: users.password, + lastLoginAt: users.lastLoginAt, + banned: users.banned, + banReason: users.banReason, + bannedAt: users.bannedAt, + oidcSub: users.oidcSub, + avatar: avatarFiles, + banner: bannerFiles + }) + .from(users) + .leftJoin(avatarFiles, eq(users.avatarId, avatarFiles.id)) + .leftJoin(bannerFiles, eq(users.bannerId, bannerFiles.id)) + .where(eq(users.oidcSub, oidcSub)) + .get(); + + if (!user) return undefined; + + const roles = await db + .select({ roleId: userRoles.roleId }) + .from(userRoles) + .where(eq(userRoles.userId, user.id)) + .all(); + + return { + ...user, + avatar: user.avatar, + banner: user.banner, + roleIds: roles.map((r) => r.roleId) + }; +}; + const getUserByIdentity = async ( identity: string ): Promise => { @@ -257,6 +306,7 @@ const getUserByIdentity = async ( banned: users.banned, banReason: users.banReason, bannedAt: users.bannedAt, + oidcSub: users.oidcSub, avatar: avatarFiles, banner: bannerFiles }) @@ -316,6 +366,7 @@ const getUsers = async (): Promise => { banned: users.banned, banReason: users.banReason, bannedAt: users.bannedAt, + oidcSub: users.oidcSub, avatar: avatarFiles, banner: bannerFiles }) @@ -359,6 +410,7 @@ const getUsers = async (): Promise => { banned: result.banned, banReason: result.banReason, bannedAt: result.bannedAt, + oidcSub: result.oidcSub, roleIds: rolesMap[result.id] || [] })); }; @@ -375,6 +427,7 @@ export { getStorageUsageByUserId, getUserById, getUserByIdentity, + getUserByOidcSub, getUserByToken, getUsers }; diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 4a9869958..9ac0cd2e1 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -150,6 +150,7 @@ const users = sqliteTable( banReason: text('ban_reason'), bannedAt: integer('banned_at'), bannerColor: text('banner_color'), + oidcSub: text('oidc_sub').unique(), lastLoginAt: integer('last_login_at') .notNull() .$defaultFn(() => Date.now()), @@ -158,6 +159,7 @@ const users = sqliteTable( }, (t) => [ uniqueIndex('users_identity_idx').on(t.identity), + uniqueIndex('users_oidc_sub_idx').on(t.oidcSub), index('users_name_idx').on(t.name), index('users_banned_idx').on(t.banned), index('users_last_login_idx').on(t.lastLoginAt) diff --git a/apps/server/src/http/index.ts b/apps/server/src/http/index.ts index 6b04efbe5..c7ace6897 100644 --- a/apps/server/src/http/index.ts +++ b/apps/server/src/http/index.ts @@ -41,7 +41,7 @@ const routeHandlers: Partial< '/healthz': (req, res) => healthRouteHandler(req, res), '/info': (req, res) => infoRouteHandler(req, res), '/auth/login': (req, res) => oidcLogin(req, res), - 'auth/callback': (req, res) => oidcCallback(req, res) + '/auth/callback': (req, res) => oidcCallback(req, res) }, prefix: { '/public': (req, res) => publicRouteHandler(req, res), diff --git a/apps/server/src/http/login.ts b/apps/server/src/http/login.ts index 1cd19916d..2175bef45 100644 --- a/apps/server/src/http/login.ts +++ b/apps/server/src/http/login.ts @@ -166,10 +166,6 @@ const loginRouteHandler = async ( ); } - if (!settings.allowNewUsers) { - const inviteError = await isInviteValid(data.invite); - } - let inviteRoleId: number | null = null; const result = await isInviteValid(data.invite); diff --git a/apps/server/src/http/oidc.ts b/apps/server/src/http/oidc.ts index 7c75d59e2..e25ddbfa0 100644 --- a/apps/server/src/http/oidc.ts +++ b/apps/server/src/http/oidc.ts @@ -8,7 +8,7 @@ import { eq, and, inArray } from 'drizzle-orm'; import { getDefaultRole, getRoles } from '../db/queries/roles'; import { getServerToken } from '../db/queries/server'; import { publishUser } from '../db/publishers'; -import { getUserByIdentity } from '../db/queries/users'; +import { getUserByIdentity, getUserByOidcSub } from '../db/queries/users'; import { randomBytes, createHash, timingSafeEqual } from 'crypto'; import fs from 'fs/promises'; @@ -24,24 +24,33 @@ const safeCompare = (a: string, b: string) => { return bufA.length === bufB.length && timingSafeEqual(bufA, bufB); }; +// Cache the OIDC discovery document for 5 minutes to avoid hitting the +// IdP well-known endpoint on every login and every callback. +let discoveryCache: { value: Awaited>; issuer: string; expiresAt: number } | null = null; +const DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000; + export const getOidcConfig = async () => { const issuerUrl = new URL(config.oidc.issuer); - + const isLocal = issuerUrl.hostname === 'localhost' || issuerUrl.hostname === '127.0.0.1'; if (!isLocal && issuerUrl.protocol !== 'https:') { throw new Error(`Security Error: OIDC Issuer must use HTTPS for non-local host: ${issuerUrl.hostname}`); } + if (discoveryCache && discoveryCache.issuer === config.oidc.issuer && Date.now() < discoveryCache.expiresAt) { + return discoveryCache.value; + } + const discoveryOptions: any = {}; if (config.oidc.caCertPath) { try { const ca = await fs.readFile(config.oidc.caCertPath); - + discoveryOptions[client.customFetch] = (url: string, options: any) => { return fetch(url, { ...options, - ca: ca, + ca: ca, }); }; } catch (err) { @@ -49,13 +58,16 @@ export const getOidcConfig = async () => { } } - return await client.discovery( + const result = await client.discovery( issuerUrl, config.oidc.clientId, config.oidc.clientSecret, undefined, discoveryOptions ); + + discoveryCache = { value: result, issuer: config.oidc.issuer, expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS }; + return result; }; export const oidcLogin = async (req: http.IncomingMessage, res: http.ServerResponse) => { @@ -91,7 +103,7 @@ export const oidcLogin = async (req: http.IncomingMessage, res: http.ServerRespo const parameters: Record = { redirect_uri: redirectUri, - scope: 'openid profile email groups', + scope: ['openid', 'profile', 'email', config.oidc.groupsClaim, ...config.oidc.additionalScopes].filter(Boolean).join(' '), code_challenge, code_challenge_method: 'S256', state, @@ -115,7 +127,12 @@ export const oidcCallback = async (req: http.IncomingMessage, res: http.ServerRe const as = await getOidcConfig(); const rawCookies = req.headers.cookie || ''; - const cookieMap = Object.fromEntries(rawCookies.split('; ').map(v => v.split('='))); + const cookieMap = Object.fromEntries( + rawCookies.split('; ').map(v => { + const idx = v.indexOf('='); + return [v.slice(0, idx), v.slice(idx + 1)]; + }) + ); const sessionCookie = cookieMap['__Host-oidc_session']; if (!sessionCookie) throw new Error('Missing OIDC session cookie'); @@ -148,20 +165,37 @@ export const oidcCallback = async (req: http.IncomingMessage, res: http.ServerRe expectedNonce, }); - const claims = tokenResponse.claims(); - if (!claims || !claims.sub || !claims.email) { - throw new Error('Invalid claims structure'); + const idTokenClaims = tokenResponse.claims(); + if (!idTokenClaims?.sub) { + throw new Error('Invalid claims: missing sub'); } - if (config.oidc.requiredGroup) { - const groups = (claims.groups as string[]) || []; - if (!groups.some(g => g.toLowerCase() === config.oidc.requiredGroup?.toLowerCase())) { + let mergedClaims: Record = { ...idTokenClaims }; + const needsUserInfo = !idTokenClaims.email || + !idTokenClaims[config.oidc.groupsClaim] || + !idTokenClaims[config.oidc.usernameClaim] || + (!!config.oidc.displayNameClaim && !idTokenClaims[config.oidc.displayNameClaim]); + + if (needsUserInfo) { + try { + const userInfo = await client.fetchUserInfo(as, tokenResponse.access_token, idTokenClaims.sub); + // ID token claims take precedence over UserInfo (ID token is signed) + mergedClaims = { ...userInfo, ...idTokenClaims }; + } catch (err) { + console.warn('OIDC: Could not fetch UserInfo endpoint:', err); + } + } + + if (config.oidc.requiredGroups.length > 0) { + const userGroups = ((mergedClaims[config.oidc.groupsClaim] as string[]) || []).map(g => g.toLowerCase()); + const hasRequired = config.oidc.requiredGroups.some(r => userGroups.includes(r.toLowerCase())); + if (!hasRequired) { return res.writeHead(403).end('Forbidden'); } } - const identity = (claims.email || claims.sub) as string; - const user = await syncUserWithDatabase(identity, claims); + const identity = ((mergedClaims.email as string) || idTokenClaims.sub) as string; + const user = await syncUserWithDatabase(identity, mergedClaims); const appToken = jwt.sign({ userId: user.id }, await getServerToken(), { expiresIn: '1d' }); @@ -183,19 +217,32 @@ export const oidcCallback = async (req: http.IncomingMessage, res: http.ServerRe } }; -async function syncUserWithDatabase(identity: string, claims: any) { - let user = await getUserByIdentity(identity); +function resolveDisplayName(claims: Record): string { + if (config.oidc.displayNameClaim) { + const val = claims[config.oidc.displayNameClaim] as string | undefined; + if (val) return val; + } + return (claims[config.oidc.usernameClaim] as string) ?? (claims.sub as string); +} + +async function syncUserWithDatabase(identity: string, claims: Record) { + const sub = claims.sub as string; + + // Look up by stable IdP subject first, fall back to identity for users + // created before oidcSub was introduced. + let user = await getUserByOidcSub(sub) ?? await getUserByIdentity(identity); if (!user) { const defaultRole = await getDefaultRole(); if (!defaultRole) throw new Error('Default role missing'); const randomPassword = createHash('sha256').update(randomBytes(32).toString('hex')).digest('hex'); - + const [insertedUser] = await db.insert(users).values({ - identity: identity, + identity, password: randomPassword, - name: (claims.name as string) ?? identity.split('@')[0], + name: resolveDisplayName(claims), + oidcSub: sub, createdAt: Date.now(), lastLoginAt: Date.now(), banned: false, @@ -212,12 +259,37 @@ async function syncUserWithDatabase(identity: string, claims: any) { }); publishUser(insertedUser.id, 'create'); - - user = await getUserByIdentity(identity); + + user = await getUserByOidcSub(sub); + } else { + // Sync mutable fields that may have changed on the IdP. + const updates: Partial = {}; + + // Always update lastLoginAt. + updates.lastLoginAt = Date.now(); + + // Always backfill oidcSub for users created before this feature. + if (!user.oidcSub) updates.oidcSub = sub; + + // Sync identity (e.g. email) if it changed on the IdP. + if (user.identity !== identity) updates.identity = identity; + + // Sync display name: always when enforceOidcDisplayName, otherwise only + // on first login (oidcSub was null, covered by the backfill path above). + if (config.oidc.enforceOidcDisplayName) { + const idpName = resolveDisplayName(claims); + if (user.name !== idpName) updates.name = idpName; + } + + if (Object.keys(updates).length > 0) { + await db.update(users).set({ ...updates, updatedAt: Date.now() }).where(eq(users.id, user.id)); + publishUser(user.id, 'update'); + user = await getUserByOidcSub(sub); + } } if (!user) throw new Error('User synchronization failed'); - + await applyRoleMappings(user.id, claims); return user; } @@ -226,7 +298,7 @@ async function applyRoleMappings(userId: number, claims: any) { const rolesMapping = config.oidc.rolesMapping; if (Object.keys(rolesMapping).length === 0) return; - const oidcGroups = ((claims.groups as string[]) || []).map((g: string) => g.toLowerCase()); + const oidcGroups = ((claims[config.oidc.groupsClaim] as string[]) || []).map((g: string) => g.toLowerCase()); const allDbRoles = await getRoles(); const targetRoleIds: number[] = []; @@ -238,6 +310,7 @@ async function applyRoleMappings(userId: number, claims: any) { if (dbRole) targetRoleIds.push(dbRole.id); } } + const uniqueTargetRoleIds = [...new Set(targetRoleIds)]; const userCurrentRoles = await db.query.userRoles.findMany({ where: eq(userRoles.userId, userId) }); if (config.oidc.enforceOidcRoles) { @@ -248,7 +321,7 @@ async function applyRoleMappings(userId: number, claims: any) { const mappedDbRoleIds = mappedDbRoles.map(r => r.id); const userCurrentRoleIds = userCurrentRoles.map(r => r.roleId); - const rolesToRemove = userCurrentRoleIds.filter(id => mappedDbRoleIds.includes(id) && !targetRoleIds.includes(id)); + const rolesToRemove = userCurrentRoleIds.filter(id => mappedDbRoleIds.includes(id) && !uniqueTargetRoleIds.includes(id)); if (rolesToRemove.length > 0) { await db.delete(userRoles).where(and( eq(userRoles.userId, userId), @@ -260,7 +333,7 @@ async function applyRoleMappings(userId: number, claims: any) { .filter((r) => r.addedBy === 'oidc') .map((r) => r.roleId); - const rolesToRemove = oidcManagedRoleIds.filter((id) => !targetRoleIds.includes(id)); + const rolesToRemove = oidcManagedRoleIds.filter((id) => !uniqueTargetRoleIds.includes(id)); if (rolesToRemove.length > 0) { await db.delete(userRoles).where(and( eq(userRoles.userId, userId), @@ -270,7 +343,7 @@ async function applyRoleMappings(userId: number, claims: any) { } } - const rolesToAdd = targetRoleIds.filter((id) => !userCurrentRoles.some((r) => r.roleId === id)); + const rolesToAdd = uniqueTargetRoleIds.filter((id) => !userCurrentRoles.some((r) => r.roleId === id)); if (rolesToAdd.length > 0) { await db.insert(userRoles).values( rolesToAdd.map(roleId => ({ From e47282443901ddd02fbecaf78e14209f60b6197d Mon Sep 17 00:00:00 2001 From: zigzag Date: Tue, 10 Mar 2026 16:38:02 -0400 Subject: [PATCH 7/7] style: apply bun run format --- apps/client/src/screens/connect/index.tsx | 24 +- apps/server/src/config.ts | 87 +++---- apps/server/src/http/info.ts | 4 +- apps/server/src/http/oidc.ts | 272 +++++++++++++++------- 4 files changed, 250 insertions(+), 137 deletions(-) diff --git a/apps/client/src/screens/connect/index.tsx b/apps/client/src/screens/connect/index.tsx index f9fd8eaa1..fff094638 100644 --- a/apps/client/src/screens/connect/index.tsx +++ b/apps/client/src/screens/connect/index.tsx @@ -5,8 +5,8 @@ import { useInfo } from '@/features/server/hooks'; import { getFileUrl, getUrlFromServer } from '@/helpers/get-file-url'; import { getLocalStorageItem, - getSessionStorageItem, getLocalStorageItemBool, + getSessionStorageItem, LocalStorageKey, removeLocalStorageItem, SessionStorageKey, @@ -14,8 +14,8 @@ import { setLocalStorageItemBool, setSessionStorageItem } from '@/helpers/storage'; -import { useStrictEffect } from '@/hooks/use-strict-effect'; import { useForm } from '@/hooks/use-form'; +import { useStrictEffect } from '@/hooks/use-strict-effect'; import { PluginSlot, TestId } from '@sharkord/shared'; import { Alert, @@ -81,24 +81,28 @@ const Connect = memo(() => { const handleOidcSuccess = useCallback(async () => { setLoading(true); try { - const cookies = document.cookie.split('; ').reduce((acc, current) => { - const [name, value] = current.split('='); - acc[name] = value; - return acc; - }, {} as Record); + const cookies = document.cookie.split('; ').reduce( + (acc, current) => { + const [name, value] = current.split('='); + acc[name] = value; + return acc; + }, + {} as Record + ); const token = cookies['sharkord_token']; if (token) { setSessionStorageItem(SessionStorageKey.TOKEN, token); - document.cookie = "sharkord_token=; Max-Age=0; path=/; SameSite=Lax; Secure"; + document.cookie = + 'sharkord_token=; Max-Age=0; path=/; SameSite=Lax; Secure'; } else { - throw new Error("No authentication token found in cookies."); + throw new Error('No authentication token found in cookies.'); } await connect(); - toast.success("Logged in with OIDC"); + toast.success('Logged in with OIDC'); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 0b81c826f..bd84a2a63 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -15,19 +15,24 @@ const [SERVER_PUBLIC_IP, SERVER_PRIVATE_IP] = await Promise.all([ ]); const jsonTransform = (fallback: T) => - z.preprocess((val) => { - if (typeof val !== 'string') return val; - try { - return JSON.parse(val); - } catch { - return fallback; - } - }, z.any()).transform((val) => val as T); + z + .preprocess((val) => { + if (typeof val !== 'string') return val; + try { + return JSON.parse(val); + } catch { + return fallback; + } + }, z.any()) + .transform((val) => val as T); const commaSeparatedTransform = (fallback: string[]) => z.preprocess((val) => { if (typeof val !== 'string') return val; - return val.split(',').map((s) => s.trim()).filter(Boolean); + return val + .split(',') + .map((s) => s.trim()) + .filter(Boolean); }, z.string().array()); const zConfig = z.object({ @@ -80,7 +85,7 @@ const zConfig = z.object({ type TConfig = z.output; -const defaultConfig : TConfig = { +const defaultConfig: TConfig = { server: { port: 4991, debug: IS_DEVELOPMENT, @@ -137,7 +142,7 @@ const prepareForSave = (data: TConfig) => { oidc: { ...oidcRest, rolesMapping: JSON.stringify(rolesMapping), - allowedOrigins: allowedOrigins.join(','), + allowedOrigins: allowedOrigins.join(',') } }; }; @@ -152,9 +157,11 @@ if (!configExists) { await fs.writeFile(CONFIG_INI_PATH, stringify(prepareForSave(config))); } else { try { - const existingConfigText = await fs.readFile(CONFIG_INI_PATH, { encoding: 'utf-8' }); + const existingConfigText = await fs.readFile(CONFIG_INI_PATH, { + encoding: 'utf-8' + }); const existingConfig = parse(existingConfigText); - + const mergedConfig = deepMerge(defaultConfig, existingConfig); config = zConfig.parse(mergedConfig); @@ -167,32 +174,34 @@ if (!configExists) { } } -config = zConfig.parse(applyEnvOverrides(config, { - 'server.port': 'SHARKORD_PORT', - 'server.debug': 'SHARKORD_DEBUG', - 'server.autoupdate': 'SHARKORD_AUTOUPDATE', - 'server.disableLocalSignup': 'SHARKORD_DISABLE_LOCAL_SIGNUP', - - 'oidc.oidcEnabled': 'OIDC_ENABLED', - 'oidc.enforceOidcRoles': 'OIDC_ENFORCE_ROLES', - 'oidc.issuer': 'OIDC_ISSUER', - 'oidc.clientId': 'OIDC_CLIENT_ID', - 'oidc.clientSecret': 'OIDC_CLIENT_SECRET', - 'oidc.rolesMapping': 'OIDC_ROLES_MAPPING', - 'oidc.requiredGroups': 'OIDC_REQUIRED_GROUPS', - 'oidc.allowedOrigins': 'OIDC_ALLOWED_ORIGINS', - 'oidc.caCertPath': 'OIDC_CA_CERT_PATH', - 'oidc.groupsClaim': 'OIDC_GROUPS_CLAIM', - 'oidc.usernameClaim': 'OIDC_USERNAME_CLAIM', - 'oidc.displayNameClaim': 'OIDC_DISPLAY_NAME_CLAIM', - 'oidc.enforceOidcDisplayName': 'OIDC_ENFORCE_DISPLAY_NAME', - 'oidc.additionalScopes': 'OIDC_ADDITIONAL_SCOPES', - - 'webRtc.port': 'SHARKORD_WEBRTC_PORT', - 'webRtc.announcedAddress': 'SHARKORD_WEBRTC_ANNOUNCED_ADDRESS', - 'webRtc.maxBitrate': 'SHARKORD_WEBRTC_MAX_BITRATE' -})); +config = zConfig.parse( + applyEnvOverrides(config, { + 'server.port': 'SHARKORD_PORT', + 'server.debug': 'SHARKORD_DEBUG', + 'server.autoupdate': 'SHARKORD_AUTOUPDATE', + 'server.disableLocalSignup': 'SHARKORD_DISABLE_LOCAL_SIGNUP', + + 'oidc.oidcEnabled': 'OIDC_ENABLED', + 'oidc.enforceOidcRoles': 'OIDC_ENFORCE_ROLES', + 'oidc.issuer': 'OIDC_ISSUER', + 'oidc.clientId': 'OIDC_CLIENT_ID', + 'oidc.clientSecret': 'OIDC_CLIENT_SECRET', + 'oidc.rolesMapping': 'OIDC_ROLES_MAPPING', + 'oidc.requiredGroups': 'OIDC_REQUIRED_GROUPS', + 'oidc.allowedOrigins': 'OIDC_ALLOWED_ORIGINS', + 'oidc.caCertPath': 'OIDC_CA_CERT_PATH', + 'oidc.groupsClaim': 'OIDC_GROUPS_CLAIM', + 'oidc.usernameClaim': 'OIDC_USERNAME_CLAIM', + 'oidc.displayNameClaim': 'OIDC_DISPLAY_NAME_CLAIM', + 'oidc.enforceOidcDisplayName': 'OIDC_ENFORCE_DISPLAY_NAME', + 'oidc.additionalScopes': 'OIDC_ADDITIONAL_SCOPES', + + 'webRtc.port': 'SHARKORD_WEBRTC_PORT', + 'webRtc.announcedAddress': 'SHARKORD_WEBRTC_ANNOUNCED_ADDRESS', + 'webRtc.maxBitrate': 'SHARKORD_WEBRTC_MAX_BITRATE' + }) +); config = Object.freeze(config); -export { config, SERVER_PRIVATE_IP, SERVER_PUBLIC_IP }; \ No newline at end of file +export { config, SERVER_PRIVATE_IP, SERVER_PUBLIC_IP }; diff --git a/apps/server/src/http/info.ts b/apps/server/src/http/info.ts index 63bee69dc..0b87d8ec7 100644 --- a/apps/server/src/http/info.ts +++ b/apps/server/src/http/info.ts @@ -16,7 +16,9 @@ const infoRouteHandler = async ( name: settings.name, description: settings.description, logo: settings.logo, - allowNewUsers: config.server.disableLocalSignup ? false : settings.allowNewUsers, + allowNewUsers: config.server.disableLocalSignup + ? false + : settings.allowNewUsers, oidcEnabled: config.oidc.oidcEnabled }; diff --git a/apps/server/src/http/oidc.ts b/apps/server/src/http/oidc.ts index e25ddbfa0..c71353de7 100644 --- a/apps/server/src/http/oidc.ts +++ b/apps/server/src/http/oidc.ts @@ -1,16 +1,16 @@ -import * as client from 'openid-client'; -import { config } from '../config'; +import { createHash, randomBytes, timingSafeEqual } from 'crypto'; +import { and, eq, inArray } from 'drizzle-orm'; +import fs from 'fs/promises'; import http from 'http'; import jwt from 'jsonwebtoken'; +import * as client from 'openid-client'; +import { config } from '../config'; import { db } from '../db'; -import { userRoles, users } from '../db/schema'; -import { eq, and, inArray } from 'drizzle-orm'; +import { publishUser } from '../db/publishers'; import { getDefaultRole, getRoles } from '../db/queries/roles'; import { getServerToken } from '../db/queries/server'; -import { publishUser } from '../db/publishers'; import { getUserByIdentity, getUserByOidcSub } from '../db/queries/users'; -import { randomBytes, createHash, timingSafeEqual } from 'crypto'; -import fs from 'fs/promises'; +import { userRoles, users } from '../db/schema'; const getBaseUrl = (req: http.IncomingMessage) => { const protocol = (req.headers['x-forwarded-proto'] as string) || 'http'; @@ -26,18 +26,29 @@ const safeCompare = (a: string, b: string) => { // Cache the OIDC discovery document for 5 minutes to avoid hitting the // IdP well-known endpoint on every login and every callback. -let discoveryCache: { value: Awaited>; issuer: string; expiresAt: number } | null = null; +let discoveryCache: { + value: Awaited>; + issuer: string; + expiresAt: number; +} | null = null; const DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000; export const getOidcConfig = async () => { const issuerUrl = new URL(config.oidc.issuer); - const isLocal = issuerUrl.hostname === 'localhost' || issuerUrl.hostname === '127.0.0.1'; + const isLocal = + issuerUrl.hostname === 'localhost' || issuerUrl.hostname === '127.0.0.1'; if (!isLocal && issuerUrl.protocol !== 'https:') { - throw new Error(`Security Error: OIDC Issuer must use HTTPS for non-local host: ${issuerUrl.hostname}`); + throw new Error( + `Security Error: OIDC Issuer must use HTTPS for non-local host: ${issuerUrl.hostname}` + ); } - if (discoveryCache && discoveryCache.issuer === config.oidc.issuer && Date.now() < discoveryCache.expiresAt) { + if ( + discoveryCache && + discoveryCache.issuer === config.oidc.issuer && + Date.now() < discoveryCache.expiresAt + ) { return discoveryCache.value; } @@ -50,11 +61,13 @@ export const getOidcConfig = async () => { discoveryOptions[client.customFetch] = (url: string, options: any) => { return fetch(url, { ...options, - ca: ca, + ca: ca }); }; } catch (err) { - console.error(`OIDC Config Error: Failed to read CA file at ${config.oidc.caCertPath}.`); + console.error( + `OIDC Config Error: Failed to read CA file at ${config.oidc.caCertPath}.` + ); } } @@ -66,15 +79,22 @@ export const getOidcConfig = async () => { discoveryOptions ); - discoveryCache = { value: result, issuer: config.oidc.issuer, expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS }; + discoveryCache = { + value: result, + issuer: config.oidc.issuer, + expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS + }; return result; }; -export const oidcLogin = async (req: http.IncomingMessage, res: http.ServerResponse) => { +export const oidcLogin = async ( + req: http.IncomingMessage, + res: http.ServerResponse +) => { if (config.oidc.oidcEnabled === false) { return res.writeHead(404); } - + try { const as = await getOidcConfig(); @@ -87,27 +107,44 @@ export const oidcLogin = async (req: http.IncomingMessage, res: http.ServerRespo if (!config.oidc.allowedOrigins.includes(refererOrigin)) { return res.writeHead(400, 'Invalid origin').end(); } - + const code_verifier = client.randomPKCECodeVerifier(); - const code_challenge = await client.calculatePKCECodeChallenge(code_verifier); + const code_challenge = + await client.calculatePKCECodeChallenge(code_verifier); const state = client.randomState(); const nonce = client.randomNonce(); - const sessionData = JSON.stringify({ code_verifier, state, nonce, redirectOrigin: refererOrigin }); - + const sessionData = JSON.stringify({ + code_verifier, + state, + nonce, + redirectOrigin: refererOrigin + }); + // Set OIDC session cookie - res.setHeader('Set-Cookie', `__Host-oidc_session=${encodeURIComponent(sessionData)}; HttpOnly; Secure; SameSite=Lax; Max-Age=300; Path=/`); + res.setHeader( + 'Set-Cookie', + `__Host-oidc_session=${encodeURIComponent(sessionData)}; HttpOnly; Secure; SameSite=Lax; Max-Age=300; Path=/` + ); const baseUrl = getBaseUrl(req); const redirectUri = `${baseUrl}/auth/callback`; const parameters: Record = { redirect_uri: redirectUri, - scope: ['openid', 'profile', 'email', config.oidc.groupsClaim, ...config.oidc.additionalScopes].filter(Boolean).join(' '), + scope: [ + 'openid', + 'profile', + 'email', + config.oidc.groupsClaim, + ...config.oidc.additionalScopes + ] + .filter(Boolean) + .join(' '), code_challenge, code_challenge_method: 'S256', state, - nonce, + nonce }; const redirectTo = client.buildAuthorizationUrl(as, parameters); @@ -118,17 +155,20 @@ export const oidcLogin = async (req: http.IncomingMessage, res: http.ServerRespo } }; -export const oidcCallback = async (req: http.IncomingMessage, res: http.ServerResponse) => { +export const oidcCallback = async ( + req: http.IncomingMessage, + res: http.ServerResponse +) => { if (config.oidc.oidcEnabled === false) { return res.writeHead(404); } try { const as = await getOidcConfig(); - + const rawCookies = req.headers.cookie || ''; const cookieMap = Object.fromEntries( - rawCookies.split('; ').map(v => { + rawCookies.split('; ').map((v) => { const idx = v.indexOf('='); return [v.slice(0, idx), v.slice(idx + 1)]; }) @@ -136,33 +176,41 @@ export const oidcCallback = async (req: http.IncomingMessage, res: http.ServerRe const sessionCookie = cookieMap['__Host-oidc_session']; if (!sessionCookie) throw new Error('Missing OIDC session cookie'); - + let sessionData; try { - sessionData = JSON.parse(decodeURIComponent(sessionCookie)); - } catch(e) { - throw new Error('Invalid session cookie format'); + sessionData = JSON.parse(decodeURIComponent(sessionCookie)); + } catch (e) { + throw new Error('Invalid session cookie format'); } - const { code_verifier, state: expectedState, nonce: expectedNonce, redirectOrigin } = sessionData; - - if (!redirectOrigin || !config.oidc.allowedOrigins.includes(redirectOrigin)) { + const { + code_verifier, + state: expectedState, + nonce: expectedNonce, + redirectOrigin + } = sessionData; + + if ( + !redirectOrigin || + !config.oidc.allowedOrigins.includes(redirectOrigin) + ) { throw new Error('Invalid redirect origin in session'); } - + const baseUrl = getBaseUrl(req); const safeUrl = (req.url || '').startsWith('/') ? req.url : '/'; const url = new URL(safeUrl || '', baseUrl); const params = Object.fromEntries(url.searchParams); - + if (!params.state || !safeCompare(params.state, expectedState)) { throw new Error('CSRF token mismatch'); } - + const tokenResponse = await client.authorizationCodeGrant(as, url, { pkceCodeVerifier: code_verifier, expectedState, - expectedNonce, + expectedNonce }); const idTokenClaims = tokenResponse.claims(); @@ -171,14 +219,20 @@ export const oidcCallback = async (req: http.IncomingMessage, res: http.ServerRe } let mergedClaims: Record = { ...idTokenClaims }; - const needsUserInfo = !idTokenClaims.email || + const needsUserInfo = + !idTokenClaims.email || !idTokenClaims[config.oidc.groupsClaim] || !idTokenClaims[config.oidc.usernameClaim] || - (!!config.oidc.displayNameClaim && !idTokenClaims[config.oidc.displayNameClaim]); + (!!config.oidc.displayNameClaim && + !idTokenClaims[config.oidc.displayNameClaim]); if (needsUserInfo) { try { - const userInfo = await client.fetchUserInfo(as, tokenResponse.access_token, idTokenClaims.sub); + const userInfo = await client.fetchUserInfo( + as, + tokenResponse.access_token, + idTokenClaims.sub + ); // ID token claims take precedence over UserInfo (ID token is signed) mergedClaims = { ...userInfo, ...idTokenClaims }; } catch (err) { @@ -187,30 +241,37 @@ export const oidcCallback = async (req: http.IncomingMessage, res: http.ServerRe } if (config.oidc.requiredGroups.length > 0) { - const userGroups = ((mergedClaims[config.oidc.groupsClaim] as string[]) || []).map(g => g.toLowerCase()); - const hasRequired = config.oidc.requiredGroups.some(r => userGroups.includes(r.toLowerCase())); + const userGroups = ( + (mergedClaims[config.oidc.groupsClaim] as string[]) || [] + ).map((g) => g.toLowerCase()); + const hasRequired = config.oidc.requiredGroups.some((r) => + userGroups.includes(r.toLowerCase()) + ); if (!hasRequired) { return res.writeHead(403).end('Forbidden'); } } - const identity = ((mergedClaims.email as string) || idTokenClaims.sub) as string; + const identity = ((mergedClaims.email as string) || + idTokenClaims.sub) as string; const user = await syncUserWithDatabase(identity, mergedClaims); - const appToken = jwt.sign({ userId: user.id }, await getServerToken(), { expiresIn: '1d' }); - + const appToken = jwt.sign({ userId: user.id }, await getServerToken(), { + expiresIn: '1d' + }); + const target = new URL(redirectOrigin); - + // Set success flag so frontend knows to initiate connection target.searchParams.set('oidc_status', 'success'); // Set App Token as HttpOnly Cookie AND Clear OIDC Session in one header const authCookie = `sharkord_token=${appToken}; Path=/; SameSite=Lax; Secure; Max-Age=86400`; - const clearSession = '__Host-oidc_session=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax'; + const clearSession = + '__Host-oidc_session=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax'; res.setHeader('Set-Cookie', [authCookie, clearSession]); res.writeHead(302, { Location: target.toString() }).end(); - } catch (error) { console.error('OIDC Callback Error:', error); res.writeHead(401).end('Authentication Failed'); @@ -222,40 +283,53 @@ function resolveDisplayName(claims: Record): string { const val = claims[config.oidc.displayNameClaim] as string | undefined; if (val) return val; } - return (claims[config.oidc.usernameClaim] as string) ?? (claims.sub as string); + return ( + (claims[config.oidc.usernameClaim] as string) ?? (claims.sub as string) + ); } -async function syncUserWithDatabase(identity: string, claims: Record) { +async function syncUserWithDatabase( + identity: string, + claims: Record +) { const sub = claims.sub as string; // Look up by stable IdP subject first, fall back to identity for users // created before oidcSub was introduced. - let user = await getUserByOidcSub(sub) ?? await getUserByIdentity(identity); + let user = + (await getUserByOidcSub(sub)) ?? (await getUserByIdentity(identity)); if (!user) { const defaultRole = await getDefaultRole(); if (!defaultRole) throw new Error('Default role missing'); - const randomPassword = createHash('sha256').update(randomBytes(32).toString('hex')).digest('hex'); - - const [insertedUser] = await db.insert(users).values({ - identity, - password: randomPassword, - name: resolveDisplayName(claims), - oidcSub: sub, - createdAt: Date.now(), - lastLoginAt: Date.now(), - banned: false, - }).returning(); + const randomPassword = createHash('sha256') + .update(randomBytes(32).toString('hex')) + .digest('hex'); + + const [insertedUser] = await db + .insert(users) + .values({ + identity, + password: randomPassword, + name: resolveDisplayName(claims), + oidcSub: sub, + createdAt: Date.now(), + lastLoginAt: Date.now(), + banned: false + }) + .returning(); if (!insertedUser) { - throw new Error('Failed to create user: Database insert returned no data.'); + throw new Error( + 'Failed to create user: Database insert returned no data.' + ); } await db.insert(userRoles).values({ roleId: defaultRole.id, userId: insertedUser.id, - createdAt: Date.now(), + createdAt: Date.now() }); publishUser(insertedUser.id, 'create'); @@ -282,7 +356,10 @@ async function syncUserWithDatabase(identity: string, claims: Record 0) { - await db.update(users).set({ ...updates, updatedAt: Date.now() }).where(eq(users.id, user.id)); + await db + .update(users) + .set({ ...updates, updatedAt: Date.now() }) + .where(eq(users.id, user.id)); publishUser(user.id, 'update'); user = await getUserByOidcSub(sub); } @@ -298,55 +375,76 @@ async function applyRoleMappings(userId: number, claims: any) { const rolesMapping = config.oidc.rolesMapping; if (Object.keys(rolesMapping).length === 0) return; - const oidcGroups = ((claims[config.oidc.groupsClaim] as string[]) || []).map((g: string) => g.toLowerCase()); + const oidcGroups = ((claims[config.oidc.groupsClaim] as string[]) || []).map( + (g: string) => g.toLowerCase() + ); const allDbRoles = await getRoles(); const targetRoleIds: number[] = []; for (const [oidcRole, localRole] of Object.entries(rolesMapping)) { if (oidcGroups.includes(oidcRole.toLowerCase())) { const dbRole = allDbRoles.find( - (r: { id: number; name: string; }) => r.name.toLowerCase() === localRole.toLowerCase() + (r: { id: number; name: string }) => + r.name.toLowerCase() === localRole.toLowerCase() ); if (dbRole) targetRoleIds.push(dbRole.id); } } const uniqueTargetRoleIds = [...new Set(targetRoleIds)]; - const userCurrentRoles = await db.query.userRoles.findMany({ where: eq(userRoles.userId, userId) }); + const userCurrentRoles = await db.query.userRoles.findMany({ + where: eq(userRoles.userId, userId) + }); if (config.oidc.enforceOidcRoles) { - const mappedRoleNames = Object.values(rolesMapping).map(name => name.toLowerCase()); - const mappedDbRoles = allDbRoles.filter( - (r: { id: number; name: string; }) => mappedRoleNames.includes(r.name.toLowerCase()) + const mappedRoleNames = Object.values(rolesMapping).map((name) => + name.toLowerCase() ); - const mappedDbRoleIds = mappedDbRoles.map(r => r.id); - const userCurrentRoleIds = userCurrentRoles.map(r => r.roleId); + const mappedDbRoles = allDbRoles.filter((r: { id: number; name: string }) => + mappedRoleNames.includes(r.name.toLowerCase()) + ); + const mappedDbRoleIds = mappedDbRoles.map((r) => r.id); + const userCurrentRoleIds = userCurrentRoles.map((r) => r.roleId); - const rolesToRemove = userCurrentRoleIds.filter(id => mappedDbRoleIds.includes(id) && !uniqueTargetRoleIds.includes(id)); + const rolesToRemove = userCurrentRoleIds.filter( + (id) => mappedDbRoleIds.includes(id) && !uniqueTargetRoleIds.includes(id) + ); if (rolesToRemove.length > 0) { - await db.delete(userRoles).where(and( - eq(userRoles.userId, userId), - inArray(userRoles.roleId, rolesToRemove) - )); + await db + .delete(userRoles) + .where( + and( + eq(userRoles.userId, userId), + inArray(userRoles.roleId, rolesToRemove) + ) + ); } } else { const oidcManagedRoleIds = userCurrentRoles .filter((r) => r.addedBy === 'oidc') .map((r) => r.roleId); - const rolesToRemove = oidcManagedRoleIds.filter((id) => !uniqueTargetRoleIds.includes(id)); + const rolesToRemove = oidcManagedRoleIds.filter( + (id) => !uniqueTargetRoleIds.includes(id) + ); if (rolesToRemove.length > 0) { - await db.delete(userRoles).where(and( - eq(userRoles.userId, userId), - inArray(userRoles.roleId, rolesToRemove), - eq(userRoles.addedBy, 'oidc') - )); + await db + .delete(userRoles) + .where( + and( + eq(userRoles.userId, userId), + inArray(userRoles.roleId, rolesToRemove), + eq(userRoles.addedBy, 'oidc') + ) + ); } } - const rolesToAdd = uniqueTargetRoleIds.filter((id) => !userCurrentRoles.some((r) => r.roleId === id)); + const rolesToAdd = uniqueTargetRoleIds.filter( + (id) => !userCurrentRoles.some((r) => r.roleId === id) + ); if (rolesToAdd.length > 0) { await db.insert(userRoles).values( - rolesToAdd.map(roleId => ({ + rolesToAdd.map((roleId) => ({ userId, roleId, createdAt: Date.now(), @@ -354,4 +452,4 @@ async function applyRoleMappings(userId: number, claims: any) { })) ); } -} \ No newline at end of file +}