Add optional OIDC authentication via Better Auth genericOAuth plugin

Support self-hosted OIDC providers (Authentik, Authelia, Keycloak, etc.)
configured entirely via environment variables. Uses Better Auth's
hooks.before to gate email/password sign-up at the endpoint level, and
disables emailAndPassword entirely when DISABLE_PASSWORD_LOGIN is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 13:39:15 -05:00
co-authored by Claude Opus 4.6
parent e19ac78b39
commit fb785645f8
9 changed files with 320 additions and 135 deletions
+26 -12
View File
@@ -1,20 +1,34 @@
# ─── Database ───────────────────────────────────────────────────────────
# SQLite database URL (Docker: file:/data/sqlite.db, local dev: file:sqlite.db)
DATABASE_URL=file:./data/sqlite.db
# DATABASE_URL=file:/data/sqlite.db
# TMDB API Read Access Token — get one at https://www.themoviedb.org/settings/api
# ─── TMDB (required) ───────────────────────────────────────────────────────
# API Read Access Token — get one at https://www.themoviedb.org/settings/api
TMDB_API_READ_ACCESS_TOKEN=your_tmdb_api_read_access_token_here
# Random secret for session encryption (min 32 chars)
BETTER_AUTH_SECRET=your_secret_here
# Public URL of your instance
BETTER_AUTH_URL=http://localhost:3000
# Optional: override TMDB base URLs (advanced)
# TMDB_API_BASE_URL=https://api.themoviedb.org/3
# TMDB_IMAGE_BASE_URL=https://image.tmdb.org/t/p
# Image caching — downloads TMDB images to local disk for faster serving
# Set to "false" to disable and use TMDB CDN directly (default: enabled)
IMAGE_CACHE_DIR=./data/images
# IMAGE_CACHE_ENABLED=false
# ─── Auth (required) ───────────────────────────────────────────────────────
# Random secret for session encryption (min 32 chars)
# Generate one with `npx @better-auth/cli secret` or `openssl rand -base64 32`
BETTER_AUTH_SECRET=your_secret_here
# Public URL of your instance, especially important if reverse proxy is used
BETTER_AUTH_URL=http://localhost:3000
# ─── OIDC Authentication (optional) ────────────────────────────────────
# OIDC is enabled when OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_ISSUER_URL are all set.
# Callback URL to configure in your IdP: ${BETTER_AUTH_URL}/api/auth/oauth2/callback/oidc
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
# OIDC_ISSUER_URL= # e.g. https://authentik.example.com/application/o/sofa
# OIDC_PROVIDER_NAME=SSO # Display name on login button (default: "SSO")
# OIDC_AUTO_REGISTER=true # Auto-create users on first OIDC login (default: true)
# DISABLE_PASSWORD_LOGIN=false # Set to "true" to hide email/password form when OIDC is configured
# ─── Image Caching ─────────────────────────────────────────────────────
# Downloads TMDB images to local disk for faster serving (default: /data/images)
# IMAGE_CACHE_DIR=/data/images
# Set IMAGE_CACHE_ENABLED to "false" to use TMDB CDN directly (default: enabled)
# IMAGE_CACHE_ENABLED=true
+15 -1
View File
@@ -1,9 +1,23 @@
import { AuthForm } from "@/components/auth-form";
import {
getOidcProviderName,
isOidcConfigured,
isPasswordLoginDisabled,
} from "@/lib/config";
export default function LoginPage() {
const oidcEnabled = isOidcConfigured();
return (
<div className="flex min-h-[80vh] items-center justify-center px-4">
<AuthForm mode="login" />
<AuthForm
mode="login"
authConfig={{
oidcEnabled,
oidcProviderName: oidcEnabled ? getOidcProviderName() : null,
passwordLoginDisabled: isPasswordLoginDisabled(),
}}
/>
</div>
);
}
+15 -2
View File
@@ -4,17 +4,30 @@ import { IconLock } from "@tabler/icons-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import type { AuthConfig } from "@/components/auth-form";
import { AuthForm } from "@/components/auth-form";
export default function RegisterPage() {
const [registrationOpen, setRegistrationOpen] = useState<boolean | null>(
null,
);
const [authConfig, setAuthConfig] = useState<AuthConfig>({
oidcEnabled: false,
oidcProviderName: null,
passwordLoginDisabled: false,
});
useEffect(() => {
fetch("/api/registration/status")
.then((res) => res.json())
.then((data) => setRegistrationOpen(data.registrationOpen))
.then((data) => {
setRegistrationOpen(data.registrationOpen);
setAuthConfig({
oidcEnabled: data.oidcEnabled ?? false,
oidcProviderName: data.oidcProviderName ?? null,
passwordLoginDisabled: data.passwordLoginDisabled ?? false,
});
})
.catch(() => setRegistrationOpen(false));
}, []);
@@ -63,7 +76,7 @@ export default function RegisterPage() {
return (
<div className="flex min-h-[80vh] items-center justify-center px-4">
<AuthForm mode="register" />
<AuthForm mode="register" authConfig={authConfig} />
</div>
);
}
+11 -2
View File
@@ -1,7 +1,16 @@
import { NextResponse } from "next/server";
import {
getOidcProviderName,
isOidcConfigured,
isPasswordLoginDisabled,
} from "@/lib/config";
import { isRegistrationOpen } from "@/lib/services/settings";
export async function GET() {
const registrationOpen = await isRegistrationOpen();
return NextResponse.json({ registrationOpen });
return NextResponse.json({
registrationOpen: await isRegistrationOpen(),
oidcEnabled: isOidcConfigured(),
oidcProviderName: isOidcConfigured() ? getOidcProviderName() : null,
passwordLoginDisabled: isPasswordLoginDisabled(),
});
}
+177 -106
View File
@@ -1,11 +1,18 @@
"use client";
import { IconKey } from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { SofaLogo } from "@/components/sofa-logo";
import { signIn, signUp } from "@/lib/auth/client";
import { authClient, signIn, signUp } from "@/lib/auth/client";
export interface AuthConfig {
oidcEnabled: boolean;
oidcProviderName: string | null;
passwordLoginDisabled: boolean;
}
const fieldVariants = {
hidden: { opacity: 0, y: 10 },
@@ -16,15 +23,24 @@ const fieldVariants = {
},
};
export function AuthForm({ mode }: { mode: "login" | "register" }) {
export function AuthForm({
mode,
authConfig,
}: {
mode: "login" | "register";
authConfig?: AuthConfig;
}) {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const isRegister = mode === "register";
const showOidc = authConfig?.oidcEnabled ?? false;
const showPasswordForm = !(authConfig?.passwordLoginDisabled ?? false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -54,6 +70,20 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
}
}
async function handleOidcLogin() {
setError("");
setOidcLoading(true);
try {
await authClient.signIn.oauth2({
providerId: "oidc",
callbackURL: "/dashboard",
});
} catch {
setError("Failed to start SSO login");
setOidcLoading(false);
}
}
return (
<div className="relative mx-auto w-full max-w-sm">
{/* Subtle glow behind card */}
@@ -77,120 +107,161 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
</p>
</div>
<motion.form
onSubmit={handleSubmit}
className="space-y-4"
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.08 } },
}}
>
{isRegister && (
<motion.div variants={fieldVariants} className="space-y-1.5">
<label
htmlFor="name"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
>
Name
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder="Your name"
/>
</motion.div>
)}
<motion.div variants={fieldVariants} className="space-y-1.5">
<label
htmlFor="email"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
{showOidc && (
<motion.div
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.08 } },
}}
>
<motion.button
type="button"
onClick={handleOidcLogin}
disabled={oidcLoading}
variants={fieldVariants}
whileTap={{ scale: 0.98 }}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-border/50 bg-background/50 text-sm font-medium transition-all hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder="wwhite@graymatter.biz"
/>
<IconKey size={16} />
{oidcLoading
? "Redirecting..."
: `Sign in with ${authConfig?.oidcProviderName || "SSO"}`}
</motion.button>
</motion.div>
)}
<motion.div variants={fieldVariants} className="space-y-1.5">
<label
htmlFor="password"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
>
Password
</label>
<input
id="password"
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder="Min 8 characters"
/>
</motion.div>
{showOidc && showPasswordForm && (
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-border/50" />
<span className="text-xs text-muted-foreground">or</span>
<div className="h-px flex-1 bg-border/50" />
</div>
)}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
{showPasswordForm && (
<motion.form
onSubmit={handleSubmit}
className="space-y-4"
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.08 } },
}}
>
{isRegister && (
<motion.div variants={fieldVariants} className="space-y-1.5">
<label
htmlFor="name"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
>
Name
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder="Your name"
/>
</motion.div>
)}
</AnimatePresence>
<motion.button
type="submit"
disabled={loading}
variants={fieldVariants}
whileTap={{ scale: 0.98 }}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-primary font-medium text-primary-foreground transition-all hover:shadow-lg hover:shadow-primary/20 disabled:pointer-events-none disabled:opacity-50"
>
{loading ? "Loading..." : isRegister ? "Create account" : "Sign in"}
</motion.button>
</motion.form>
<motion.div variants={fieldVariants} className="space-y-1.5">
<label
htmlFor="email"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
>
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder="wwhite@graymatter.biz"
/>
</motion.div>
<p className="text-center text-sm text-muted-foreground">
{isRegister ? (
<>
Already have an account?{" "}
<Link
href="/login"
className="font-medium text-primary transition-colors hover:text-primary/80"
<motion.div variants={fieldVariants} className="space-y-1.5">
<label
htmlFor="password"
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
>
Sign in
</Link>
</>
) : (
<>
Don&apos;t have an account?{" "}
<Link
href="/register"
className="font-medium text-primary transition-colors hover:text-primary/80"
>
Register
</Link>
</>
Password
</label>
<input
id="password"
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex h-11 w-full rounded-lg border border-border/50 bg-background/50 px-4 text-sm transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder="Min 8 characters"
/>
</motion.div>
<motion.button
type="submit"
disabled={loading}
variants={fieldVariants}
whileTap={{ scale: 0.98 }}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-primary font-medium text-primary-foreground transition-all hover:shadow-lg hover:shadow-primary/20 disabled:pointer-events-none disabled:opacity-50"
>
{loading
? "Loading..."
: isRegister
? "Create account"
: "Sign in"}
</motion.button>
</motion.form>
)}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</motion.div>
)}
</p>
</AnimatePresence>
{showPasswordForm && (
<p className="text-center text-sm text-muted-foreground">
{isRegister ? (
<>
Already have an account?{" "}
<Link
href="/login"
className="font-medium text-primary transition-colors hover:text-primary/80"
>
Sign in
</Link>
</>
) : (
<>
Don&apos;t have an account?{" "}
<Link
href="/register"
className="font-medium text-primary transition-colors hover:text-primary/80"
>
Register
</Link>
</>
)}
</p>
)}
</motion.div>
</div>
);
+2 -2
View File
@@ -1,10 +1,10 @@
"use client";
import { adminClient } from "better-auth/client/plugins";
import { adminClient, genericOAuthClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
plugins: [adminClient()],
plugins: [adminClient(), genericOAuthClient()],
});
export const { signIn, signUp, signOut, useSession } = authClient;
+53 -10
View File
@@ -1,8 +1,13 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { APIError } from "better-auth/api";
import { admin } from "better-auth/plugins";
import { APIError, createAuthMiddleware } from "better-auth/api";
import { admin, genericOAuth } from "better-auth/plugins";
import { v4 as uuid } from "uuid";
import {
isOidcAutoRegisterEnabled,
isOidcConfigured,
isPasswordLoginDisabled,
} from "@/lib/config";
import { db } from "@/lib/db/client";
import {
getUserCount,
@@ -10,23 +15,66 @@ import {
setSetting,
} from "@/lib/services/settings";
const oidcPlugin = isOidcConfigured()
? [
genericOAuth({
config: [
{
providerId: "oidc",
clientId: process.env.OIDC_CLIENT_ID ?? "",
clientSecret: process.env.OIDC_CLIENT_SECRET ?? "",
discoveryUrl: `${process.env.OIDC_ISSUER_URL}/.well-known/openid-configuration`,
scopes: ["openid", "email", "profile"],
pkce: true,
disableImplicitSignUp: !isOidcAutoRegisterEnabled(),
mapProfileToUser: (profile) => ({
name: profile.name || profile.preferred_username || profile.email,
}),
},
],
}),
]
: [];
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "sqlite",
}),
emailAndPassword: {
enabled: true,
enabled: !isPasswordLoginDisabled(),
},
plugins: [admin()],
account: {
accountLinking: {
enabled: true,
trustedProviders: ["oidc"],
},
},
plugins: [admin(), ...oidcPlugin],
advanced: {
database: {
generateId: () => uuid(),
},
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
// Block email/password sign-up when registration is closed.
// This is endpoint-level so it doesn't affect OIDC user creation
// (which is gated by the genericOAuth plugin's disableImplicitSignUp).
if (ctx.path === "/sign-up/email") {
const open = await isRegistrationOpen();
if (!open) {
throw new APIError("FORBIDDEN", {
message: "Registration is currently closed",
});
}
}
}),
},
databaseHooks: {
user: {
create: {
before: async (userData) => {
// First user becomes admin regardless of auth method
const userCount = await getUserCount();
if (userCount === 0) {
return {
@@ -36,15 +84,10 @@ export const auth = betterAuth({
},
};
}
const open = await isRegistrationOpen();
if (!open) {
throw new APIError("FORBIDDEN", {
message: "Registration is currently closed",
});
}
return { data: userData };
},
after: async () => {
// Auto-close registration after first user
const userCount = await getUserCount();
if (userCount === 1) {
await setSetting("registrationOpen", "false");
+20
View File
@@ -6,3 +6,23 @@
export function isTmdbConfigured(): boolean {
return !!process.env.TMDB_API_READ_ACCESS_TOKEN;
}
export function isOidcConfigured(): boolean {
return !!(
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_ISSUER_URL
);
}
export function getOidcProviderName(): string {
return process.env.OIDC_PROVIDER_NAME || "SSO";
}
export function isOidcAutoRegisterEnabled(): boolean {
return process.env.OIDC_AUTO_REGISTER !== "false";
}
export function isPasswordLoginDisabled(): boolean {
return process.env.DISABLE_PASSWORD_LOGIN === "true" && isOidcConfigured();
}
+1
View File
@@ -55,6 +55,7 @@ export const account = sqliteTable("account", {
providerId: text("providerId").notNull(),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
accessTokenExpiresAt: int("accessTokenExpiresAt", { mode: "timestamp" }),
refreshTokenExpiresAt: int("refreshTokenExpiresAt", { mode: "timestamp" }),
scope: text("scope"),