From 2af362e5c17f537d5e83e3266981a5852a000fcc Mon Sep 17 00:00:00 2001 From: sjenkins Date: Sat, 28 Mar 2026 17:36:16 -0500 Subject: [PATCH 1/2] added basic oidc support --- .env.example | 12 ++++++ compose.yml | 7 ++++ src/client/components/user-dropdown.tsx | 16 ++++---- src/routes/login.tsx | 4 +- src/routes/login/-components/login-form.tsx | 38 ++++++++++++++++++- src/server/config.ts | 11 ++++++ src/server/env.ts | 6 +++ src/server/infrastructure/auth/client.ts | 5 ++- src/server/infrastructure/auth/index.ts | 27 ++++++++++++- .../infrastructure/functions/public-config.ts | 8 +++- .../persistence/drizzle/schema.ts | 5 +++ 11 files changed, 124 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index bda01c3..3c63b0f 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,18 @@ SITE_URL="http://localhost:3000" # SearXNG configuration SEARXNG_SECRET="change-me-to-a-random-secret" +# OIDC / SSO configuration (optional) +# When all three are set, a "Sign in with SSO" button appears on the login page. +# The redirect/callback URL to register with your provider is: +# {SITE_URL}/api/auth/oauth2/callback/oidc +# OIDC_ISSUER_URL="https://sso.example.com/realms/myrealm" +# OIDC_CLIENT_ID="voy" +# OIDC_CLIENT_SECRET="change-me" +# OIDC_DISPLAY_NAME="SSO" +# To grant admin role based on a group/claim from the IdP, set both: +# OIDC_ADMIN_CLAIM="groups" # claim name in the OIDC profile (e.g. groups, roles) +# OIDC_ADMIN_VALUE="voy-admins" # the value (or one of the array values) that means admin + # Logging configuration # Valid values: trace, debug, info, warn, error, fatal, silent LOG_LEVEL="info" diff --git a/compose.yml b/compose.yml index 7824720..ac2759f 100644 --- a/compose.yml +++ b/compose.yml @@ -1,6 +1,7 @@ services: app: build: . + pull_policy: build restart: unless-stopped environment: BUN_ENV: production @@ -10,6 +11,12 @@ services: BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} INSTANCE_NAME: ${INSTANCE_NAME} SITE_URL: ${SITE_URL} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} + OIDC_DISPLAY_NAME: ${OIDC_DISPLAY_NAME:-} + OIDC_ADMIN_CLAIM: ${OIDC_ADMIN_CLAIM:-} + OIDC_ADMIN_VALUE: ${OIDC_ADMIN_VALUE:-} volumes: - app-data:/data depends_on: diff --git a/src/client/components/user-dropdown.tsx b/src/client/components/user-dropdown.tsx index d42a9fa..77a69c9 100644 --- a/src/client/components/user-dropdown.tsx +++ b/src/client/components/user-dropdown.tsx @@ -72,15 +72,13 @@ export function UserDropdown() { - {user.role === "admin" && ( - navigate({ to: "/settings" })} - className="cursor-pointer" - > - - Settings - - )} + navigate({ to: "/settings" })} + className="cursor-pointer" + > + + Settings + Disconnect diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 4b06985..e03b833 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -43,7 +43,7 @@ export const Route = createFileRoute("/login")({ function LoginPage() { const { redirect: redirectTo } = Route.useSearch(); - const { instanceName } = rootRoute.useLoaderData(); + const { instanceName, oidc } = rootRoute.useLoaderData(); return (
@@ -58,7 +58,7 @@ function LoginPage() {
- +
diff --git a/src/routes/login/-components/login-form.tsx b/src/routes/login/-components/login-form.tsx index 022b314..1622f9c 100644 --- a/src/routes/login/-components/login-form.tsx +++ b/src/routes/login/-components/login-form.tsx @@ -1,6 +1,7 @@ import { useForm } from "@tanstack/react-form"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; +import { Loader2 } from "lucide-react"; import { useId, useState } from "react"; import { z } from "zod"; import { Button } from "@/client/components/ui/button"; @@ -22,6 +23,7 @@ const loginSchema = z.object({ interface LoginFormProps extends React.ComponentProps<"form"> { redirectTo?: string; + oidc?: { displayName: string } | null; } function getLoginRedirectHref({ redirectTo }: { redirectTo?: string }): string { @@ -36,11 +38,17 @@ function getLoginRedirectHref({ redirectTo }: { redirectTo?: string }): string { return redirectTo; } -export function LoginForm({ className, redirectTo, ...props }: LoginFormProps) { +export function LoginForm({ + className, + redirectTo, + oidc, + ...props +}: LoginFormProps) { const navigate = useNavigate(); const queryClient = useQueryClient(); const [error, setError] = useState(null); const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); + const [isSsoLoading, setIsSsoLoading] = useState(false); const emailId = useId(); const passwordId = useId(); @@ -151,6 +159,34 @@ export function LoginForm({ className, redirectTo, ...props }: LoginFormProps) { )} + + {oidc && ( + <> +
+
+ or +
+
+ + + )} ); diff --git a/src/server/config.ts b/src/server/config.ts index 6e77f7a..a843fa2 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -19,6 +19,17 @@ export const config = { name: env.INSTANCE_NAME ?? "Voy", url: env.SITE_URL ?? "http://localhost:3000", }, + oidc: { + enabled: Boolean( + env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER_URL, + ), + clientId: env.OIDC_CLIENT_ID, + clientSecret: env.OIDC_CLIENT_SECRET, + issuerUrl: env.OIDC_ISSUER_URL, + displayName: env.OIDC_DISPLAY_NAME ?? "SSO", + adminClaim: env.OIDC_ADMIN_CLAIM, + adminValue: env.OIDC_ADMIN_VALUE, + }, logging: { level: env.LOG_LEVEL ?? (isDevelopment ? "debug" : "info"), pretty: env.LOG_PRETTY ? env.LOG_PRETTY === "true" : isDevelopment, diff --git a/src/server/env.ts b/src/server/env.ts index 6313e26..ed97e57 100644 --- a/src/server/env.ts +++ b/src/server/env.ts @@ -11,6 +11,12 @@ const envSchema = z.object({ .optional(), LOG_PRETTY: z.enum(["true", "false"]).optional(), LOG_REDACT_PATHS: z.string().optional(), + OIDC_CLIENT_ID: z.string().optional(), + OIDC_CLIENT_SECRET: z.string().optional(), + OIDC_ISSUER_URL: z.url().optional(), + OIDC_DISPLAY_NAME: z.string().optional(), + OIDC_ADMIN_CLAIM: z.string().optional(), + OIDC_ADMIN_VALUE: z.string().optional(), }); export type Env = z.infer; diff --git a/src/server/infrastructure/auth/client.ts b/src/server/infrastructure/auth/client.ts index f1012dd..c6587dc 100644 --- a/src/server/infrastructure/auth/client.ts +++ b/src/server/infrastructure/auth/client.ts @@ -1,3 +1,6 @@ +import { genericOAuthClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; -export const authClient = createAuthClient(); +export const authClient = createAuthClient({ + plugins: [genericOAuthClient()], +}); diff --git a/src/server/infrastructure/auth/index.ts b/src/server/infrastructure/auth/index.ts index 582933d..785b77c 100644 --- a/src/server/infrastructure/auth/index.ts +++ b/src/server/infrastructure/auth/index.ts @@ -1,6 +1,6 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { admin } from "better-auth/plugins"; +import { admin, genericOAuth } from "better-auth/plugins"; import { tanstackStartCookies } from "better-auth/tanstack-start"; import { config } from "@/server/config"; import { db } from "@/server/infrastructure/persistence/drizzle/connection"; @@ -23,5 +23,30 @@ export const auth = betterAuth({ defaultRole: "user", adminRoles: ["admin"], }), + ...(config.oidc.enabled + ? [ + genericOAuth({ + config: [ + { + providerId: "oidc", + clientId: config.oidc.clientId as string, + clientSecret: config.oidc.clientSecret as string, + discoveryUrl: `${config.oidc.issuerUrl}/.well-known/openid-configuration`, + scopes: ["openid", "email", "profile"], + overrideUserInfo: true, + mapProfileToUser: (profile) => { + if (!config.oidc.adminClaim || !config.oidc.adminValue) + return {}; + const claim = profile[config.oidc.adminClaim]; + const isAdmin = Array.isArray(claim) + ? claim.includes(config.oidc.adminValue) + : claim === config.oidc.adminValue; + return { role: isAdmin ? "admin" : "user" }; + }, + }, + ], + }), + ] + : []), ], }); diff --git a/src/server/infrastructure/functions/public-config.ts b/src/server/infrastructure/functions/public-config.ts index f9adf48..8b06c19 100644 --- a/src/server/infrastructure/functions/public-config.ts +++ b/src/server/infrastructure/functions/public-config.ts @@ -2,11 +2,17 @@ import { createServerFn } from "@tanstack/react-start"; export type PublicConfig = { instanceName: string; + oidc: { displayName: string } | null; }; export const getPublicConfig = createServerFn({ method: "GET" }).handler( async (): Promise => { const { config } = await import("@/server/config"); - return { instanceName: config.instance.name }; + return { + instanceName: config.instance.name, + oidc: config.oidc.enabled + ? { displayName: config.oidc.displayName } + : null, + }; }, ); diff --git a/src/server/infrastructure/persistence/drizzle/schema.ts b/src/server/infrastructure/persistence/drizzle/schema.ts index de02e5f..82e5846 100644 --- a/src/server/infrastructure/persistence/drizzle/schema.ts +++ b/src/server/infrastructure/persistence/drizzle/schema.ts @@ -60,6 +60,11 @@ export const account = sqliteTable("account", { refreshToken: text("refreshToken"), idToken: text("idToken"), expiresAt: integer("expiresAt", { mode: "timestamp" }), + accessTokenExpiresAt: integer("accessTokenExpiresAt", { mode: "timestamp" }), + refreshTokenExpiresAt: integer("refreshTokenExpiresAt", { + mode: "timestamp", + }), + scope: text("scope"), password: text("password"), createdAt: integer("createdAt", { mode: "timestamp" }).notNull(), updatedAt: integer("updatedAt", { mode: "timestamp" }).notNull(), From e45754406327c1b3bf6d9cda435c5af3a91280a3 Mon Sep 17 00:00:00 2001 From: sjenkins Date: Sat, 28 Mar 2026 17:44:39 -0500 Subject: [PATCH 2/2] updated readme with oidc support info --- README.md | 44 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 727f557..96ee76f 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ your own server — no tracking, no data sent to third parties. - **Web, Image & File search** — switch between result categories with tab-based filters - **Autocomplete** — real-time search suggestions as you type -- **Authentication** — email/password login with admin and user roles +- **Authentication** — email/password login with admin and user roles, optional SSO via any OIDC provider - **Per-user settings** — theme (light/dark/system), safe search level, link behavior, AI toggle - **OpenSearch support** — add Voy as a search provider in your browser @@ -200,10 +200,48 @@ through configuring safe search and creating your admin account. | `LOG_LEVEL` | No | `info` | Server log verbosity | | `LOG_PRETTY` | No | `false` | Pretty logs for local debugging | | `LOG_REDACT_PATHS` | No | — | Comma-separated redact paths override | +| `OIDC_ISSUER_URL` | No | — | Issuer URL of your OIDC provider | +| `OIDC_CLIENT_ID` | No | — | OAuth2 client ID | +| `OIDC_CLIENT_SECRET` | No | — | OAuth2 client secret | +| `OIDC_DISPLAY_NAME` | No | `SSO` | Label on the login button | +| `OIDC_ADMIN_CLAIM` | No | — | Profile claim used to grant admin role (e.g. `groups`) | +| `OIDC_ADMIN_VALUE` | No | — | Value within that claim that maps to admin (e.g. `voy-admins`) | -### Logging +### SSO / OIDC -- The server emits structured JSON logs suitable for container logging backends. +Voy supports single sign-on via any OIDC-compliant provider (Keycloak, Authentik, Okta, Auth0, etc.). When configured, a "Sign in with SSO" button appears on the login page alongside the existing email/password form. + +**1. Register a client with your provider** + +Set the redirect/callback URL to: + +``` +{SITE_URL}/api/auth/oauth2/callback/oidc +``` + +**2. Add the environment variables** + +```env +OIDC_ISSUER_URL=https://sso.example.com/realms/myrealm +OIDC_CLIENT_ID=voy +OIDC_CLIENT_SECRET=your-client-secret +OIDC_DISPLAY_NAME=SSO # optional, defaults to "SSO" +``` + +**3. Admin role mapping (optional)** + +To automatically grant the admin role based on group membership from the IdP, set both: + +```env +OIDC_ADMIN_CLAIM=groups # the claim name in the OIDC profile +OIDC_ADMIN_VALUE=voy-admins # the value that indicates admin +``` + +The claim is re-evaluated on every login — removing a user from the group in the IdP will downgrade their role on their next sign-in. Common claim names are `groups` (Keycloak, Authentik) and `roles` (some Okta/Auth0 setups). Your provider may require requesting an additional scope to include group claims in the token. + +If `OIDC_ADMIN_CLAIM` / `OIDC_ADMIN_VALUE` are not set, SSO users are assigned the default `user` role. Admin access can still be granted manually via the admin panel. + +### Logging- The server emits structured JSON logs suitable for container logging backends. - Each request is correlated with `x-request-id` and the header is returned in responses. - Sensitive fields are redacted by default (auth headers, cookies, tokens, passwords, API keys). - Recommended production settings: `LOG_LEVEL=info`, `LOG_PRETTY=false`.