mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -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)
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'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'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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user