From be21f46ad50bba08f9afa167c78fefc3c5987824 Mon Sep 17 00:00:00 2001 From: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Sun, 28 Dec 2025 15:14:56 +0100 Subject: [PATCH] Added user tab and oauth2, streamlined readme --- .env.example | 28 + README.md | 191 +-- app/(auth)/link-account/LinkAccountClient.tsx | 141 +++ app/(auth)/link-account/page.tsx | 43 + app/(auth)/login/LoginClient.tsx | 84 +- app/(auth)/login/page.tsx | 5 +- app/(dashboard)/DashboardLayoutClient.tsx | 22 +- app/(dashboard)/profile/ProfileClient.tsx | 556 ++++++++ app/(dashboard)/profile/page.tsx | 23 + app/api/auth/link-account/route.ts | 96 ++ app/api/user/change-password/route.ts | 75 ++ app/api/user/link-oauth-start/route.ts | 72 ++ app/api/user/unlink-oauth/route.ts | 76 ++ app/api/user/update-avatar/route.ts | 77 ++ docker-compose.yml | 11 + drizzle/0001_adorable_sally_floyd.sql | 8 + drizzle/0002_perfect_hedge_knight.sql | 14 + drizzle/meta/0001_snapshot.json | 63 +- drizzle/meta/0002_snapshot.json | 1121 +++++++++++++++++ drizzle/meta/_journal.json | 16 +- package-lock.json | 140 +- package.json | 12 +- src/lib/auth.ts | 297 ++++- src/lib/config.ts | 33 + src/lib/db/schema.ts | 12 + src/lib/models/audit.ts | 21 +- src/lib/models/user.ts | 2 +- src/lib/services/account-linking.ts | 219 ++++ 28 files changed, 3213 insertions(+), 245 deletions(-) create mode 100644 app/(auth)/link-account/LinkAccountClient.tsx create mode 100644 app/(auth)/link-account/page.tsx create mode 100644 app/(dashboard)/profile/ProfileClient.tsx create mode 100644 app/(dashboard)/profile/page.tsx create mode 100644 app/api/auth/link-account/route.ts create mode 100644 app/api/user/change-password/route.ts create mode 100644 app/api/user/link-oauth-start/route.ts create mode 100644 app/api/user/unlink-oauth/route.ts create mode 100644 app/api/user/update-avatar/route.ts create mode 100644 drizzle/0001_adorable_sally_floyd.sql create mode 100644 drizzle/0002_perfect_hedge_knight.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 src/lib/services/account-linking.ts diff --git a/.env.example b/.env.example index 05e48325..4adfac55 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,34 @@ ADMIN_PASSWORD=Your-Secure-P@ssw0rd-Here! # Public base URL for the application BASE_URL=http://localhost:3000 +# ============================================================================= +# OAUTH2/OIDC AUTHENTICATION (OPTIONAL) +# ============================================================================= + +# OAuth2/OIDC Provider (works with Authentik, Authelia, Keycloak, etc.) +# Enable OAuth2 authentication with any OIDC-compliant provider +OAUTH_ENABLED=false +OAUTH_PROVIDER_NAME=OAuth2 # Display name (e.g., "Authentik", "Keycloak") +OAUTH_CLIENT_ID= +OAUTH_CLIENT_SECRET= +OAUTH_ISSUER= # OIDC discovery URL (e.g., https://auth.example.com/application/o/app/) + +# Optional: Override auto-discovered URLs (only if OIDC discovery doesn't work) +# OAUTH_AUTHORIZATION_URL= +# OAUTH_TOKEN_URL= +# OAUTH_USERINFO_URL= + +# OAuth Settings +OAUTH_ALLOW_AUTO_LINKING=false # Auto-link OAuth to accounts without passwords + +# Example for Authentik: +# OAUTH_ENABLED=true +# OAUTH_PROVIDER_NAME=Authentik +# OAUTH_CLIENT_ID=your-client-id +# OAUTH_CLIENT_SECRET=your-client-secret +# OAUTH_ISSUER=https://auth.example.com/application/o/caddy-proxy/ +# Redirect URI: {BASE_URL}/api/auth/callback/oauth2 + # ============================================================================= # OPTIONAL: ADVANCED CONFIGURATION # ============================================================================= diff --git a/README.md b/README.md index ae77da60..890627c1 100644 --- a/README.md +++ b/README.md @@ -17,69 +17,39 @@ This project provides a web UI for Caddy Server, eliminating the need to manuall **Key features:** - Reverse proxy configuration with upstream pools and custom headers - HTTP basic auth access lists +- OAuth2/OIDC authentication support - Automatic HTTPS via Caddy's ACME (Let's Encrypt) with Cloudflare DNS-01 support - Custom certificate import (internal CA, wildcards, etc.) - Audit logging of all configuration changes -- Login rate limiting and session management - Built with Next.js 16, React 19, Drizzle ORM, and TypeScript --- ## Installation -### Docker Compose - ```bash git clone https://github.com/fuomag9/caddy-proxy-manager.git cd caddy-proxy-manager cp .env.example .env -# Edit .env with your credentials (see Configuration section below) +# Edit .env with your credentials docker compose up -d ``` -The stack includes: -- `web` - Next.js app with SQLite database -- `caddy` - Custom Caddy build (includes Cloudflare DNS and Layer4 modules) +Access at `http://localhost:3000/login` -Data is persisted in: -- `./data` - Application database and certificates -- `./caddy-data` - ACME certificates -- `./caddy-config` - Caddy runtime config - -### Local Development - -```bash -npm install -cp .env.example .env -# Edit .env with your credentials -npm run dev -``` - -Access the interface at `http://localhost:3000/login`. - -Login attempts are rate-limited (5 attempts per 5 minutes, 15 minute lockout after repeated failures). +Data persists in `./data`, `./caddy-data`, and `./caddy-config`. --- ## Features -| Module | Description | -|--------|-------------| -| **Proxy Hosts** | HTTP/HTTPS reverse proxies with upstream pools, custom headers, Authentik forward auth | -| **Redirects** | 301/302 redirects with optional query string preservation | -| **Dead Hosts** | Maintenance pages with custom status codes | -| **Access Lists** | HTTP basic auth user management for proxy hosts | -| **Certificates** | Custom SSL/TLS certificate import (Caddy auto-manages Let's Encrypt) | -| **Settings** | ACME email and Cloudflare API configuration | -| **Audit Log** | Chronological log of all configuration changes | - -**Technical Stack:** -- Next.js 16 App Router with React 19 -- Material UI (dark theme) -- Drizzle ORM with SQLite -- Direct integration with Caddy Admin API -- Cloudflare DNS-01 challenge support -- bcrypt for access list password hashing +- **Proxy Hosts** - Reverse proxies with custom headers and upstream pools +- **Redirects** - 301/302 redirects +- **Dead Hosts** - Maintenance pages +- **Access Lists** - HTTP basic auth +- **Certificates** - Custom SSL/TLS import (automatic Let's Encrypt via Caddy) +- **Settings** - ACME email and Cloudflare DNS-01 configuration +- **Audit Log** - Configuration change tracking --- @@ -99,153 +69,68 @@ Login attempts are rate-limited (5 attempts per 5 minutes, 15 minute lockout aft | `LOGIN_MAX_ATTEMPTS` | Max login attempts before rate limit | `5` | No | | `LOGIN_WINDOW_MS` | Rate limit window in milliseconds | `300000` (5 min) | No | | `LOGIN_BLOCK_MS` | Rate limit block duration in milliseconds | `900000` (15 min) | No | +| `OAUTH_ENABLED` | Enable OAuth2/OIDC authentication | `false` | No | +| `OAUTH_PROVIDER_NAME` | Display name for OAuth provider | `OAuth2` | No | +| `OAUTH_CLIENT_ID` | OAuth2 client ID | None | No | +| `OAUTH_CLIENT_SECRET` | OAuth2 client secret | None | No | +| `OAUTH_ISSUER` | OAuth2 OIDC issuer URL | None | No | -**Production Security Requirements (Strictly Enforced):** +**Production Requirements:** +- `SESSION_SECRET`: 32+ characters (`openssl rand -base64 32`) +- `ADMIN_PASSWORD`: 12+ chars with uppercase, lowercase, numbers, and special characters -The application will **fail to start** in production if these requirements are not met: - -- **`SESSION_SECRET`**: - - Must be at least 32 characters long - - Cannot be a known placeholder value - - Generate with: `openssl rand -base64 32` - -- **`ADMIN_USERNAME`**: - - Must be set (any value is acceptable, including `admin`) - -- **`ADMIN_PASSWORD`**: - - Minimum 12 characters - - Must include uppercase letters (A-Z) - - Must include lowercase letters (a-z) - - Must include numbers (0-9) - - Must include special characters (!@#$%^&* etc.) - - Cannot be `admin` in production - -**Development Mode:** -- Default credentials (`admin`/`admin`) are allowed in development -- Set `NODE_ENV=development` to use relaxed validation +Development mode (`NODE_ENV=development`) allows default `admin`/`admin` credentials. --- -## Architecture - -``` -caddy-proxy-manager/ -├── app/ # Next.js App Router -│ ├── (auth)/ # Authentication pages -│ ├── (dashboard)/ # Dashboard and feature modules -│ ├── api/ # API routes -│ └── providers.tsx # Theme providers -├── src/lib/ # Core business logic -│ ├── models/ # Database models -│ ├── caddy/ # Caddy config generation -│ └── auth/ # Authentication -├── drizzle/ # Database migrations -├── docker/ -│ ├── web/ # Next.js Dockerfile -│ └── caddy/ # Custom Caddy build -├── docker-compose.yml # Deployment stack -└── data/ # SQLite + certificates -``` - ---- ## Security -**Authentication:** -- Production mode enforces strong credentials (12+ chars, mixed case, numbers, special characters) -- Application refuses to start with weak passwords in production +- Production enforces strong passwords (12+ chars, mixed case, numbers, special characters) - 32+ character session secrets required -- Login rate limiting: 5 attempts per 5 minutes, 15 minute lockout -- Single admin user model - -**Data Protection:** -- Imported certificates stored with `0600` permissions -- Session encryption with validated secrets -- API tokens redacted after initial entry +- Login rate limiting: 5 attempts per 5 minutes - Audit trail for all configuration changes -- HSTS headers applied to managed hosts +- Supports OAuth2/OIDC for SSO **Production Setup:** ```bash export SESSION_SECRET=$(openssl rand -base64 32) export ADMIN_USERNAME="admin" export ADMIN_PASSWORD="YourStr0ng-P@ssw0rd123!" -echo "SESSION_SECRET=$SESSION_SECRET" > .env -echo "ADMIN_USERNAME=$ADMIN_USERNAME" >> .env -echo "ADMIN_PASSWORD=$ADMIN_PASSWORD" >> .env -chmod 600 .env -``` - -**Development Mode:** -```bash -export NODE_ENV=development -npm run dev -# Login with admin/admin +docker compose up -d ``` **Limitations:** - Certificate private keys stored unencrypted in SQLite - In-memory rate limiting (not suitable for multi-instance deployments) -- No 2FA support --- ## Certificate Management -**Automatic HTTPS (Default):** +Caddy automatically obtains Let's Encrypt certificates for all proxy hosts. -Caddy automatically obtains and renews Let's Encrypt certificates for all proxy hosts. Just add a domain and certificates are handled automatically. +**Cloudflare DNS-01** (optional): Configure in Settings with a Cloudflare API token (`Zone.DNS:Edit` permissions). -For automatic certificates, configure Cloudflare DNS-01 in Settings (see below). - -**Custom Certificates (Optional):** - -Import your own certificates for: -- Internal CA certificates -- Certificates from other providers -- Compliance requirements - -To import: -1. Go to Certificates page -2. Click Import Custom Certificate -3. Enter certificate name and domains -4. Paste certificate PEM (full chain recommended) -5. Paste private key PEM -6. Assign to proxy hosts as needed - -Note: Private keys are stored in SQLite without encryption. +**Custom Certificates** (optional): Import your own certificates via the Certificates page. Private keys are stored unencrypted in SQLite. --- -## Cloudflare DNS-01 Setup +## OAuth Authentication -For automatic certificates via DNS-01 challenge: +Supports any OIDC-compliant provider (Authentik, Keycloak, Auth0, etc.). -1. Go to Settings -2. Create a Cloudflare API token with `Zone.DNS:Edit` permissions -3. Enter token (not displayed again after saving) -4. Optionally add Zone ID / Account ID -5. Set ACME email for certificate notifications +```bash +OAUTH_ENABLED=true +OAUTH_PROVIDER_NAME="Authentik" # Display name +OAUTH_CLIENT_ID=your-client-id +OAUTH_CLIENT_SECRET=your-client-secret +OAUTH_ISSUER=https://auth.example.com/application/o/app/ +``` -To revoke: Select "Remove existing token" in Settings. +**Redirect URI**: `{BASE_URL}/api/auth/callback/oauth2` ---- - -## Development - -| Command | Description | -|---------|-------------| -| `npm run dev` | Development server with hot reload | -| `npm run build` | Production build | -| `npm start` | Run production server | -| `npm run typecheck` | TypeScript type checking | -| `npm run db:migrate` | Apply database migrations | - -**Notes:** -- Drizzle migrations are in `/drizzle` -- Caddy config regenerated on each mutation and pushed via Admin API -- Rate limiting is in-memory (not suitable for multi-instance deployments) -- Single admin user architecture +OAuth login appears on the login page alongside credentials. Users can link OAuth to existing accounts from the Profile page. --- diff --git a/app/(auth)/link-account/LinkAccountClient.tsx b/app/(auth)/link-account/LinkAccountClient.tsx new file mode 100644 index 00000000..7838e62d --- /dev/null +++ b/app/(auth)/link-account/LinkAccountClient.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useState, FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Stack, + TextField, + Typography +} from "@mui/material"; +import { signIn } from "next-auth/react"; + +interface LinkAccountClientProps { + provider: string; + email: string; + linkingToken: string; +} + +export default function LinkAccountClient({ + provider, + email, + linkingToken +}: LinkAccountClientProps) { + const router = useRouter(); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleLinkAccount = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setLoading(true); + + try { + // Call API to verify password and link account + const response = await fetch("/api/auth/link-account", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + linkingToken, + password + }) + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || "Failed to link account"); + setLoading(false); + return; + } + + // Successfully linked - sign in with OAuth + // The provider should now recognize the linked account + await signIn(provider, { + callbackUrl: "/" + }); + } catch (err) { + setError("An error occurred while linking your account"); + setLoading(false); + } + }; + + const handleUsePassword = () => { + router.push("/login"); + }; + + const providerName = provider.charAt(0).toUpperCase() + provider.slice(1); + + return ( + + + + + + + Link Your Account + + + An account with {email} already exists + + + + + Would you like to link your {providerName} account + to your existing account? Enter your password to confirm. + + + {error && {error}} + + + setPassword(e.target.value)} + required + fullWidth + autoComplete="current-password" + autoFocus + disabled={loading} + /> + + + + + + + + + + ); +} diff --git a/app/(auth)/link-account/page.tsx b/app/(auth)/link-account/page.tsx new file mode 100644 index 00000000..bae37865 --- /dev/null +++ b/app/(auth)/link-account/page.tsx @@ -0,0 +1,43 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/src/lib/auth"; +import { verifyLinkingToken } from "@/src/lib/services/account-linking"; +import LinkAccountClient from "./LinkAccountClient"; + +interface LinkAccountPageProps { + searchParams: { + error?: string; + }; +} + +export default async function LinkAccountPage({ searchParams }: LinkAccountPageProps) { + const session = await auth(); + + // Already authenticated - redirect + if (session) { + redirect("/"); + } + + // Get linking token from error parameter (NextAuth redirects with error param) + const errorParam = searchParams.error || ""; + + if (!errorParam.startsWith("LINKING_REQUIRED:")) { + redirect("/login?error=Invalid linking request"); + } + + const linkingToken = errorParam.replace("LINKING_REQUIRED:", ""); + + // Verify token and decode + const tokenPayload = await verifyLinkingToken(linkingToken); + + if (!tokenPayload) { + redirect("/login?error=Linking token expired or invalid"); + } + + return ( + + ); +} diff --git a/app/(auth)/login/LoginClient.tsx b/app/(auth)/login/LoginClient.tsx index 234f61f4..afe8445f 100644 --- a/app/(auth)/login/LoginClient.tsx +++ b/app/(auth)/login/LoginClient.tsx @@ -2,13 +2,19 @@ import { useRouter } from "next/navigation"; import { FormEvent, useState } from "react"; -import { Alert, Box, Button, Card, CardContent, Stack, TextField, Typography } from "@mui/material"; +import { Alert, Box, Button, Card, CardContent, Divider, Stack, TextField, Typography } from "@mui/material"; import { signIn } from "next-auth/react"; +import LoginIcon from "@mui/icons-material/Login"; -export default function LoginClient() { +interface LoginClientProps { + enabledProviders: Array<{id: string; name: string}>; +} + +export default function LoginClient({ enabledProviders = [] }: LoginClientProps) { const router = useRouter(); const [loginError, setLoginError] = useState(null); const [loginPending, setLoginPending] = useState(false); + const [oauthPending, setOauthPending] = useState(null); const handleSignIn = async (event: FormEvent) => { event.preventDefault(); @@ -52,6 +58,20 @@ export default function LoginClient() { router.refresh(); }; + const handleOAuthSignIn = async (providerId: string) => { + setLoginError(null); + setOauthPending(providerId); + + try { + await signIn(providerId, { + callbackUrl: "/" + }); + } catch (error) { + setLoginError(`Failed to sign in with OAuth`); + setOauthPending(null); + } + }; + return ( @@ -61,15 +81,67 @@ export default function LoginClient() { Caddy Proxy Manager - Sign in with your credentials + + {enabledProviders.length > 0 ? "Sign in to your account" : "Sign in with your credentials"} + {loginError && {loginError}} + {/* OAuth Providers */} + {enabledProviders.length > 0 && ( + + {enabledProviders.map((provider) => { + const isPending = oauthPending === provider.id; + return ( + + ); + })} + + + + Or sign in with credentials + + + + )} + - - - diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 46188780..0bb940e5 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,5 +1,6 @@ import { redirect } from "next/navigation"; import { auth } from "@/src/lib/auth"; +import { getEnabledOAuthProviders } from "@/src/lib/config"; import LoginClient from "./LoginClient"; export default async function LoginPage() { @@ -8,5 +9,7 @@ export default async function LoginPage() { redirect("/"); } - return ; + const enabledProviders = getEnabledOAuthProviders(); + + return ; } diff --git a/app/(dashboard)/DashboardLayoutClient.tsx b/app/(dashboard)/DashboardLayoutClient.tsx index 543f6a88..fd66be03 100644 --- a/app/(dashboard)/DashboardLayoutClient.tsx +++ b/app/(dashboard)/DashboardLayoutClient.tsx @@ -9,6 +9,7 @@ type User = { id: string; name?: string | null; email?: string | null; + image?: string | null; }; const NAV_ITEMS = [ @@ -139,8 +140,27 @@ export default function DashboardLayoutClient({ user, children }: { user: User; - + ; +} + +export default function ProfileClient({ user, enabledProviders }: ProfileClientProps) { + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + const [unlinkDialogOpen, setUnlinkDialogOpen] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [loading, setLoading] = useState(false); + const [avatarUrl, setAvatarUrl] = useState(user.avatar_url); + + const hasPassword = !!user.password_hash; + const hasOAuth = user.provider !== "credentials"; + const isCredentialsOnly = user.provider === "credentials"; + + const handlePasswordChange = async () => { + setError(null); + setSuccess(null); + + if (newPassword !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + if (newPassword.length < 12) { + setError("Password must be at least 12 characters long"); + return; + } + + setLoading(true); + + try { + const response = await fetch("/api/user/change-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currentPassword, + newPassword + }) + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || "Failed to change password"); + setLoading(false); + return; + } + + setSuccess("Password changed successfully"); + setPasswordDialogOpen(false); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setLoading(false); + } catch (err) { + setError("An error occurred while changing password"); + setLoading(false); + } + }; + + const handleUnlinkOAuth = async () => { + if (!hasPassword) { + setError("Cannot unlink OAuth: You must set a password first"); + return; + } + + setError(null); + setSuccess(null); + setLoading(true); + + try { + const response = await fetch("/api/user/unlink-oauth", { + method: "POST", + headers: { "Content-Type": "application/json" } + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || "Failed to unlink OAuth"); + setLoading(false); + return; + } + + setSuccess("OAuth account unlinked successfully. Reloading..."); + setUnlinkDialogOpen(false); + setLoading(false); + + // Reload page to reflect changes + setTimeout(() => window.location.reload(), 1500); + } catch (err) { + setError("An error occurred while unlinking OAuth"); + setLoading(false); + } + }; + + const handleLinkOAuth = async (providerId: string) => { + setError(null); + setSuccess(null); + setLoading(true); + + try { + // Set a cookie to indicate this is a linking attempt + const response = await fetch("/api/user/link-oauth-start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerId }) + }); + + if (!response.ok) { + const data = await response.json(); + setError(data.error || "Failed to start OAuth linking"); + setLoading(false); + return; + } + + // Now initiate OAuth flow + await signIn(providerId, { + callbackUrl: "/profile" + }); + } catch (err) { + setError("An error occurred while linking OAuth"); + setLoading(false); + } + }; + + const handleAvatarUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + // Validate file type + if (!file.type.startsWith("image/")) { + setError("Please upload an image file"); + return; + } + + // Validate file size (max 2MB) + if (file.size > 2 * 1024 * 1024) { + setError("Image must be smaller than 2MB"); + return; + } + + setError(null); + setLoading(true); + + try { + // Convert to base64 + const reader = new FileReader(); + reader.onloadend = async () => { + const base64 = reader.result as string; + + const response = await fetch("/api/user/update-avatar", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ avatarUrl: base64 }) + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || "Failed to upload avatar"); + setLoading(false); + return; + } + + setAvatarUrl(base64); + setSuccess("Avatar updated successfully. Refreshing..."); + setLoading(false); + + setTimeout(() => window.location.reload(), 1000); + }; + + reader.readAsDataURL(file); + } catch (err) { + setError("An error occurred while uploading avatar"); + setLoading(false); + } + }; + + const handleAvatarDelete = async () => { + setError(null); + setLoading(true); + + try { + const response = await fetch("/api/user/update-avatar", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ avatarUrl: null }) + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.error || "Failed to delete avatar"); + setLoading(false); + return; + } + + setAvatarUrl(null); + setSuccess("Avatar removed successfully. Refreshing..."); + setLoading(false); + + setTimeout(() => window.location.reload(), 1000); + } catch (err) { + setError("An error occurred while deleting avatar"); + setLoading(false); + } + }; + + const getProviderName = (provider: string) => { + if (provider === "credentials") return "Username/Password"; + if (provider === "oauth2") return "OAuth2"; + if (provider === "authentik") return "Authentik"; + return provider; + }; + + const getProviderColor = (provider: string) => { + if (provider === "credentials") return "default"; + return "primary"; + }; + + return ( + + + Profile & Account Settings + + + {error && ( + setError(null)}> + {error} + + )} + + {success && ( + setSuccess(null)}> + {success} + + )} + + + {/* Account Information */} + + + + + + Account Information + + + + + {/* Avatar Section */} + + + Profile Picture + + + + {(!avatarUrl && user.name) ? user.name.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase()} + + + + {avatarUrl && ( + + + + )} + + + + Recommended: Square image, max 2MB + + + + + + + + Email + + {user.email} + + + + + Name + + {user.name || "Not set"} + + + + + Role + + + + + + + Authentication Method + + + + + {hasPassword && ( + + + Password + + + ✓ Password is set + + + )} + + + + + {/* Password Management */} + + + + + + Password Management + + + + + {hasPassword ? ( + + + Change your password to maintain account security + + + + ) : ( + + + You are using OAuth-only authentication. Setting a password will allow you to + sign in with either OAuth or credentials. + + + + )} + + + + + {/* OAuth Management */} + {enabledProviders.length > 0 && ( + + + + + + OAuth Connections + + + + + {hasOAuth ? ( + + + Your account is linked to {getProviderName(user.provider)} + + + {hasPassword ? ( + + ) : ( + + To unlink OAuth, you must first set a password as a fallback authentication + method. + + )} + + ) : ( + + + Link an OAuth provider to enable single sign-on + + + + {enabledProviders.map((provider) => ( + + ))} + + + )} + + + + )} + + + {/* Change Password Dialog */} + setPasswordDialogOpen(false)} maxWidth="sm" fullWidth> + {hasPassword ? "Change Password" : "Set Password"} + + + {hasPassword && ( + setCurrentPassword(e.target.value)} + fullWidth + autoComplete="current-password" + /> + )} + setNewPassword(e.target.value)} + fullWidth + autoComplete="new-password" + helperText="Minimum 12 characters" + /> + setConfirmPassword(e.target.value)} + fullWidth + autoComplete="new-password" + /> + + + + + + + + + {/* Unlink OAuth Dialog */} + setUnlinkDialogOpen(false)} maxWidth="sm" fullWidth> + Unlink OAuth Account + + + Are you sure you want to unlink your {getProviderName(user.provider)} account? + You will only be able to sign in with your username and password after this. + + + + + + + + + ); +} diff --git a/app/(dashboard)/profile/page.tsx b/app/(dashboard)/profile/page.tsx new file mode 100644 index 00000000..765db370 --- /dev/null +++ b/app/(dashboard)/profile/page.tsx @@ -0,0 +1,23 @@ +import { requireUser } from "@/src/lib/auth"; +import { getUserById } from "@/src/lib/models/user"; +import { getEnabledOAuthProviders } from "@/src/lib/config"; +import ProfileClient from "./ProfileClient"; +import { redirect } from "next/navigation"; + +export default async function ProfilePage() { + const session = await requireUser(); + + const user = await getUserById(Number(session.user.id)); + if (!user) { + redirect("/login"); + } + + const enabledProviders = getEnabledOAuthProviders(); + + return ( + + ); +} diff --git a/app/api/auth/link-account/route.ts b/app/api/auth/link-account/route.ts new file mode 100644 index 00000000..28064d46 --- /dev/null +++ b/app/api/auth/link-account/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyLinkingToken, verifyAndLinkOAuth } from "@/src/lib/services/account-linking"; +import { createAuditEvent } from "@/src/lib/models/audit"; +import { registerFailedAttempt } from "@/src/lib/rate-limit"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { linkingToken, password } = body; + + if (!linkingToken || !password) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + // Verify linking token + const tokenPayload = await verifyLinkingToken(linkingToken); + if (!tokenPayload) { + return NextResponse.json( + { error: "Authentication failed" }, + { status: 401 } + ); + } + + // Rate limiting: prevent brute force password attacks during OAuth linking + const rateLimitKey = `oauth-link-verify:${tokenPayload.userId}`; + const rateLimitResult = registerFailedAttempt(rateLimitKey); + if (rateLimitResult.blocked) { + // Audit log for blocked attempt + await createAuditEvent({ + userId: tokenPayload.userId, + action: "oauth_link_rate_limited", + entityType: "user", + entityId: tokenPayload.userId, + summary: `OAuth linking rate limited: too many password attempts`, + data: JSON.stringify({ provider: tokenPayload.provider }) + }); + + return NextResponse.json( + { error: "Too many attempts. Please try again later." }, + { status: 429 } + ); + } + + // Verify password and link OAuth account + const success = await verifyAndLinkOAuth( + tokenPayload.userId, + password, + tokenPayload.provider, + tokenPayload.providerAccountId + ); + + if (!success) { + // Audit log for failed password verification + await createAuditEvent({ + userId: tokenPayload.userId, + action: "oauth_link_password_failed", + entityType: "user", + entityId: tokenPayload.userId, + summary: `Failed password verification during OAuth linking`, + data: JSON.stringify({ provider: tokenPayload.provider }) + }); + + return NextResponse.json( + { error: "Authentication failed" }, + { status: 401 } + ); + } + + // Audit log + await createAuditEvent({ + userId: tokenPayload.userId, + action: "account_linked", + entityType: "user", + entityId: tokenPayload.userId, + summary: `OAuth account manually linked: ${tokenPayload.provider}`, + data: JSON.stringify({ + provider: tokenPayload.provider, + email: tokenPayload.email + }) + }); + + return NextResponse.json({ + success: true, + message: "Account linked successfully" + }); + } catch (error) { + console.error("Account linking error:", error); + return NextResponse.json( + { error: "Failed to link account" }, + { status: 500 } + ); + } +} diff --git a/app/api/user/change-password/route.ts b/app/api/user/change-password/route.ts new file mode 100644 index 00000000..5f591984 --- /dev/null +++ b/app/api/user/change-password/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/src/lib/auth"; +import { getUserById, updateUserPassword } from "@/src/lib/models/user"; +import { createAuditEvent } from "@/src/lib/models/audit"; +import bcrypt from "bcryptjs"; + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { currentPassword, newPassword } = body; + + if (!newPassword || newPassword.length < 12) { + return NextResponse.json( + { error: "New password must be at least 12 characters long" }, + { status: 400 } + ); + } + + const userId = Number(session.user.id); + const user = await getUserById(userId); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + // If user has a password, verify current password + if (user.password_hash) { + if (!currentPassword) { + return NextResponse.json( + { error: "Current password is required" }, + { status: 400 } + ); + } + + const isValid = bcrypt.compareSync(currentPassword, user.password_hash); + if (!isValid) { + return NextResponse.json( + { error: "Current password is incorrect" }, + { status: 401 } + ); + } + } + + // Hash new password + const newPasswordHash = bcrypt.hashSync(newPassword, 12); + + // Update password + await updateUserPassword(userId, newPasswordHash); + + // Audit log + await createAuditEvent({ + userId, + action: user.password_hash ? "password_changed" : "password_set", + entityType: "user", + entityId: userId, + summary: user.password_hash ? "User changed their password" : "User set a password", + }); + + return NextResponse.json({ + success: true, + message: "Password updated successfully" + }); + } catch (error) { + console.error("Password change error:", error); + return NextResponse.json( + { error: "Failed to change password" }, + { status: 500 } + ); + } +} diff --git a/app/api/user/link-oauth-start/route.ts b/app/api/user/link-oauth-start/route.ts new file mode 100644 index 00000000..fc36267d --- /dev/null +++ b/app/api/user/link-oauth-start/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/src/lib/auth"; +import db, { nowIso } from "@/src/lib/db"; +import { pendingOAuthLinks } from "@/src/lib/db/schema"; +import { eq, and, lt } from "drizzle-orm"; +import { registerFailedAttempt } from "@/src/lib/rate-limit"; + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = Number(session.user.id); + + // Rate limiting: prevent OAuth linking spam + const rateLimitKey = `oauth-link:${userId}`; + const rateLimitResult = registerFailedAttempt(rateLimitKey); + if (rateLimitResult.blocked) { + return NextResponse.json( + { error: "Too many OAuth linking attempts. Please try again later." }, + { status: 429 } + ); + } + + const body = await request.json(); + const { provider } = body; + + if (!provider) { + return NextResponse.json({ error: "Provider is required" }, { status: 400 }); + } + + const userEmail = session.user.email; + + if (!userEmail) { + return NextResponse.json({ error: "User email not found" }, { status: 400 }); + } + + const now = new Date(); + const expiresAt = new Date(now.getTime() + 5 * 60 * 1000); // 5 minutes from now + + // Clean up old expired entries for all users + await db.delete(pendingOAuthLinks).where(lt(pendingOAuthLinks.expiresAt, nowIso())); + + // Delete any existing pending link for THIS USER and this provider + // (unique index will prevent duplicates, but we delete explicitly for clarity) + await db.delete(pendingOAuthLinks).where( + and( + eq(pendingOAuthLinks.userId, userId), + eq(pendingOAuthLinks.provider, provider) + ) + ); + + // Insert new pending link record for THIS USER only + await db.insert(pendingOAuthLinks).values({ + userId, + provider, + userEmail, + createdAt: nowIso(), + expiresAt: expiresAt.toISOString() + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("OAuth linking start error:", error); + return NextResponse.json( + { error: "Failed to start OAuth linking" }, + { status: 500 } + ); + } +} diff --git a/app/api/user/unlink-oauth/route.ts b/app/api/user/unlink-oauth/route.ts new file mode 100644 index 00000000..39158695 --- /dev/null +++ b/app/api/user/unlink-oauth/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/src/lib/auth"; +import { getUserById } from "@/src/lib/models/user"; +import { createAuditEvent } from "@/src/lib/models/audit"; +import db from "@/src/lib/db"; +import { users } from "@/src/lib/db/schema"; +import { eq } from "drizzle-orm"; +import { nowIso } from "@/src/lib/db"; + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = Number(session.user.id); + const user = await getUserById(userId); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + // Must have a password before unlinking OAuth + if (!user.password_hash) { + return NextResponse.json( + { error: "Cannot unlink OAuth: You must set a password first" }, + { status: 400 } + ); + } + + // Must be using OAuth to unlink + if (user.provider === "credentials") { + return NextResponse.json( + { error: "No OAuth account to unlink" }, + { status: 400 } + ); + } + + const previousProvider = user.provider; + + // Revert to credentials-only + const email = user.email; + const username = email.replace(/@localhost$/, "") || email.split("@")[0]; + + await db + .update(users) + .set({ + provider: "credentials", + subject: `${username}@localhost`, + updatedAt: nowIso() + }) + .where(eq(users.id, userId)); + + // Audit log + await createAuditEvent({ + userId, + action: "oauth_unlinked", + entityType: "user", + entityId: userId, + summary: `User unlinked OAuth account: ${previousProvider}`, + data: JSON.stringify({ provider: previousProvider }) + }); + + return NextResponse.json({ + success: true, + message: "OAuth account unlinked successfully" + }); + } catch (error) { + console.error("OAuth unlink error:", error); + return NextResponse.json( + { error: "Failed to unlink OAuth account" }, + { status: 500 } + ); + } +} diff --git a/app/api/user/update-avatar/route.ts b/app/api/user/update-avatar/route.ts new file mode 100644 index 00000000..9f3dc80e --- /dev/null +++ b/app/api/user/update-avatar/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/src/lib/auth"; +import { updateUserProfile } from "@/src/lib/models/user"; +import { createAuditEvent } from "@/src/lib/models/audit"; + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = Number(session.user.id); + const body = await request.json(); + const { avatarUrl } = body; + + // Validate avatarUrl is either null or a base64 image string + if (avatarUrl !== null && typeof avatarUrl !== "string") { + return NextResponse.json( + { error: "Invalid avatar data" }, + { status: 400 } + ); + } + + // If avatarUrl is provided, validate it's a base64 image + if (avatarUrl !== null) { + if (!avatarUrl.startsWith("data:image/")) { + return NextResponse.json( + { error: "Avatar must be a base64-encoded image" }, + { status: 400 } + ); + } + + // Check base64 size (rough estimate: base64 is ~33% larger than binary) + // 2MB binary = ~2.7MB base64, so limit to 3MB base64 string + if (avatarUrl.length > 3 * 1024 * 1024) { + return NextResponse.json( + { error: "Avatar image is too large" }, + { status: 400 } + ); + } + } + + // Update user avatar + const updatedUser = await updateUserProfile(userId, { + avatar_url: avatarUrl + }); + + if (!updatedUser) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); + } + + // Audit log + await createAuditEvent({ + userId, + action: avatarUrl ? "avatar_updated" : "avatar_deleted", + entityType: "user", + entityId: userId, + summary: avatarUrl ? "User updated profile picture" : "User removed profile picture", + data: JSON.stringify({ hasAvatar: !!avatarUrl }) + }); + + return NextResponse.json({ + success: true, + avatarUrl: updatedUser.avatar_url + }); + } catch (error) { + console.error("Avatar update error:", error); + return NextResponse.json( + { error: "Failed to update avatar" }, + { status: 500 } + ); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 9618376e..47bf0456 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,17 @@ services: # Password must be 12+ chars with uppercase, lowercase, numbers, and special chars ADMIN_USERNAME: ${ADMIN_USERNAME:?ERROR - ADMIN_USERNAME is required} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ERROR - ADMIN_PASSWORD is required} + + # OAuth2/OIDC Authentication (Optional - works with Authentik, Authelia, Keycloak, etc.) + OAUTH_ENABLED: ${OAUTH_ENABLED:-false} + OAUTH_PROVIDER_NAME: ${OAUTH_PROVIDER_NAME:-OAuth2} + OAUTH_CLIENT_ID: ${OAUTH_CLIENT_ID:-} + OAUTH_CLIENT_SECRET: ${OAUTH_CLIENT_SECRET:-} + OAUTH_ISSUER: ${OAUTH_ISSUER:-} + OAUTH_AUTHORIZATION_URL: ${OAUTH_AUTHORIZATION_URL:-} + OAUTH_TOKEN_URL: ${OAUTH_TOKEN_URL:-} + OAUTH_USERINFO_URL: ${OAUTH_USERINFO_URL:-} + OAUTH_ALLOW_AUTO_LINKING: ${OAUTH_ALLOW_AUTO_LINKING:-false} volumes: - ./data:/app/data depends_on: diff --git a/drizzle/0001_adorable_sally_floyd.sql b/drizzle/0001_adorable_sally_floyd.sql new file mode 100644 index 00000000..328c7d57 --- /dev/null +++ b/drizzle/0001_adorable_sally_floyd.sql @@ -0,0 +1,8 @@ +CREATE TABLE `pending_oauth_links` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer NOT NULL, + `provider` text NOT NULL, + `created_at` text NOT NULL, + `expires_at` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/drizzle/0002_perfect_hedge_knight.sql b/drizzle/0002_perfect_hedge_knight.sql new file mode 100644 index 00000000..98a231e2 --- /dev/null +++ b/drizzle/0002_perfect_hedge_knight.sql @@ -0,0 +1,14 @@ +-- Drop existing pending OAuth links (they're temporary with 5-minute expiry anyway) +DROP TABLE IF EXISTS `pending_oauth_links`;--> statement-breakpoint +-- Create new table with userEmail column and unique index +CREATE TABLE `pending_oauth_links` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer NOT NULL, + `provider` text(50) NOT NULL, + `user_email` text NOT NULL, + `created_at` text NOT NULL, + `expires_at` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `pending_oauth_user_provider_unique` ON `pending_oauth_links` (`user_id`,`provider`); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json index 4c675c72..27b51f2b 100644 --- a/drizzle/meta/0001_snapshot.json +++ b/drizzle/meta/0001_snapshot.json @@ -1,8 +1,8 @@ { "version": "6", "dialect": "sqlite", - "id": "b14592c5-88b2-43bd-910e-bd39d63b6923", - "prevId": "00000000-0000-0000-0000-000000000000", + "id": "27881e49-47c6-4fe5-898d-a4f095273605", + "prevId": "b14592c5-88b2-43bd-910e-bd39d63b6923", "tables": { "access_list_entries": { "name": "access_list_entries", @@ -556,6 +556,65 @@ "uniqueConstraints": {}, "checkConstraints": {} }, + "pending_oauth_links": { + "name": "pending_oauth_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "pending_oauth_links_user_id_users_id_fk": { + "name": "pending_oauth_links_user_id_users_id_fk", + "tableFrom": "pending_oauth_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "proxy_hosts": { "name": "proxy_hosts", "columns": { diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..185552fb --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1121 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "db635ecc-0855-4f49-a54c-21bec0ec495d", + "prevId": "27881e49-47c6-4fe5-898d-a4f095273605", + "tables": { + "access_list_entries": { + "name": "access_list_entries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "access_list_id": { + "name": "access_list_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "access_list_entries_list_idx": { + "name": "access_list_entries_list_idx", + "columns": [ + "access_list_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "access_list_entries_access_list_id_access_lists_id_fk": { + "name": "access_list_entries_access_list_id_access_lists_id_fk", + "tableFrom": "access_list_entries", + "tableTo": "access_lists", + "columnsFrom": [ + "access_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "access_lists": { + "name": "access_lists", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_lists_created_by_users_id_fk": { + "name": "access_lists_created_by_users_id_fk", + "tableFrom": "access_lists", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_tokens": { + "name": "api_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_tokens_token_hash_unique": { + "name": "api_tokens_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "api_tokens_created_by_users_id_fk": { + "name": "api_tokens_created_by_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_events": { + "name": "audit_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_events_user_id_users_id_fk": { + "name": "audit_events_user_id_users_id_fk", + "tableFrom": "audit_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "certificates": { + "name": "certificates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain_names": { + "name": "domain_names", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_renew": { + "name": "auto_renew", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "provider_options": { + "name": "provider_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "certificate_pem": { + "name": "certificate_pem", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key_pem": { + "name": "private_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "certificates_created_by_users_id_fk": { + "name": "certificates_created_by_users_id_fk", + "tableFrom": "certificates", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dead_hosts": { + "name": "dead_hosts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domains": { + "name": "domains", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 503 + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "dead_hosts_created_by_users_id_fk": { + "name": "dead_hosts_created_by_users_id_fk", + "tableFrom": "dead_hosts", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_states": { + "name": "oauth_states", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_to": { + "name": "redirect_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_state_unique": { + "name": "oauth_state_unique", + "columns": [ + "state" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_oauth_links": { + "name": "pending_oauth_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_oauth_user_provider_unique": { + "name": "pending_oauth_user_provider_unique", + "columns": [ + "user_id", + "provider" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pending_oauth_links_user_id_users_id_fk": { + "name": "pending_oauth_links_user_id_users_id_fk", + "tableFrom": "pending_oauth_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_hosts": { + "name": "proxy_hosts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domains": { + "name": "domains", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstreams": { + "name": "upstreams", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "certificate_id": { + "name": "certificate_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_list_id": { + "name": "access_list_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssl_forced": { + "name": "ssl_forced", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "hsts_enabled": { + "name": "hsts_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "hsts_subdomains": { + "name": "hsts_subdomains", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allow_websocket": { + "name": "allow_websocket", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "preserve_host_header": { + "name": "preserve_host_header", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skip_https_hostname_validation": { + "name": "skip_https_hostname_validation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "proxy_hosts_certificate_id_certificates_id_fk": { + "name": "proxy_hosts_certificate_id_certificates_id_fk", + "tableFrom": "proxy_hosts", + "tableTo": "certificates", + "columnsFrom": [ + "certificate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "proxy_hosts_access_list_id_access_lists_id_fk": { + "name": "proxy_hosts_access_list_id_access_lists_id_fk", + "tableFrom": "proxy_hosts", + "tableTo": "access_lists", + "columnsFrom": [ + "access_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "proxy_hosts_owner_user_id_users_id_fk": { + "name": "proxy_hosts_owner_user_id_users_id_fk", + "tableFrom": "proxy_hosts", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "redirect_hosts": { + "name": "redirect_hosts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domains": { + "name": "domains", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 302 + }, + "preserve_query": { + "name": "preserve_query", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "redirect_hosts_created_by_users_id_fk": { + "name": "redirect_hosts_created_by_users_id_fk", + "tableFrom": "redirect_hosts", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_provider_subject_idx": { + "name": "users_provider_subject_idx", + "columns": [ + "provider", + "subject" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e1e1c47a..39beb9bc 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1762515724134, "tag": "0000_initial", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1766854292252, + "tag": "0001_adorable_sally_floyd", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1766880443160, + "tag": "0002_perfect_hedge_knight", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2e4e3fdd..607289f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,18 +16,18 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.5.0", "drizzle-orm": "^0.45.1", - "next": "^16.0.8", + "next": "^16.1.1", "next-auth": "^5.0.0-beta.30", - "react": "^19.2.1", - "react-dom": "^19.2.1" + "react": "^19.2.3", + "react-dom": "^19.2.3" }, "devDependencies": { - "@types/node": "^24.10.2", + "@types/node": "^25.0.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.8", - "eslint": "^9.39.1", - "eslint-config-next": "^16.0.8", + "eslint": "^9.39.2", + "eslint-config-next": "^16.1.1", "typescript": "^5.9.3" } }, @@ -1501,9 +1501,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -2323,15 +2323,15 @@ } }, "node_modules/@next/env": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.8.tgz", - "integrity": "sha512-xP4WrQZuj9MdmLJy3eWFHepo+R3vznsMSS8Dy3wdA7FKpjCiesQ6DxZvdGziQisj0tEtCgBKJzjcAc4yZOgLEQ==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz", + "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.8.tgz", - "integrity": "sha512-1miV0qXDcLUaOdHridVPCh4i39ElRIAraseVIbb3BEqyZ5ol9sPyjTP/GNTPV5rBxqxjF6/vv5zQTVbhiNaLqA==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.1.tgz", + "integrity": "sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==", "dev": true, "license": "MIT", "dependencies": { @@ -2339,9 +2339,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.8.tgz", - "integrity": "sha512-yjVMvTQN21ZHOclQnhSFbjBTEizle+1uo4NV6L4rtS9WO3nfjaeJYw+H91G+nEf3Ef43TaEZvY5mPWfB/De7tA==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz", + "integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==", "cpu": [ "arm64" ], @@ -2355,9 +2355,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.8.tgz", - "integrity": "sha512-+zu2N3QQ0ZOb6RyqQKfcu/pn0UPGmg+mUDqpAAEviAcEVEYgDckemOpiMRsBP3IsEKpcoKuNzekDcPczEeEIzA==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz", + "integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==", "cpu": [ "x64" ], @@ -2371,9 +2371,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.8.tgz", - "integrity": "sha512-LConttk+BeD0e6RG0jGEP9GfvdaBVMYsLJ5aDDweKiJVVCu6sGvo+Ohz9nQhvj7EQDVVRJMCGhl19DmJwGr6bQ==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz", + "integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==", "cpu": [ "arm64" ], @@ -2387,9 +2387,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.8.tgz", - "integrity": "sha512-JaXFAlqn8fJV+GhhA9lpg6da/NCN/v9ub98n3HoayoUSPOVdoxEEt86iT58jXqQCs/R3dv5ZnxGkW8aF4obMrQ==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz", + "integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==", "cpu": [ "arm64" ], @@ -2403,9 +2403,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.8.tgz", - "integrity": "sha512-O7M9it6HyNhsJp3HNAsJoHk5BUsfj7hRshfptpGcVsPZ1u0KQ/oVy8oxF7tlwxA5tR43VUP0yRmAGm1us514ng==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz", + "integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==", "cpu": [ "x64" ], @@ -2419,9 +2419,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.8.tgz", - "integrity": "sha512-8+KClEC/GLI2dLYcrWwHu5JyC5cZYCFnccVIvmxpo6K+XQt4qzqM5L4coofNDZYkct/VCCyJWGbZZDsg6w6LFA==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz", + "integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==", "cpu": [ "x64" ], @@ -2435,9 +2435,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.8.tgz", - "integrity": "sha512-rpQ/PgTEgH68SiXmhu/cJ2hk9aZ6YgFvspzQWe2I9HufY6g7V02DXRr/xrVqOaKm2lenBFPNQ+KAaeveywqV+A==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz", + "integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==", "cpu": [ "arm64" ], @@ -2451,9 +2451,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.8.tgz", - "integrity": "sha512-jWpWjWcMQu2iZz4pEK2IktcfR+OA9+cCG8zenyLpcW8rN4rzjfOzH4yj/b1FiEAZHKS+5Vq8+bZyHi+2yqHbFA==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz", + "integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==", "cpu": [ "x64" ], @@ -2592,9 +2592,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.2.tgz", - "integrity": "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==", + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -3545,7 +3545,6 @@ "version": "2.8.22", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.22.tgz", "integrity": "sha512-/tk9kky/d8T8CTXIQYASLyhAxR5VwL3zct1oAoVTaOUHwrmsGnfbRwNdEq+vOl2BN8i3PcDdP0o4Q+jjKQoFbQ==", - "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -4480,9 +4479,9 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "peer": true, @@ -4493,7 +4492,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", + "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -4541,13 +4540,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.8.tgz", - "integrity": "sha512-8J5cOAboXIV3f8OD6BOyj7Fik6n/as7J4MboiUSExWruf/lCu1OPR3ZVSdnta6WhzebrmAATEmNSBZsLWA6kbg==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.1.tgz", + "integrity": "sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.0.8", + "@next/eslint-plugin-next": "16.1.1", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -6303,14 +6302,15 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/next/-/next-16.0.8.tgz", - "integrity": "sha512-LmcZzG04JuzNXi48s5P+TnJBsTGPJunViNKV/iE4uM6kstjTQsQhvsAv+xF6MJxU2Pr26tl15eVbp0jQnsv6/g==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz", + "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==", "license": "MIT", "peer": true, "dependencies": { - "@next/env": "16.0.8", + "@next/env": "16.1.1", "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -6322,14 +6322,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.0.8", - "@next/swc-darwin-x64": "16.0.8", - "@next/swc-linux-arm64-gnu": "16.0.8", - "@next/swc-linux-arm64-musl": "16.0.8", - "@next/swc-linux-x64-gnu": "16.0.8", - "@next/swc-linux-x64-musl": "16.0.8", - "@next/swc-win32-arm64-msvc": "16.0.8", - "@next/swc-win32-x64-msvc": "16.0.8", + "@next/swc-darwin-arm64": "16.1.1", + "@next/swc-darwin-x64": "16.1.1", + "@next/swc-linux-arm64-gnu": "16.1.1", + "@next/swc-linux-arm64-musl": "16.1.1", + "@next/swc-linux-x64-gnu": "16.1.1", + "@next/swc-linux-x64-musl": "16.1.1", + "@next/swc-win32-arm64-msvc": "16.1.1", + "@next/swc-win32-x64-msvc": "16.1.1", "sharp": "^0.34.4" }, "peerDependencies": { @@ -6864,9 +6864,9 @@ } }, "node_modules/react": { - "version": "19.2.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", - "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", "peer": true, "engines": { @@ -6874,16 +6874,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", - "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.1" + "react": "^19.2.3" } }, "node_modules/react-is": { diff --git a/package.json b/package.json index 2926bde2..489a4a31 100644 --- a/package.json +++ b/package.json @@ -21,18 +21,18 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.5.0", "drizzle-orm": "^0.45.1", - "next": "^16.0.8", + "next": "^16.1.1", "next-auth": "^5.0.0-beta.30", - "react": "^19.2.1", - "react-dom": "^19.2.1" + "react": "^19.2.3", + "react-dom": "^19.2.3" }, "devDependencies": { "drizzle-kit": "^0.31.8", - "@types/node": "^24.10.2", + "@types/node": "^25.0.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "eslint": "^9.39.1", - "eslint-config-next": "^16.0.8", + "eslint": "^9.39.2", + "eslint-config-next": "^16.1.1", "typescript": "^5.9.3" } } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 79b990ef..5392ceb7 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,20 +1,27 @@ import NextAuth, { type DefaultSession } from "next-auth"; import Credentials from "next-auth/providers/credentials"; +import type { OAuthConfig } from "next-auth/providers"; import bcrypt from "bcryptjs"; +import { cookies } from "next/headers"; import db from "./db"; import { config } from "./config"; import { users } from "./db/schema"; +import { findUserByProviderSubject, findUserByEmail, createUser, getUserById } from "./models/user"; +import { createAuditEvent } from "./models/audit"; +import { decideLinkingStrategy, createLinkingToken, autoLinkOAuth, linkOAuthAuthenticated } from "./services/account-linking"; declare module "next-auth" { interface Session { user: { id: string; role: string; + provider?: string; } & DefaultSession["user"]; } interface User { role?: string; + provider?: string; } } @@ -63,8 +70,43 @@ function createCredentialsProvider() { const credentialsProvider = createCredentialsProvider(); +// Create OAuth providers based on configuration +function createOAuthProviders(): OAuthConfig[] { + const providers: OAuthConfig[] = []; + + if ( + config.oauth.enabled && + config.oauth.clientId && + config.oauth.clientSecret + ) { + const oauthProvider: OAuthConfig = { + id: "oauth2", + name: config.oauth.providerName, + type: "oidc", + clientId: config.oauth.clientId, + clientSecret: config.oauth.clientSecret, + issuer: config.oauth.issuer ?? undefined, + authorization: config.oauth.authorizationUrl ?? undefined, + token: config.oauth.tokenUrl ?? undefined, + userinfo: config.oauth.userinfoUrl ?? undefined, + checks: ["state"], + profile(profile) { + return { + id: profile.sub ?? profile.id, + name: profile.name ?? profile.preferred_username ?? profile.email, + email: profile.email, + image: profile.picture ?? profile.avatar_url ?? null, + }; + }, + }; + providers.push(oauthProvider); + } + + return providers; +} + export const { handlers, signIn, signOut, auth } = NextAuth({ - providers: [credentialsProvider], + providers: [credentialsProvider, ...createOAuthProviders()], session: { strategy: "jwt", maxAge: 7 * 24 * 60 * 60, // 7 days @@ -73,20 +115,269 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ signIn: "/login", }, callbacks: { - async jwt({ token, user }) { + async signIn({ user, account, profile }) { + // Credentials provider - handled by authorize function + if (account?.provider === "credentials") { + return true; + } + + // OAuth provider sign-in + if (!account || !user.email) { + return false; + } + + try { + // Check if this is an OAuth linking attempt by checking the database + const { pendingOAuthLinks } = await import("./db/schema"); + const { eq, and, gt } = await import("drizzle-orm"); + const { nowIso } = await import("./db"); + + // Find ALL non-expired pending links for this provider + const allPendingLinks = await db.query.pendingOAuthLinks.findMany({ + where: (table, operators) => + operators.and( + operators.eq(table.provider, account.provider), + operators.gt(table.expiresAt, nowIso()) + ) + }); + + // Security: Match by userId to prevent race condition where User B could + // overwrite User A's pending link. We verify by checking which user exists. + let pendingLink = null; + if (allPendingLinks.length === 1) { + // Common case: only one user is linking this provider right now + pendingLink = allPendingLinks[0]; + } else if (allPendingLinks.length > 1) { + // Race condition detected: multiple users linking same provider + // This shouldn't happen with unique index, but handle gracefully + // Find the user whose email matches their stored email + for (const link of allPendingLinks) { + const existingUser = await getUserById(link.userId); + if (existingUser && existingUser.email === link.userEmail) { + pendingLink = link; + break; + } + } + } + + if (pendingLink) { + try { + const userId = pendingLink.userId; + const existingUser = await getUserById(userId); + + if (existingUser) { + // Security: Validate OAuth email matches the authenticated user's stored email + // This prevents users from linking arbitrary OAuth accounts to their credentials account + if (user.email && existingUser.email !== pendingLink.userEmail) { + console.error(`OAuth linking rejected: user email mismatch. Expected ${pendingLink.userEmail}, got ${existingUser.email}`); + + // Clean up the pending link + await db.delete(pendingOAuthLinks).where(eq(pendingOAuthLinks.id, pendingLink.id)); + + // Audit log for security event + await createAuditEvent({ + userId: existingUser.id, + action: "oauth_link_rejected", + entityType: "user", + entityId: existingUser.id, + summary: `OAuth linking rejected: email mismatch`, + data: JSON.stringify({ + provider: account.provider, + expectedEmail: pendingLink.userEmail, + actualEmail: existingUser.email + }) + }); + + return false; + } + + // User is already authenticated - auto-link + const linked = await linkOAuthAuthenticated( + userId, + account.provider, + account.providerAccountId, + user.image + ); + + if (linked) { + // Reload user from database to get updated data + const updatedUser = await getUserById(userId); + + if (updatedUser) { + user.id = updatedUser.id.toString(); + user.role = updatedUser.role; + user.provider = updatedUser.provider; + user.email = updatedUser.email; + user.name = updatedUser.name; + + // Delete the pending link + await db.delete(pendingOAuthLinks).where(eq(pendingOAuthLinks.id, pendingLink.id)); + + // Audit log + await createAuditEvent({ + userId: updatedUser.id, + action: "account_linked", + entityType: "user", + entityId: updatedUser.id, + summary: `OAuth account linked while authenticated: ${account.provider}`, + data: JSON.stringify({ provider: account.provider, email: user.email }) + }); + + return true; + } + } + } + } catch (e) { + console.error("Error processing pending link:", e); + } + } + + // Check if OAuth account already exists + const existingOAuthUser = await findUserByProviderSubject( + account.provider, + account.providerAccountId + ); + + if (existingOAuthUser) { + // Existing OAuth user - update user object and allow sign-in + user.id = existingOAuthUser.id.toString(); + user.role = existingOAuthUser.role; + user.provider = existingOAuthUser.provider; + + // Audit log + await createAuditEvent({ + userId: existingOAuthUser.id, + action: "oauth_signin", + entityType: "user", + entityId: existingOAuthUser.id, + summary: `User signed in via ${account.provider}`, + data: JSON.stringify({ provider: account.provider }) + }); + + return true; + } + + // Determine linking strategy + const decision = await decideLinkingStrategy( + account.provider, + account.providerAccountId, + user.email + ); + + if (decision.action === "auto_link" && decision.userId) { + // Auto-link OAuth to existing account without password + const linked = await autoLinkOAuth( + decision.userId, + account.provider, + account.providerAccountId, + user.image + ); + + if (linked) { + const linkedUser = await getUserById(decision.userId); + if (linkedUser) { + user.id = linkedUser.id.toString(); + user.role = linkedUser.role; + user.provider = linkedUser.provider; + + // Audit log + await createAuditEvent({ + userId: linkedUser.id, + action: "account_linked", + entityType: "user", + entityId: linkedUser.id, + summary: `OAuth account auto-linked: ${account.provider}`, + data: JSON.stringify({ provider: account.provider, email: user.email }) + }); + + return true; + } + } + } + + if (decision.action === "require_manual_link" && decision.userId) { + // Email collision - require manual linking with password verification + const linkingToken = await createLinkingToken( + decision.userId, + account.provider, + account.providerAccountId, + user.email + ); + + // Redirect to link-account page with token + throw new Error(`LINKING_REQUIRED:${linkingToken}`); + } + + // New OAuth user - create account (defaults to admin role) + const newUser = await createUser({ + email: user.email, + name: user.name, + provider: account.provider, + subject: account.providerAccountId, + avatar_url: user.image + }); + + user.id = newUser.id.toString(); + user.role = newUser.role; + user.provider = newUser.provider; + + // Audit log + await createAuditEvent({ + userId: newUser.id, + action: "oauth_signup", + entityType: "user", + entityId: newUser.id, + summary: `New user created via ${account.provider} OAuth`, + data: JSON.stringify({ provider: account.provider, email: user.email }) + }); + + return true; + } catch (error) { + console.error("OAuth sign-in error:", error); + + // Audit log for failed OAuth attempts + try { + await createAuditEvent({ + userId: null, + action: "oauth_signin_failed", + entityType: "user", + entityId: null, + summary: `OAuth sign-in failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + data: JSON.stringify({ + provider: account?.provider, + email: user?.email, + error: error instanceof Error ? error.message : String(error) + }) + }); + } catch (auditError) { + console.error("Failed to create audit log for OAuth error:", auditError); + } + + return false; + } + }, + async jwt({ token, user, account }) { // On sign in, add user info to token if (user) { token.id = user.id; token.email = user.email; token.role = user.role ?? "user"; + token.provider = account?.provider ?? user.provider ?? "credentials"; + token.image = user.image; } return token; }, async session({ session, token }) { // Add user info from token to session - if (session.user) { + if (session.user && token.id) { session.user.id = token.id as string; session.user.role = token.role as string; + session.user.provider = token.provider as string; + + // Fetch current avatar from database to ensure it's always up-to-date + const userId = Number(token.id); + const currentUser = await getUserById(userId); + session.user.image = currentUser?.avatar_url ?? (token.image as string | null | undefined); } return session; }, diff --git a/src/lib/config.ts b/src/lib/config.ts index ac61daee..a4812b39 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -157,6 +157,17 @@ export const config = { }, get adminPassword() { return getAdminCredentials().password; + }, + oauth: { + enabled: process.env.OAUTH_ENABLED === "true", + providerName: process.env.OAUTH_PROVIDER_NAME ?? "OAuth2", + clientId: process.env.OAUTH_CLIENT_ID ?? null, + clientSecret: process.env.OAUTH_CLIENT_SECRET ?? null, + issuer: process.env.OAUTH_ISSUER ?? null, + authorizationUrl: process.env.OAUTH_AUTHORIZATION_URL ?? null, + tokenUrl: process.env.OAUTH_TOKEN_URL ?? null, + userinfoUrl: process.env.OAUTH_USERINFO_URL ?? null, + allowAutoLinking: process.env.OAUTH_ALLOW_AUTO_LINKING === "true", } }; @@ -174,3 +185,25 @@ export function validateProductionConfig() { const ___ = config.adminPassword; } } + +/** + * Returns list of enabled OAuth providers based on configuration. + * Only includes providers that have complete credentials configured. + */ +export function getEnabledOAuthProviders(): Array<{id: string; name: string}> { + const providers: Array<{id: string; name: string}> = []; + + if ( + config.oauth.enabled && + config.oauth.clientId && + config.oauth.clientSecret && + config.oauth.issuer + ) { + providers.push({ + id: "oauth2", + name: config.oauth.providerName + }); + } + + return providers; +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 271efbfe..8c162e86 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -52,6 +52,18 @@ export const oauthStates = sqliteTable( }) ); +export const pendingOAuthLinks = sqliteTable("pending_oauth_links", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + provider: text("provider", { length: 50 }).notNull(), + userEmail: text("user_email").notNull(), // Email of the user who initiated linking + createdAt: text("created_at").notNull(), + expiresAt: text("expires_at").notNull() +}, (table) => ({ + // Ensure only one pending link per user per provider (prevents race conditions) + userProviderUnique: uniqueIndex("pending_oauth_user_provider_unique").on(table.userId, table.provider) +})); + export const settings = sqliteTable("settings", { key: text("key").primaryKey(), value: text("value").notNull(), diff --git a/src/lib/models/audit.ts b/src/lib/models/audit.ts index 852793ee..ffb750de 100644 --- a/src/lib/models/audit.ts +++ b/src/lib/models/audit.ts @@ -1,4 +1,4 @@ -import db, { toIso } from "../db"; +import db, { toIso, nowIso } from "../db"; import { auditEvents } from "../db/schema"; import { desc } from "drizzle-orm"; @@ -29,3 +29,22 @@ export async function listAuditEvents(limit = 100): Promise { created_at: toIso(event.createdAt)! })); } + +export async function createAuditEvent(data: { + userId: number | null; + action: string; + entityType: string; + entityId?: number | null; + summary?: string | null; + data?: string | null; +}): Promise { + await db.insert(auditEvents).values({ + userId: data.userId, + action: data.action, + entityType: data.entityType, + entityId: data.entityId ?? null, + summary: data.summary ?? null, + data: data.data ?? null, + createdAt: nowIso(), + }); +} diff --git a/src/lib/models/user.ts b/src/lib/models/user.ts index 04d2d653..94325226 100644 --- a/src/lib/models/user.ts +++ b/src/lib/models/user.ts @@ -71,7 +71,7 @@ export async function createUser(data: { passwordHash?: string | null; }): Promise { const now = nowIso(); - const role = data.role ?? "user"; + const role = data.role ?? "admin"; // All users are admin by default const email = data.email.trim().toLowerCase(); const [user] = await db diff --git a/src/lib/services/account-linking.ts b/src/lib/services/account-linking.ts new file mode 100644 index 00000000..d7115b0b --- /dev/null +++ b/src/lib/services/account-linking.ts @@ -0,0 +1,219 @@ +import bcrypt from "bcryptjs"; +import { SignJWT, jwtVerify } from "jose"; +import { config } from "../config"; +import { findUserByEmail, findUserByProviderSubject, getUserById } from "../models/user"; +import db from "../db"; +import { users } from "../db/schema"; +import { eq } from "drizzle-orm"; +import { nowIso } from "../db"; + +const LINKING_TOKEN_EXPIRY = 5 * 60; // 5 minutes in seconds + +export type LinkingDecision = { + action: "auto_link" | "require_manual_link" | "create_new" | "signin_existing"; + userId?: number; + reason: string; +}; + +export type LinkingTokenPayload = { + userId: number; + provider: string; + providerAccountId: string; + email: string; + exp: number; +}; + +/** + * Determines how to handle an OAuth sign-in attempt + */ +export async function decideLinkingStrategy( + provider: string, + providerAccountId: string, + email: string +): Promise { + // Check if OAuth account already exists + const existingOAuthUser = await findUserByProviderSubject(provider, providerAccountId); + if (existingOAuthUser) { + return { + action: "signin_existing", + userId: existingOAuthUser.id, + reason: "OAuth account already linked" + }; + } + + // Check if email matches existing user + const existingEmailUser = await findUserByEmail(email); + if (!existingEmailUser) { + return { + action: "create_new", + reason: "No existing account with this email" + }; + } + + // User exists with this email + if (existingEmailUser.password_hash) { + // Has password - require manual linking with password verification + return { + action: "require_manual_link", + userId: existingEmailUser.id, + reason: "Account has password - requires manual linking" + }; + } + + // No password (OAuth-only account) + if (config.oauth.allowAutoLinking) { + return { + action: "auto_link", + userId: existingEmailUser.id, + reason: "Account has no password - auto-linking enabled" + }; + } + + return { + action: "require_manual_link", + userId: existingEmailUser.id, + reason: "Auto-linking disabled" + }; +} + +/** + * Create a temporary linking token (5-minute expiry) + */ +export async function createLinkingToken( + userId: number, + provider: string, + providerAccountId: string, + email: string +): Promise { + const secret = new TextEncoder().encode(config.sessionSecret); + + const token = await new SignJWT({ + userId, + provider, + providerAccountId, + email + }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime(`${LINKING_TOKEN_EXPIRY}s`) + .setIssuedAt() + .sign(secret); + + return token; +} + +/** + * Verify and decode linking token + */ +export async function verifyLinkingToken(token: string): Promise { + try { + const secret = new TextEncoder().encode(config.sessionSecret); + const { payload } = await jwtVerify(token, secret); + + return { + userId: payload.userId as number, + provider: payload.provider as string, + providerAccountId: payload.providerAccountId as string, + email: payload.email as string, + exp: payload.exp as number + }; + } catch (error) { + console.error("Token verification failed:", error); + return null; + } +} + +/** + * Verify password and link OAuth account to existing user + */ +export async function verifyAndLinkOAuth( + userId: number, + password: string, + provider: string, + providerAccountId: string +): Promise { + const user = await getUserById(userId); + if (!user || !user.password_hash) { + return false; + } + + // Verify password + const isValid = bcrypt.compareSync(password, user.password_hash); + if (!isValid) { + return false; + } + + // Update user to link OAuth + await db + .update(users) + .set({ + provider, + subject: providerAccountId, + updatedAt: nowIso() + }) + .where(eq(users.id, userId)); + + return true; +} + +/** + * Auto-link OAuth account (for users without passwords) + */ +export async function autoLinkOAuth( + userId: number, + provider: string, + providerAccountId: string, + avatarUrl?: string | null +): Promise { + const user = await getUserById(userId); + if (!user) { + return false; + } + + // Don't auto-link if user has a password (unless explicitly called for authenticated linking) + // This check is bypassed when called from the authenticated linking flow + if (user.password_hash && !process.env.OAUTH_ALLOW_AUTO_LINKING) { + return false; + } + + // Update user to link OAuth + await db + .update(users) + .set({ + provider, + subject: providerAccountId, + avatarUrl: avatarUrl ?? user.avatar_url, + updatedAt: nowIso() + }) + .where(eq(users.id, userId)); + + return true; +} + +/** + * Link OAuth account for an already-authenticated user + * This bypasses the password check since the user is already authenticated + */ +export async function linkOAuthAuthenticated( + userId: number, + provider: string, + providerAccountId: string, + avatarUrl?: string | null +): Promise { + const user = await getUserById(userId); + if (!user) { + return false; + } + + // Update user to link OAuth + await db + .update(users) + .set({ + provider, + subject: providerAccountId, + avatarUrl: avatarUrl ?? user.avatar_url, + updatedAt: nowIso() + }) + .where(eq(users.id, userId)); + + return true; +}