implement oauth2 login
This commit is contained in:
67
README.md
67
README.md
@@ -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.js 16 (App Router), Material UI, 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.js 16 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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") ?? ""),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/src/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") ?? ""),
|
||||
|
||||
@@ -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
103
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
215
src/lib/auth.ts
Normal 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
205
src/lib/auth/adapter.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user