implement oauth2 login

This commit is contained in:
fuomag9
2025-10-31 23:02:30 +01:00
parent 29acf06f75
commit d9ced96e1b
29 changed files with 800 additions and 136 deletions

View File

@@ -1,28 +1,71 @@
# Caddy Proxy Manager
Caddy Proxy Manager is a modern control panel for Caddy that simplifies reverse proxy configuration, TLS automation, access control, and observability. The entire application is built with Next.js and ships with a lean dependency set, OAuth2 login, and a battery of tools for managing hosts, redirects, streams, certificates, and Cloudflare DNS-based certificate issuance.
[https://caddyproxymanager.com](https://caddyproxymanager.com)
Caddy Proxy Manager is a modern control panel for Caddy that simplifies reverse proxy configuration, TLS automation, access control, and observability. The stack is built with Next.js16 (App Router), MaterialUI, and a lightweight SQLite data layer. It ships with OAuth2 SSO, first-class Caddy admin API integration, and tooling for Cloudflare DNS challenge automation.
## Highlights
- **Next.js 14 App Router** UI and API in a single project, backed by an embedded SQLite database.
- **OAuth2 single sign-on** with PKCE and configurable claim mapping. The first authenticated user becomes the administrator.
- **End-to-end Caddy orchestration** using the admin API, generating JSON configurations for HTTP, HTTPS, redirects, custom 404 hosts, and TCP/UDP streams.
- **Cloudflare DNS challenge integration** via xcaddy-built Caddy binary with `cloudflare` and `layer4` modules; credentials are stored in the UI.
- **Access lists** (HTTP basic auth), custom certificates (managed or imported PEM), and a full audit log of administrative changes.
- **Default HSTS configuration** (`Strict-Transport-Security: max-age=63072000`) baked into every HTTP route to meet security baseline requirements.
- **Next.js16 App Router** server components for data loading, client components for interactivity, and a unified API surface.
- **Material UI dark mode** fast, responsive dashboard with ready-made components and accessibility baked in.
- **OAuth2 single sign-on** PKCE flow with configurable claims; the first authenticated user is promoted to administrator.
- **End-to-end Caddy orchestration** generate JSON for HTTP(S) proxies, redirects, 404 hosts, and TCP/UDP streams via the Caddy admin API.
- **Cloudflare DNS challenge support** xcaddy build bundles the `cloudflare` DNS and `layer4` modules; credentials are configurable in the UI.
- **Security-by-default** HSTS (`Strict-Transport-Security: max-age=63072000`) applied to every managed host.
- **Embedded audit log** every configuration change is recorded with actor, summary, and timestamp.
## Project Structure
```
.
├── app/ # Next.js app router (auth, dashboard, APIs)
├── app/ # Next.js App Router entrypoint (layouts, routes, server actions)
│ ├── (auth)/ # Login + OAuth setup flows
│ ├── (dashboard)/ # Dashboard layout, feature surface, client renderers
│ ├── api/ # Route handlers for auth callbacks/logout
│ ├── providers.tsx # Global MUI theme + CssBaseline
│ └── layout.tsx # Root HTML/body wrapper
├── src/
│ └── lib/ # Database, Caddy integration, models, settings
├── docker/ # Dockerfiles for web + Caddy
├── compose.yaml # Production-ready docker compose definition
└── data/ # (Generated) SQLite database, TLS material, Caddy data
│ └── lib/ # SQLite integration, migrations, models, Caddy config builder
├── docker/
│ ├── web/ # Next.js production image (standalone output)
│ └── caddy/ # xcaddy build with Cloudflare + layer4 modules
├── compose.yaml # Multi-container deployment (Next.js app + Caddy)
├── data/ # Generated at runtime (SQLite DB, cert storage, Caddy state)
└── README.md # You are here
```
### Dashboard Modules
- `ProxyHostsClient.tsx` create/update/delete HTTP(S) reverse proxies, assign certs/access lists.
- `RedirectsClient.tsx` manage 301/302 redirects with optional path/query preservation.
- `DeadHostsClient.tsx` serve custom offline pages with programmable status codes.
- `StreamsClient.tsx` configure TCP/UDP layer4 proxies.
- `AccessListsClient.tsx` manage HTTP basic auth credentials and membership.
- `CertificatesClient.tsx` import PEMs or request managed ACME certificates.
- `SettingsClient.tsx` general metadata, OAuth2 endpoints, Cloudflare DNS token.
- `AuditLogClient.tsx` list chronological administrative activity.
## Feature Overview
### Authentication & Authorization
- OAuth2/OIDC login with PKCE.
- First user bootstrap to admin role.
- Session persistence via signed, rotating cookies stored in SQLite.
### Reverse Proxy Management
- HTTP(S) proxy hosts with TLS enforcement, WebSocket + HTTP/2 toggles.
- Redirect hosts with custom status codes and query preservation.
- Dead/maintenance hosts with custom responses.
- Stream (TCP/UDP) forwarding powered by the Caddy layer4 module.
- Access list (basic auth) integration for protected hosts.
- TLS certificate lifecycle: managed ACME (DNS-01 via Cloudflare) or imported PEMs.
### Operations & Observability
- Full audit log with actor/action/summary/time.
- One-click revalidation of Caddy configuration after mutations.
- Migrations run automatically on startup; upgrades are seamless.
- Docker-first deployment, HSTS defaults, Cloudflare DNS automation.
## Requirements
- Node.js 20+ (development)

View File

@@ -2,8 +2,13 @@
import Link from "next/link";
import { Alert, Box, Button, Card, CardContent, Stack, Typography } from "@mui/material";
import { signIn } from "next-auth/react";
export default function LoginClient({ oauthConfigured, providerId }: { oauthConfigured: boolean; providerId: string }) {
const handleSignIn = async () => {
await signIn(providerId, { callbackUrl: "/" });
};
export default function LoginClient({ oauthConfigured, startOAuth }: { oauthConfigured: boolean; startOAuth: (formData: FormData) => void }) {
return (
<Box sx={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", bgcolor: "background.default" }}>
<Card sx={{ maxWidth: 420, width: "100%", p: 1.5 }} elevation={6}>
@@ -19,11 +24,9 @@ export default function LoginClient({ oauthConfigured, startOAuth }: { oauthConf
</Stack>
{oauthConfigured ? (
<Box component="form" action={startOAuth}>
<Button type="submit" variant="contained" size="large" fullWidth>
Sign in with OAuth2
</Button>
</Box>
<Button onClick={handleSignIn} variant="contained" size="large" fullWidth>
Sign in with OAuth2
</Button>
) : (
<Alert severity="warning">
The system administrator needs to configure OAuth2 settings before logins are allowed. If this is a fresh installation,

View File

@@ -1,22 +1,19 @@
import { redirect } from "next/navigation";
import { getSession } from "@/src/lib/auth/session";
import { buildAuthorizationUrl } from "@/src/lib/auth/oauth";
import { auth } from "@/src/lib/auth";
import { getOAuthSettings } from "@/src/lib/settings";
import LoginClient from "./LoginClient";
export default async function LoginPage() {
const session = await getSession();
const session = await auth();
if (session) {
redirect("/");
}
const oauthConfigured = Boolean(getOAuthSettings());
const settings = getOAuthSettings();
const oauthConfigured = Boolean(settings);
async function startOAuth() {
"use server";
const target = buildAuthorizationUrl("/");
redirect(target);
}
// Determine provider ID based on settings
const providerId = settings?.providerType === "authentik" ? "authentik" : "oauth";
return <LoginClient oauthConfigured={oauthConfigured} startOAuth={startOAuth} />;
return <LoginClient oauthConfigured={oauthConfigured} providerId={providerId} />;
}

View File

@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import { requireUser } from "@/src/lib/auth";
import {
addAccessListEntry,
createAccessList,

View File

@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import { requireUser } from "@/src/lib/auth";
import { createCertificate, deleteCertificate, updateCertificate } from "@/src/lib/models/certificates";
function parseDomains(value: FormDataEntryValue | null): string[] {

View File

@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import { requireUser } from "@/src/lib/auth";
import { createDeadHost, deleteDeadHost, updateDeadHost } from "@/src/lib/models/dead-hosts";
function parseDomains(value: FormDataEntryValue | null): string[] {

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { requireUser } from "@/src/lib/auth/session";
import { requireUser } from "@/src/lib/auth";
import DashboardLayoutClient from "./DashboardLayoutClient";
export default async function DashboardLayout({ children }: { children: ReactNode }) {

View File

@@ -1,5 +1,5 @@
import db from "@/src/lib/db";
import { requireUser } from "@/src/lib/auth/session";
import { requireUser } from "@/src/lib/auth";
import OverviewClient from "./OverviewClient";
type StatCard = {

View File

@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import { requireUser } from "@/src/lib/auth";
import { createProxyHost, deleteProxyHost, updateProxyHost } from "@/src/lib/models/proxy-hosts";
function parseCsv(value: FormDataEntryValue | null): string[] {

View File

@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import { requireUser } from "@/src/lib/auth";
import { createRedirectHost, deleteRedirectHost, updateRedirectHost } from "@/src/lib/models/redirect-hosts";
function parseList(value: FormDataEntryValue | null): string[] {

View File

@@ -1,6 +1,7 @@
"use client";
import { Box, Button, Card, CardContent, Stack, TextField, Typography } from "@mui/material";
import { useState } from "react";
import { Box, Button, Card, CardContent, FormControl, FormControlLabel, FormLabel, Radio, RadioGroup, Stack, TextField, Typography } from "@mui/material";
import type { CloudflareSettings, GeneralSettings, OAuthSettings } from "@/src/lib/settings";
import {
updateCloudflareSettingsAction,
@@ -15,6 +16,7 @@ type Props = {
};
export default function SettingsClient({ general, oauth, cloudflare }: Props) {
const [providerType, setProviderType] = useState<"authentik" | "generic">(oauth?.providerType || "authentik");
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
@@ -56,24 +58,55 @@ export default function SettingsClient({ general, oauth, cloudflare }: Props) {
<Card>
<CardContent>
<Typography variant="h6" fontWeight={600} gutterBottom>
OAuth2 Authentication
OAuth2/OIDC Authentication
</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
Provide the OAuth 2.0 endpoints and client credentials issued by your identity provider. Scopes should include profile and
Provide the OAuth 2.0/OIDC endpoints and client credentials issued by your identity provider. Scopes should include profile and
email data.
</Typography>
<Stack component="form" action={updateOAuthSettingsAction} spacing={2}>
<TextField name="authorizationUrl" label="Authorization URL" defaultValue={oauth?.authorizationUrl ?? ""} required fullWidth />
<TextField name="tokenUrl" label="Token URL" defaultValue={oauth?.tokenUrl ?? ""} required fullWidth />
<TextField name="userInfoUrl" label="User info URL" defaultValue={oauth?.userInfoUrl ?? ""} required fullWidth />
<TextField name="clientId" label="Client ID" defaultValue={oauth?.clientId ?? ""} required fullWidth />
<TextField name="clientSecret" label="Client secret" defaultValue={oauth?.clientSecret ?? ""} required fullWidth />
<TextField name="scopes" label="Scopes" defaultValue={oauth?.scopes ?? "openid email profile"} fullWidth />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField name="emailClaim" label="Email claim" defaultValue={oauth?.emailClaim ?? "email"} fullWidth />
<TextField name="nameClaim" label="Name claim" defaultValue={oauth?.nameClaim ?? "name"} fullWidth />
<TextField name="avatarClaim" label="Avatar claim" defaultValue={oauth?.avatarClaim ?? "picture"} fullWidth />
</Stack>
<FormControl component="fieldset">
<FormLabel component="legend" sx={{ mb: 1 }}>Provider Type</FormLabel>
<RadioGroup
row
name="providerType"
value={providerType}
onChange={(e) => setProviderType(e.target.value as "authentik" | "generic")}
>
<FormControlLabel value="authentik" control={<Radio />} label="Authentik (OIDC)" />
<FormControlLabel value="generic" control={<Radio />} label="Generic OAuth2" />
</RadioGroup>
</FormControl>
{providerType === "authentik" ? (
<>
<TextField
name="authorizationUrl"
label="Authorization URL"
defaultValue={oauth?.authorizationUrl ?? ""}
helperText="Other endpoints will be auto-discovered from the OIDC issuer"
required
fullWidth
/>
<TextField name="clientId" label="Client ID" defaultValue={oauth?.clientId ?? ""} required fullWidth />
<TextField name="clientSecret" label="Client secret" defaultValue={oauth?.clientSecret ?? ""} required fullWidth type="password" />
<TextField name="scopes" label="Scopes" defaultValue={oauth?.scopes ?? "openid email profile"} fullWidth />
</>
) : (
<>
<TextField name="authorizationUrl" label="Authorization URL" defaultValue={oauth?.authorizationUrl ?? ""} required fullWidth />
<TextField name="tokenUrl" label="Token URL" defaultValue={oauth?.tokenUrl ?? ""} required fullWidth />
<TextField name="userInfoUrl" label="User info URL" defaultValue={oauth?.userInfoUrl ?? ""} required fullWidth />
<TextField name="clientId" label="Client ID" defaultValue={oauth?.clientId ?? ""} required fullWidth />
<TextField name="clientSecret" label="Client secret" defaultValue={oauth?.clientSecret ?? ""} required fullWidth type="password" />
<TextField name="scopes" label="Scopes" defaultValue={oauth?.scopes ?? "openid email profile"} fullWidth />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField name="emailClaim" label="Email claim" defaultValue={oauth?.emailClaim ?? "email"} fullWidth />
<TextField name="nameClaim" label="Name claim" defaultValue={oauth?.nameClaim ?? "name"} fullWidth />
<TextField name="avatarClaim" label="Avatar claim" defaultValue={oauth?.avatarClaim ?? "picture"} fullWidth />
</Stack>
</>
)}
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Save OAuth settings

View File

@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import { requireUser } from "@/src/lib/auth";
import { applyCaddyConfig } from "@/src/lib/caddy";
import { saveCloudflareSettings, saveGeneralSettings, saveOAuthSettings } from "@/src/lib/settings";
@@ -16,7 +16,11 @@ export async function updateGeneralSettingsAction(formData: FormData) {
export async function updateOAuthSettingsAction(formData: FormData) {
await requireUser();
const providerType = String(formData.get("providerType") ?? "authentik");
saveOAuthSettings({
providerType: providerType === "generic" ? "generic" : "authentik",
authorizationUrl: String(formData.get("authorizationUrl") ?? ""),
tokenUrl: String(formData.get("tokenUrl") ?? ""),
clientId: String(formData.get("clientId") ?? ""),

View File

@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import { requireUser } from "@/src/lib/auth";
import { createStreamHost, deleteStreamHost, updateStreamHost } from "@/src/lib/models/stream-hosts";
export async function createStreamAction(formData: FormData) {

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/src/lib/auth";
export const { GET, POST } = handlers;

View File

@@ -1,24 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { finalizeOAuthLogin } from "@/src/lib/auth/oauth";
import { createSession } from "@/src/lib/auth/session";
import { config } from "@/src/lib/config";
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code || !state) {
return NextResponse.redirect(new URL("/login?error=invalid_response", config.baseUrl));
}
try {
const { user, redirectTo } = await finalizeOAuthLogin(code, state);
await createSession(user.id);
const destination = redirectTo && redirectTo.startsWith("/") ? redirectTo : "/";
return NextResponse.redirect(new URL(destination, config.baseUrl));
} catch (error) {
console.error("OAuth callback failed", error);
return NextResponse.redirect(new URL("/login?error=oauth_failed", config.baseUrl));
}
}

View File

@@ -1,8 +1,5 @@
import { NextResponse } from "next/server";
import { destroySession } from "@/src/lib/auth/session";
import { config } from "@/src/lib/config";
import { signOut } from "@/src/lib/auth";
export async function POST() {
await destroySession();
return NextResponse.redirect(new URL("/login", config.baseUrl));
await signOut({ redirectTo: "/login" });
}

View File

@@ -1,8 +1,11 @@
"use client";
import { Box, Button, Card, CardContent, Grid, Stack, TextField, Typography } from "@mui/material";
import { useState } from "react";
import { Box, Button, Card, CardContent, FormControl, FormControlLabel, FormLabel, Grid, Radio, RadioGroup, Stack, TextField, Typography } from "@mui/material";
export default function OAuthSetupClient({ startSetup }: { startSetup: (formData: FormData) => void }) {
const [providerType, setProviderType] = useState<"authentik" | "generic">("authentik");
return (
<Box sx={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", bgcolor: "background.default" }}>
<Card sx={{ width: { xs: "90vw", sm: 640 }, p: { xs: 2, sm: 3 } }}>
@@ -10,7 +13,7 @@ export default function OAuthSetupClient({ startSetup }: { startSetup: (formData
<Stack spacing={3}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Configure OAuth2
Configure OAuth2/OIDC
</Typography>
<Typography color="text.secondary">
Provide the OAuth configuration for your identity provider to finish setting up Caddy Proxy Manager. The first user who
@@ -19,35 +22,66 @@ export default function OAuthSetupClient({ startSetup }: { startSetup: (formData
</Stack>
<Stack component="form" action={startSetup} spacing={2}>
<TextField
name="authorizationUrl"
label="Authorization URL"
placeholder="https://id.example.com/oauth2/authorize"
required
fullWidth
/>
<TextField name="tokenUrl" label="Token URL" placeholder="https://id.example.com/oauth2/token" required fullWidth />
<TextField
name="userInfoUrl"
label="User info URL"
placeholder="https://id.example.com/oauth2/userinfo"
required
fullWidth
/>
<TextField name="clientId" label="Client ID" placeholder="client-id" required fullWidth />
<TextField name="clientSecret" label="Client secret" placeholder="client-secret" required fullWidth />
<TextField name="scopes" label="Scopes" defaultValue="openid email profile" fullWidth />
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<TextField name="emailClaim" label="Email claim" defaultValue="email" fullWidth />
</Grid>
<Grid item xs={12} sm={4}>
<TextField name="nameClaim" label="Name claim" defaultValue="name" fullWidth />
</Grid>
<Grid item xs={12} sm={4}>
<TextField name="avatarClaim" label="Avatar claim" defaultValue="picture" fullWidth />
</Grid>
</Grid>
<FormControl component="fieldset">
<FormLabel component="legend" sx={{ mb: 1 }}>Provider Type</FormLabel>
<RadioGroup
row
name="providerType"
value={providerType}
onChange={(e) => setProviderType(e.target.value as "authentik" | "generic")}
>
<FormControlLabel value="authentik" control={<Radio />} label="Authentik (OIDC)" />
<FormControlLabel value="generic" control={<Radio />} label="Generic OAuth2" />
</RadioGroup>
</FormControl>
{providerType === "authentik" ? (
<>
<TextField
name="authorizationUrl"
label="Authorization URL"
placeholder="https://authentik.example.com/application/o/myapp/authorization/authorize/"
helperText="Other endpoints will be auto-discovered from the OIDC issuer"
required
fullWidth
/>
<TextField name="clientId" label="Client ID" placeholder="client-id" required fullWidth />
<TextField name="clientSecret" label="Client secret" placeholder="client-secret" required fullWidth type="password" />
<TextField name="scopes" label="Scopes" defaultValue="openid email profile" fullWidth />
</>
) : (
<>
<TextField
name="authorizationUrl"
label="Authorization URL"
placeholder="https://id.example.com/oauth2/authorize"
required
fullWidth
/>
<TextField name="tokenUrl" label="Token URL" placeholder="https://id.example.com/oauth2/token" required fullWidth />
<TextField
name="userInfoUrl"
label="User info URL"
placeholder="https://id.example.com/oauth2/userinfo"
required
fullWidth
/>
<TextField name="clientId" label="Client ID" placeholder="client-id" required fullWidth />
<TextField name="clientSecret" label="Client secret" placeholder="client-secret" required fullWidth type="password" />
<TextField name="scopes" label="Scopes" defaultValue="openid email profile" fullWidth />
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<TextField name="emailClaim" label="Email claim" defaultValue="email" fullWidth />
</Grid>
<Grid item xs={12} sm={4}>
<TextField name="nameClaim" label="Name claim" defaultValue="name" fullWidth />
</Grid>
<Grid item xs={12} sm={4}>
<TextField name="avatarClaim" label="Avatar claim" defaultValue="picture" fullWidth />
</Grid>
</Grid>
</>
)}
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained" size="large">
Save OAuth configuration

View File

@@ -5,10 +5,12 @@ import { getUserCount } from "@/src/lib/models/user";
import { saveOAuthSettings } from "@/src/lib/settings";
export async function initialOAuthSetupAction(formData: FormData) {
if (getUserCount() > 0) {
redirect("/login");
}
// Allow reconfiguring OAuth even if users exist (in case settings were lost)
// Just save the settings and redirect
const providerType = String(formData.get("providerType") ?? "authentik");
saveOAuthSettings({
providerType: providerType === "generic" ? "generic" : "authentik",
authorizationUrl: String(formData.get("authorizationUrl") ?? ""),
tokenUrl: String(formData.get("tokenUrl") ?? ""),
userInfoUrl: String(formData.get("userInfoUrl") ?? ""),

View File

@@ -5,7 +5,12 @@ import { initialOAuthSetupAction } from "./actions";
import OAuthSetupClient from "./SetupClient";
export default function OAuthSetupPage() {
if (getUserCount() > 0 && getOAuthSettings()) {
// Only redirect if BOTH users exist AND OAuth is configured
// This allows reconfiguring OAuth even if users exist
const hasUsers = getUserCount() > 0;
const hasOAuth = getOAuthSettings();
if (hasUsers && hasOAuth) {
redirect("/login");
}

103
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"bcryptjs": "^3.0.2",
"better-sqlite3": "^12.4.1",
"next": "^16.0.1",
"next-auth": "^5.0.0-beta.30",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
@@ -27,6 +28,35 @@
"typescript": "^5.9.3"
}
},
"node_modules/@auth/core": {
"version": "0.41.0",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz",
"integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.2.1",
"jose": "^6.0.6",
"oauth4webapi": "^3.3.0",
"preact": "10.24.3",
"preact-render-to-string": "6.5.11"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -1618,6 +1648,15 @@
"node": ">=12.4.0"
}
},
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -4860,6 +4899,15 @@
"node": ">= 0.4"
}
},
"node_modules/jose": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5221,6 +5269,33 @@
}
}
},
"node_modules/next-auth": {
"version": "5.0.0-beta.30",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz",
"integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==",
"license": "ISC",
"dependencies": {
"@auth/core": "0.41.0"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
"nodemailer": "^7.0.7",
"react": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/node-abi": {
"version": "3.80.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.80.0.tgz",
@@ -5240,6 +5315,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/oauth4webapi": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.2.tgz",
"integrity": "sha512-FzZZ+bht5X0FKe7Mwz3DAVAmlH1BV5blSak/lHMBKz0/EBMhX6B10GlQYI51+oRp8ObJaX0g6pXrAxZh5s8rjw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5561,6 +5645,25 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
"license": "MIT",
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",

View File

@@ -18,6 +18,7 @@
"bcryptjs": "^3.0.2",
"better-sqlite3": "^12.4.1",
"next": "^16.0.1",
"next-auth": "^5.0.0-beta.30",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},

215
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,215 @@
import NextAuth, { type DefaultSession } from "next-auth";
import Authentik from "next-auth/providers/authentik";
import { CustomAdapter } from "./auth/adapter";
import { getOAuthSettings } from "./settings";
import { config } from "./config";
import type { SessionContext, UserRecord } from "./auth/session";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession["user"];
}
interface User {
role?: string;
}
}
// Legacy compatibility types
export type { SessionContext, UserRecord };
/**
* Creates the appropriate OAuth provider based on settings.
*/
function createOAuthProvider() {
const settings = getOAuthSettings();
if (!settings) {
return null;
}
// Use official Authentik provider for OIDC
if (settings.providerType === "authentik") {
// Extract issuer from authorization URL
// Authentik format: https://domain/application/o/APP_SLUG/authorization/authorize/
// Issuer should be: https://domain/application/o/APP_SLUG/
let issuer: string;
try {
const url = new URL(settings.authorizationUrl);
const pathParts = url.pathname.split('/').filter(Boolean);
const oIndex = pathParts.indexOf('o');
if (oIndex >= 0 && pathParts[oIndex + 2] === 'authorization') {
const slug = pathParts[oIndex + 1];
issuer = `${url.origin}/application/o/${slug}/`;
} else {
// Fallback: remove the authorization path
issuer = settings.authorizationUrl.replace(/\/authorization\/authorize\/?$/, '/');
}
console.log('[Auth.js] Derived Authentik issuer:', issuer);
console.log('[Auth.js] Will attempt OIDC discovery at:', `${issuer}.well-known/openid-configuration`);
} catch (e) {
console.error("Failed to parse Authentik issuer from URL", e);
return null;
}
return Authentik({
clientId: settings.clientId,
clientSecret: settings.clientSecret,
issuer,
authorization: {
params: {
scope: settings.scopes || "openid email profile",
},
},
});
}
// Generic OAuth2 provider for non-OIDC providers
return {
id: "oauth",
name: "OAuth2",
type: "oauth" as const,
authorization: {
url: settings.authorizationUrl,
params: {
scope: settings.scopes || "openid email profile",
},
},
token: {
url: settings.tokenUrl,
},
userinfo: {
url: settings.userInfoUrl,
},
clientId: settings.clientId,
clientSecret: settings.clientSecret,
checks: ["state", "pkce"] as const,
profile(profile: any) {
const emailClaim = settings.emailClaim || "email";
const nameClaim = settings.nameClaim || "name";
const avatarClaim = settings.avatarClaim || "picture";
return {
id: String(profile.sub || profile.id || profile.user_id || profile[emailClaim]),
email: String(profile[emailClaim]),
name: profile[nameClaim] ? String(profile[nameClaim]) : null,
image: profile[avatarClaim] ? String(profile[avatarClaim]) : null,
};
},
};
}
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: CustomAdapter(),
providers: [createOAuthProvider()].filter(Boolean),
session: {
strategy: "database",
maxAge: 7 * 24 * 60 * 60, // 7 days
},
pages: {
signIn: "/login",
},
callbacks: {
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
// Fetch role from database
const db = (await import("./db")).default;
const dbUser = db.prepare("SELECT role FROM users WHERE id = ?").get(user.id) as { role: string } | undefined;
session.user.role = dbUser?.role || "user";
}
return session;
},
async signIn({ user, account, profile }) {
// Auto-assign admin role to first user
const db = (await import("./db")).default;
const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number };
if (userCount.count === 1) {
// This is the first user, make them admin
db.prepare("UPDATE users SET role = ? WHERE id = ?").run("admin", user.id);
}
return true;
},
async redirect({ url, baseUrl }) {
// Validate redirect URL to prevent open redirect attacks
if (url.startsWith("/")) {
// Reject URLs starting with // (protocol-relative URLs)
if (url.startsWith("//")) {
return baseUrl;
}
// Check for encoded slashes
if (url.includes('%2f%2f') || url.toLowerCase().includes('%2f%2f')) {
return baseUrl;
}
// Reject protocol specifications in the path
if (/^\/[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) {
return baseUrl;
}
return url;
}
// Only allow redirects to same origin
if (url.startsWith(baseUrl)) {
return url;
}
return baseUrl;
},
},
secret: config.sessionSecret,
trustHost: true,
basePath: "/api/auth",
});
/**
* Helper function to get the current session on the server.
* Returns user and session data in the legacy format for compatibility.
*/
export async function getSessionLegacy(): Promise<SessionContext | null> {
const session = await auth();
if (!session?.user) {
return null;
}
const db = (await import("./db")).default;
const user = db.prepare(
`SELECT id, email, name, role, provider, subject, avatar_url, status, created_at, updated_at
FROM users WHERE id = ?`
).get(session.user.id) as UserRecord | undefined;
if (!user) {
return null;
}
return {
session: {
id: 0, // Auth.js doesn't expose session ID
user_id: Number(session.user.id),
token: "", // Not exposed by Auth.js
expires_at: session.expires || "",
created_at: ""
},
user
};
}
/**
* Helper function to require authentication, throwing if not authenticated.
* Returns user and session data in the legacy format for compatibility.
*/
export async function requireUser(): Promise<SessionContext> {
const context = await getSessionLegacy();
if (!context) {
const { redirect } = await import("next/navigation");
redirect("/login");
// TypeScript doesn't know redirect() never returns, so we throw to help the type checker
throw new Error("Redirecting to login");
}
return context;
}

205
src/lib/auth/adapter.ts Normal file
View File

@@ -0,0 +1,205 @@
import type { Adapter, AdapterUser, AdapterAccount, AdapterSession, VerificationToken } from "next-auth/adapters";
import db, { nowIso } from "../db";
import crypto from "node:crypto";
/**
* Custom Auth.js adapter for our existing SQLite database schema.
* Maps our existing users/sessions tables to Auth.js expectations.
*/
export function CustomAdapter(): Adapter {
return {
async createUser(user: Omit<AdapterUser, "id">): Promise<AdapterUser> {
const stmt = db.prepare(
`INSERT INTO users (email, name, avatar_url, provider, subject, role, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
// For Auth.js, we'll use 'oidc' as provider and email as subject initially
const subject = crypto.randomBytes(16).toString("hex");
const info = stmt.run(
user.email,
user.name || null,
user.image || null,
"oidc",
subject,
"user",
"active",
nowIso(),
nowIso()
);
return {
id: String(info.lastInsertRowid),
email: user.email,
emailVerified: user.emailVerified || null,
name: user.name || null,
image: user.image || null
};
},
async getUser(id: string): Promise<AdapterUser | null> {
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as any;
if (!user) return null;
return {
id: String(user.id),
email: user.email,
emailVerified: null,
name: user.name,
image: user.avatar_url
};
},
async getUserByEmail(email: string): Promise<AdapterUser | null> {
const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email) as any;
if (!user) return null;
return {
id: String(user.id),
email: user.email,
emailVerified: null,
name: user.name,
image: user.avatar_url
};
},
async getUserByAccount({ providerAccountId, provider }): Promise<AdapterUser | null> {
// For Authentik OIDC, match by subject (sub claim)
const user = db.prepare(
"SELECT * FROM users WHERE subject = ?"
).get(providerAccountId) as any;
if (!user) return null;
return {
id: String(user.id),
email: user.email,
emailVerified: null,
name: user.name,
image: user.avatar_url
};
},
async updateUser(user: Partial<AdapterUser> & Pick<AdapterUser, "id">): Promise<AdapterUser> {
const existing = db.prepare("SELECT * FROM users WHERE id = ?").get(user.id) as any;
db.prepare(
`UPDATE users SET email = ?, name = ?, avatar_url = ?, updated_at = ?
WHERE id = ?`
).run(
user.email || existing.email,
user.name || existing.name,
user.image || existing.avatar_url,
nowIso(),
user.id
);
return {
id: user.id,
email: user.email || existing.email,
emailVerified: user.emailVerified || null,
name: user.name || existing.name,
image: user.image || existing.avatar_url
};
},
async deleteUser(userId: string): Promise<void> {
db.prepare("DELETE FROM users WHERE id = ?").run(userId);
},
async linkAccount(account: AdapterAccount): Promise<AdapterAccount | null | undefined> {
// Update the user's subject to the OIDC sub claim
db.prepare(
`UPDATE users SET subject = ?, updated_at = ?
WHERE id = ?`
).run(account.providerAccountId, nowIso(), account.userId);
return account;
},
async unlinkAccount({ providerAccountId, provider }): Promise<void> {
// Set subject back to random
db.prepare(
`UPDATE users SET subject = ?, updated_at = ?
WHERE subject = ?`
).run(crypto.randomBytes(16).toString("hex"), nowIso(), providerAccountId);
},
async createSession({ sessionToken, userId, expires }): Promise<AdapterSession> {
const expiresAt = expires.toISOString();
db.prepare(
`INSERT INTO sessions (user_id, token, expires_at, created_at)
VALUES (?, ?, ?, ?)`
).run(userId, sessionToken, expiresAt, nowIso());
return {
sessionToken,
userId,
expires
};
},
async getSessionAndUser(sessionToken: string): Promise<{ session: AdapterSession; user: AdapterUser } | null> {
const result = db.prepare(
`SELECT s.token, s.user_id, s.expires_at, u.id, u.email, u.name, u.avatar_url
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.token = ?`
).get(sessionToken) as any;
if (!result) return null;
const expires = new Date(result.expires_at);
if (expires.getTime() < Date.now()) {
db.prepare("DELETE FROM sessions WHERE token = ?").run(sessionToken);
return null;
}
return {
session: {
sessionToken: result.token,
userId: String(result.user_id),
expires
},
user: {
id: String(result.id),
email: result.email,
emailVerified: null,
name: result.name,
image: result.avatar_url
}
};
},
async updateSession(session: Partial<AdapterSession> & Pick<AdapterSession, "sessionToken">): Promise<AdapterSession | null | undefined> {
if (session.expires) {
db.prepare(
"UPDATE sessions SET expires_at = ? WHERE token = ?"
).run(session.expires.toISOString(), session.sessionToken);
}
const existing = db.prepare("SELECT * FROM sessions WHERE token = ?").get(session.sessionToken) as any;
if (!existing) return null;
return {
sessionToken: session.sessionToken,
userId: String(existing.user_id),
expires: session.expires || new Date(existing.expires_at)
};
},
async deleteSession(sessionToken: string): Promise<void> {
db.prepare("DELETE FROM sessions WHERE token = ?").run(sessionToken);
},
// Verification tokens not currently used, but required by adapter interface
async createVerificationToken(token: VerificationToken): Promise<VerificationToken | null | undefined> {
return token;
},
async useVerificationToken({ identifier, token }): Promise<VerificationToken | null> {
return null;
}
};
}

View File

@@ -15,6 +15,25 @@ type TokenResponse = {
id_token?: string;
};
/**
* Validates that a redirect path is safe for internal redirection.
* Only allows paths that start with / but not //
* @param path - The path to validate
* @returns true if the path is safe, false otherwise
*/
function isValidRedirectPath(path: string): boolean {
if (!path) return false;
// Must start with / but not // (which could redirect to external site)
// Must not contain any protocol (http:, https:, ftp:, etc.)
if (!path.startsWith('/')) return false;
if (path.startsWith('//')) return false;
// Check for encoded slashes and protocols
if (path.includes('%2f%2f') || path.toLowerCase().includes('%2f%2f')) return false;
// Ensure no protocol specification
if (/^\/[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path)) return false;
return true;
}
export function requireOAuthSettings(): OAuthSettings {
const settings = getOAuthSettings();
if (!settings) {
@@ -66,10 +85,22 @@ function consumeOAuthState(state: string): { codeVerifier: string; redirectTo: s
export function buildAuthorizationUrl(redirectTo?: string): string {
const settings = requireOAuthSettings();
// Validate redirectTo parameter to prevent open redirect attacks
let safeRedirectTo: string | undefined;
if (redirectTo) {
if (!isValidRedirectPath(redirectTo)) {
console.warn(`Invalid redirectTo parameter rejected: ${redirectTo}`);
safeRedirectTo = undefined;
} else {
safeRedirectTo = redirectTo;
}
}
const state = crypto.randomBytes(24).toString("base64url");
const verifier = createCodeVerifier();
const challenge = codeChallengeFromVerifier(verifier);
storeOAuthState(state, verifier, redirectTo);
storeOAuthState(state, verifier, safeRedirectTo);
const redirectUri = `${config.baseUrl}/api/auth/callback`;
const url = new URL(settings.authorizationUrl);

View File

@@ -10,11 +10,7 @@ const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
type CookiesHandle = Awaited<ReturnType<typeof cookies>>;
async function getCookieStore(): Promise<CookiesHandle> {
const store = cookies();
if (typeof (store as any)?.then === "function") {
return (await store) as CookiesHandle;
}
return store as CookiesHandle;
return (await cookies()) as CookiesHandle;
}
function hashToken(token: string): string {
@@ -67,8 +63,8 @@ export async function createSession(userId: number): Promise<SessionRecord> {
};
const cookieStore = await getCookieStore();
if (typeof (cookieStore as any).set === "function") {
(cookieStore as any).set({
if (typeof cookieStore.set === "function") {
cookieStore.set({
name: SESSION_COOKIE,
value: token,
httpOnly: true,
@@ -86,9 +82,9 @@ export async function createSession(userId: number): Promise<SessionRecord> {
export async function destroySession() {
const cookieStore = await getCookieStore();
const token = typeof (cookieStore as any).get === "function" ? cookieStore.get(SESSION_COOKIE) : undefined;
if (typeof (cookieStore as any).delete === "function") {
(cookieStore as any).delete(SESSION_COOKIE);
const token = typeof cookieStore.get === "function" ? cookieStore.get(SESSION_COOKIE) : undefined;
if (typeof cookieStore.delete === "function") {
cookieStore.delete(SESSION_COOKIE);
}
if (!token) {
@@ -101,7 +97,7 @@ export async function destroySession() {
export async function getSession(): Promise<SessionContext | null> {
const cookieStore = await getCookieStore();
const token = typeof (cookieStore as any).get === "function" ? cookieStore.get(SESSION_COOKIE) : undefined;
const token = typeof cookieStore.get === "function" ? cookieStore.get(SESSION_COOKIE) : undefined;
if (!token) {
return null;
}
@@ -116,17 +112,11 @@ export async function getSession(): Promise<SessionContext | null> {
.get(hashed) as SessionRecord | undefined;
if (!session) {
if (typeof (cookieStore as any).delete === "function") {
(cookieStore as any).delete(SESSION_COOKIE);
}
return null;
}
if (new Date(session.expires_at).getTime() < Date.now()) {
db.prepare("DELETE FROM sessions WHERE id = ?").run(session.id);
if (typeof (cookieStore as any).delete === "function") {
(cookieStore as any).delete(SESSION_COOKIE);
}
return null;
}
@@ -138,9 +128,6 @@ export async function getSession(): Promise<SessionContext | null> {
.get(session.user_id) as UserRecord | undefined;
if (!user || user.status !== "active") {
if (typeof (cookieStore as any).delete === "function") {
(cookieStore as any).delete(SESSION_COOKIE);
}
return null;
}

View File

@@ -178,6 +178,28 @@ const MIGRATIONS: Migration[] = [
);
`);
}
},
{
id: 2,
description: "add provider type to OAuth settings",
up: (db) => {
// Add providerType field to existing OAuth settings
// Default to 'authentik' for existing installations since that's what we're supporting
const settings = db.prepare("SELECT value FROM settings WHERE key = 'oauth'").get() as { value: string } | undefined;
if (settings) {
try {
const oauth = JSON.parse(settings.value);
// Only update if providerType doesn't exist
if (!oauth.providerType) {
oauth.providerType = 'authentik';
db.prepare("UPDATE settings SET value = ? WHERE key = 'oauth'").run(JSON.stringify(oauth));
}
} catch (e) {
console.error("Failed to migrate OAuth settings:", e);
}
}
}
}
];

View File

@@ -3,6 +3,7 @@ import db, { nowIso } from "./db";
export type SettingValue<T> = T | null;
export type OAuthSettings = {
providerType: "authentik" | "generic";
authorizationUrl: string;
tokenUrl: string;
clientId: string;

View File

@@ -17,6 +17,7 @@
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
"types": [
"node"
],

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long