mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 00:25:38 -04:00
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:
+26
-12
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+177
-106
@@ -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,120 +107,161 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.form
|
{showOidc && (
|
||||||
onSubmit={handleSubmit}
|
<motion.div
|
||||||
className="space-y-4"
|
initial="hidden"
|
||||||
initial="hidden"
|
animate="visible"
|
||||||
animate="visible"
|
variants={{
|
||||||
variants={{
|
hidden: {},
|
||||||
hidden: {},
|
visible: { transition: { staggerChildren: 0.08 } },
|
||||||
visible: { transition: { staggerChildren: 0.08 } },
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<motion.button
|
||||||
{isRegister && (
|
type="button"
|
||||||
<motion.div variants={fieldVariants} className="space-y-1.5">
|
onClick={handleOidcLogin}
|
||||||
<label
|
disabled={oidcLoading}
|
||||||
htmlFor="name"
|
variants={fieldVariants}
|
||||||
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
|
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
Email
|
<IconKey size={16} />
|
||||||
</label>
|
{oidcLoading
|
||||||
<input
|
? "Redirecting..."
|
||||||
id="email"
|
: `Sign in with ${authConfig?.oidcProviderName || "SSO"}`}
|
||||||
type="email"
|
</motion.button>
|
||||||
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>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
<motion.div variants={fieldVariants} className="space-y-1.5">
|
{showOidc && showPasswordForm && (
|
||||||
<label
|
<div className="flex items-center gap-3">
|
||||||
htmlFor="password"
|
<div className="h-px flex-1 bg-border/50" />
|
||||||
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
|
<span className="text-xs text-muted-foreground">or</span>
|
||||||
>
|
<div className="h-px flex-1 bg-border/50" />
|
||||||
Password
|
</div>
|
||||||
</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>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
{showPasswordForm && (
|
||||||
{error && (
|
<motion.form
|
||||||
<motion.div
|
onSubmit={handleSubmit}
|
||||||
initial={{ opacity: 0, height: 0 }}
|
className="space-y-4"
|
||||||
animate={{ opacity: 1, height: "auto" }}
|
initial="hidden"
|
||||||
exit={{ opacity: 0, height: 0 }}
|
animate="visible"
|
||||||
className="overflow-hidden rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
variants={{
|
||||||
>
|
hidden: {},
|
||||||
{error}
|
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>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<motion.button
|
<motion.div variants={fieldVariants} className="space-y-1.5">
|
||||||
type="submit"
|
<label
|
||||||
disabled={loading}
|
htmlFor="email"
|
||||||
variants={fieldVariants}
|
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
|
||||||
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"
|
Email
|
||||||
>
|
</label>
|
||||||
{loading ? "Loading..." : isRegister ? "Create account" : "Sign in"}
|
<input
|
||||||
</motion.button>
|
id="email"
|
||||||
</motion.form>
|
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">
|
<motion.div variants={fieldVariants} className="space-y-1.5">
|
||||||
{isRegister ? (
|
<label
|
||||||
<>
|
htmlFor="password"
|
||||||
Already have an account?{" "}
|
className="text-xs font-medium uppercase tracking-wider text-muted-foreground"
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="font-medium text-primary transition-colors hover:text-primary/80"
|
|
||||||
>
|
>
|
||||||
Sign in
|
Password
|
||||||
</Link>
|
</label>
|
||||||
</>
|
<input
|
||||||
) : (
|
id="password"
|
||||||
<>
|
type="password"
|
||||||
Don't have an account?{" "}
|
required
|
||||||
<Link
|
minLength={8}
|
||||||
href="/register"
|
value={password}
|
||||||
className="font-medium text-primary transition-colors hover:text-primary/80"
|
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"
|
||||||
Register
|
placeholder="Min 8 characters"
|
||||||
</Link>
|
/>
|
||||||
</>
|
</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't have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="font-medium text-primary transition-colors hover:text-primary/80"
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-2
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user