Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 41 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
7 changes: 7 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
services:
app:
build: .
pull_policy: build
restart: unless-stopped
environment:
BUN_ENV: production
Expand All @@ -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:
Expand Down
16 changes: 7 additions & 9 deletions src/client/components/user-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,13 @@ export function UserDropdown() {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{user.role === "admin" && (
<DropdownMenuItem
onClick={() => navigate({ to: "/settings" })}
className="cursor-pointer"
>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate({ to: "/settings" })}
className="cursor-pointer"
>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSignOut} className="cursor-pointer">
<LogOut className="mr-2 h-4 w-4" />
<span>Disconnect</span>
Expand Down
4 changes: 2 additions & 2 deletions src/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="grid min-h-svh lg:grid-cols-2">
Expand All @@ -58,7 +58,7 @@ function LoginPage() {
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-xs">
<LoginForm redirectTo={redirectTo} />
<LoginForm redirectTo={redirectTo} oidc={oidc} />
</div>
</div>
</div>
Expand Down
38 changes: 37 additions & 1 deletion src/routes/login/-components/login-form.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand All @@ -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<string | null>(null);
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
const [isSsoLoading, setIsSsoLoading] = useState(false);
const emailId = useId();
const passwordId = useId();

Expand Down Expand Up @@ -151,6 +159,34 @@ export function LoginForm({ className, redirectTo, ...props }: LoginFormProps) {
)}
</form.Subscribe>
</Field>

{oidc && (
<>
<div className="relative flex items-center">
<div className="flex-1 border-t" />
<span className="text-muted-foreground px-3 text-xs">or</span>
<div className="flex-1 border-t" />
</div>
<Button
type="button"
variant="outline"
disabled={isSsoLoading}
onClick={async () => {
setIsSsoLoading(true);
await authClient.signIn.oauth2({
providerId: "oidc",
callbackURL: getLoginRedirectHref({ redirectTo }),
});
setIsSsoLoading(false);
}}
>
{isSsoLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Sign in with {oidc.displayName}
</Button>
</>
)}
</FieldGroup>
</form>
);
Expand Down
11 changes: 11 additions & 0 deletions src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/server/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof envSchema>;
Expand Down
5 changes: 4 additions & 1 deletion src/server/infrastructure/auth/client.ts
Original file line number Diff line number Diff line change
@@ -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()],
});
27 changes: 26 additions & 1 deletion src/server/infrastructure/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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" };
},
},
],
}),
]
: []),
],
});
8 changes: 7 additions & 1 deletion src/server/infrastructure/functions/public-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PublicConfig> => {
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,
};
},
);
5 changes: 5 additions & 0 deletions src/server/infrastructure/persistence/drizzle/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down