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/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`.
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 (