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) # 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 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) # Optional: override TMDB base URLs (advanced)
# TMDB_API_BASE_URL=https://api.themoviedb.org/3 # TMDB_API_BASE_URL=https://api.themoviedb.org/3
# TMDB_IMAGE_BASE_URL=https://image.tmdb.org/t/p # TMDB_IMAGE_BASE_URL=https://image.tmdb.org/t/p
# Image caching — downloads TMDB images to local disk for faster serving # ─── Auth (required) ───────────────────────────────────────────────────────
# Set to "false" to disable and use TMDB CDN directly (default: enabled) # Random secret for session encryption (min 32 chars)
IMAGE_CACHE_DIR=./data/images # Generate one with `npx @better-auth/cli secret` or `openssl rand -base64 32`
# IMAGE_CACHE_ENABLED=false 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 { AuthForm } from "@/components/auth-form";
import {
getOidcProviderName,
isOidcConfigured,
isPasswordLoginDisabled,
} from "@/lib/config";
export default function LoginPage() { export default function LoginPage() {
const oidcEnabled = isOidcConfigured();
return ( return (
<div className="flex min-h-[80vh] items-center justify-center px-4"> <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> </div>
); );
} }
+15 -2
View File
@@ -4,17 +4,30 @@ import { IconLock } from "@tabler/icons-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { AuthConfig } from "@/components/auth-form";
import { AuthForm } from "@/components/auth-form"; import { AuthForm } from "@/components/auth-form";
export default function RegisterPage() { export default function RegisterPage() {
const [registrationOpen, setRegistrationOpen] = useState<boolean | null>( const [registrationOpen, setRegistrationOpen] = useState<boolean | null>(
null, null,
); );
const [authConfig, setAuthConfig] = useState<AuthConfig>({
oidcEnabled: false,
oidcProviderName: null,
passwordLoginDisabled: false,
});
useEffect(() => { useEffect(() => {
fetch("/api/registration/status") fetch("/api/registration/status")
.then((res) => res.json()) .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)); .catch(() => setRegistrationOpen(false));
}, []); }, []);
@@ -63,7 +76,7 @@ export default function RegisterPage() {
return ( return (
<div className="flex min-h-[80vh] items-center justify-center px-4"> <div className="flex min-h-[80vh] items-center justify-center px-4">
<AuthForm mode="register" /> <AuthForm mode="register" authConfig={authConfig} />
</div> </div>
); );
} }
+11 -2
View File
@@ -1,7 +1,16 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import {
getOidcProviderName,
isOidcConfigured,
isPasswordLoginDisabled,
} from "@/lib/config";
import { isRegistrationOpen } from "@/lib/services/settings"; import { isRegistrationOpen } from "@/lib/services/settings";
export async function GET() { export async function GET() {
const registrationOpen = await isRegistrationOpen(); return NextResponse.json({
return NextResponse.json({ registrationOpen }); registrationOpen: await isRegistrationOpen(),
oidcEnabled: isOidcConfigured(),
oidcProviderName: isOidcConfigured() ? getOidcProviderName() : null,
passwordLoginDisabled: isPasswordLoginDisabled(),
});
} }
+84 -13
View File
@@ -1,11 +1,18 @@
"use client"; "use client";
import { IconKey } from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { SofaLogo } from "@/components/sofa-logo"; 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 = { const fieldVariants = {
hidden: { opacity: 0, y: 10 }, 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 router = useRouter();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const isRegister = mode === "register"; const isRegister = mode === "register";
const showOidc = authConfig?.oidcEnabled ?? false;
const showPasswordForm = !(authConfig?.passwordLoginDisabled ?? false);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); 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 ( return (
<div className="relative mx-auto w-full max-w-sm"> <div className="relative mx-auto w-full max-w-sm">
{/* Subtle glow behind card */} {/* Subtle glow behind card */}
@@ -77,6 +107,40 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
</p> </p>
</div> </div>
{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"
>
<IconKey size={16} />
{oidcLoading
? "Redirecting..."
: `Sign in with ${authConfig?.oidcProviderName || "SSO"}`}
</motion.button>
</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>
)}
{showPasswordForm && (
<motion.form <motion.form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="space-y-4" className="space-y-4"
@@ -144,6 +208,22 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
/> />
</motion.div> </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> <AnimatePresence>
{error && ( {error && (
<motion.div <motion.div
@@ -157,17 +237,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
)} )}
</AnimatePresence> </AnimatePresence>
<motion.button {showPasswordForm && (
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>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
{isRegister ? ( {isRegister ? (
<> <>
@@ -191,6 +261,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
</> </>
)} )}
</p> </p>
)}
</motion.div> </motion.div>
</div> </div>
); );
+2 -2
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { adminClient } from "better-auth/client/plugins"; import { adminClient, genericOAuthClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react"; import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({ export const authClient = createAuthClient({
plugins: [adminClient()], plugins: [adminClient(), genericOAuthClient()],
}); });
export const { signIn, signUp, signOut, useSession } = authClient; export const { signIn, signUp, signOut, useSession } = authClient;
+53 -10
View File
@@ -1,8 +1,13 @@
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { APIError } from "better-auth/api"; import { APIError, createAuthMiddleware } from "better-auth/api";
import { admin } from "better-auth/plugins"; import { admin, genericOAuth } from "better-auth/plugins";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import {
isOidcAutoRegisterEnabled,
isOidcConfigured,
isPasswordLoginDisabled,
} from "@/lib/config";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { import {
getUserCount, getUserCount,
@@ -10,23 +15,66 @@ import {
setSetting, setSetting,
} from "@/lib/services/settings"; } 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({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "sqlite", provider: "sqlite",
}), }),
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: !isPasswordLoginDisabled(),
}, },
plugins: [admin()], account: {
accountLinking: {
enabled: true,
trustedProviders: ["oidc"],
},
},
plugins: [admin(), ...oidcPlugin],
advanced: { advanced: {
database: { database: {
generateId: () => uuid(), 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: { databaseHooks: {
user: { user: {
create: { create: {
before: async (userData) => { before: async (userData) => {
// First user becomes admin regardless of auth method
const userCount = await getUserCount(); const userCount = await getUserCount();
if (userCount === 0) { if (userCount === 0) {
return { 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 }; return { data: userData };
}, },
after: async () => { after: async () => {
// Auto-close registration after first user
const userCount = await getUserCount(); const userCount = await getUserCount();
if (userCount === 1) { if (userCount === 1) {
await setSetting("registrationOpen", "false"); await setSetting("registrationOpen", "false");
+20
View File
@@ -6,3 +6,23 @@
export function isTmdbConfigured(): boolean { export function isTmdbConfigured(): boolean {
return !!process.env.TMDB_API_READ_ACCESS_TOKEN; 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(), providerId: text("providerId").notNull(),
accessToken: text("accessToken"), accessToken: text("accessToken"),
refreshToken: text("refreshToken"), refreshToken: text("refreshToken"),
idToken: text("idToken"),
accessTokenExpiresAt: int("accessTokenExpiresAt", { mode: "timestamp" }), accessTokenExpiresAt: int("accessTokenExpiresAt", { mode: "timestamp" }),
refreshTokenExpiresAt: int("refreshTokenExpiresAt", { mode: "timestamp" }), refreshTokenExpiresAt: int("refreshTokenExpiresAt", { mode: "timestamp" }),
scope: text("scope"), scope: text("scope"),