From cad77320f309aa9611a6193e77f22c8947191804 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Thu, 17 Jul 2025 16:19:56 +0530 Subject: [PATCH] Added Header Authentication --- .env.example | 13 +++ README.md | 65 +++++++++++++ docker-compose.yml | 7 ++ src/components/config/SSOSettings.tsx | 73 +++++++++++--- src/lib/auth-header.ts | 135 ++++++++++++++++++++++++++ src/middleware.ts | 34 ++++++- src/pages/api/auth/header-status.ts | 16 +++ 7 files changed, 328 insertions(+), 15 deletions(-) create mode 100644 src/lib/auth-header.ts create mode 100644 src/pages/api/auth/header-status.ts diff --git a/.env.example b/.env.example index 6311dd2..4ec9f43 100644 --- a/.env.example +++ b/.env.example @@ -66,3 +66,16 @@ DOCKER_TAG=latest # TLS/SSL Configuration # GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing +# =========================================== +# AUTHENTICATION CONFIGURATION +# =========================================== + +# Header Authentication (for Reverse Proxy SSO) +# Enable automatic authentication via reverse proxy headers +# HEADER_AUTH_ENABLED=false +# HEADER_AUTH_USER_HEADER=X-Authentik-Username +# HEADER_AUTH_EMAIL_HEADER=X-Authentik-Email +# HEADER_AUTH_NAME_HEADER=X-Authentik-Name +# HEADER_AUTH_AUTO_PROVISION=false +# HEADER_AUTH_ALLOWED_DOMAINS=example.com,company.org + diff --git a/README.md b/README.md index f78a9d9..82eac8a 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,71 @@ If upgrading from a version without token encryption: bun run migrate:encrypt-tokens ``` +## Authentication + +Gitea Mirror supports multiple authentication methods. **Email/password authentication is the default and always enabled.** + +### 1. Email & Password (Default) +The standard authentication method. First user to sign up becomes the admin. + +### 2. Single Sign-On (SSO) with OIDC +Enable users to sign in with external identity providers like Google, Azure AD, Okta, Authentik, or any OIDC-compliant service. + +**Configuration:** +1. Navigate to Settings → Authentication & SSO +2. Click "Add Provider" +3. Enter your OIDC provider details: + - Issuer URL (e.g., `https://accounts.google.com`) + - Client ID and Secret from your provider + - Use the "Discover" button to auto-fill endpoints + +**Redirect URL for your provider:** +``` +https://your-domain.com/api/auth/sso/callback/{provider-id} +``` + +### 3. Header Authentication (Reverse Proxy) +Perfect for automatic authentication when using reverse proxies like Authentik, Authelia, or Traefik Forward Auth. + +**Environment Variables:** +```bash +# Enable header authentication +HEADER_AUTH_ENABLED=true + +# Header names (customize based on your proxy) +HEADER_AUTH_USER_HEADER=X-Authentik-Username +HEADER_AUTH_EMAIL_HEADER=X-Authentik-Email +HEADER_AUTH_NAME_HEADER=X-Authentik-Name + +# Auto-provision new users +HEADER_AUTH_AUTO_PROVISION=true + +# Restrict to specific email domains (optional) +HEADER_AUTH_ALLOWED_DOMAINS=example.com,company.org +``` + +**How it works:** +- Users authenticated by your reverse proxy are automatically logged in +- No additional login step required +- New users can be auto-provisioned if enabled +- Falls back to regular authentication if headers are missing + +**Example Authentik Configuration:** +```nginx +# In your reverse proxy configuration +proxy_set_header X-Authentik-Username $authentik_username; +proxy_set_header X-Authentik-Email $authentik_email; +proxy_set_header X-Authentik-Name $authentik_name; +``` + +### 4. OAuth Applications (Act as Identity Provider) +Gitea Mirror can also act as an OIDC provider for other applications. Register OAuth applications in Settings → Authentication & SSO → OAuth Applications tab. + +**Use cases:** +- Allow other services to authenticate using Gitea Mirror accounts +- Create service-to-service authentication +- Build integrations with your Gitea Mirror instance + ## Contributing Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests. diff --git a/docker-compose.yml b/docker-compose.yml index cc294d3..ec9110e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,13 @@ services: - DELAY=${DELAY:-3600} # Optional: Skip TLS verification (insecure, use only for testing) # - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false} + # Header Authentication (for Reverse Proxy SSO) + - HEADER_AUTH_ENABLED=${HEADER_AUTH_ENABLED:-false} + - HEADER_AUTH_USER_HEADER=${HEADER_AUTH_USER_HEADER:-X-Authentik-Username} + - HEADER_AUTH_EMAIL_HEADER=${HEADER_AUTH_EMAIL_HEADER:-X-Authentik-Email} + - HEADER_AUTH_NAME_HEADER=${HEADER_AUTH_NAME_HEADER:-X-Authentik-Name} + - HEADER_AUTH_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false} + - HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-} healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"] interval: 30s diff --git a/src/components/config/SSOSettings.tsx b/src/components/config/SSOSettings.tsx index 9895045..0d3b072 100644 --- a/src/components/config/SSOSettings.tsx +++ b/src/components/config/SSOSettings.tsx @@ -9,9 +9,10 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { apiRequest, showErrorToast } from '@/lib/utils'; import { toast } from 'sonner'; -import { Plus, Trash2, ExternalLink, Loader2, AlertCircle, Copy } from 'lucide-react'; +import { Plus, Trash2, ExternalLink, Loader2, AlertCircle, Copy, Shield, Info } from 'lucide-react'; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '../ui/skeleton'; +import { Badge } from '../ui/badge'; interface SSOProvider { id: string; @@ -43,6 +44,7 @@ export function SSOSettings() { const [isLoading, setIsLoading] = useState(true); const [showProviderDialog, setShowProviderDialog] = useState(false); const [isDiscovering, setIsDiscovering] = useState(false); + const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false); // Form states for new provider const [providerForm, setProviderForm] = useState({ @@ -66,8 +68,13 @@ export function SSOSettings() { const loadData = async () => { setIsLoading(true); try { - const providersRes = await apiRequest('/sso/providers'); + const [providersRes, headerAuthStatus] = await Promise.all([ + apiRequest('/sso/providers'), + apiRequest<{ enabled: boolean }>('/auth/header-status').catch(() => ({ enabled: false })) + ]); + setProviders(providersRes); + setHeaderAuthEnabled(headerAuthStatus.enabled); } catch (error) { showErrorToast(error, toast); } finally { @@ -183,16 +190,58 @@ export function SSOSettings() { - {/* Info Alert for Authentication Flow */} - {providers.length === 0 && ( - - - - Current authentication: Users sign in with email and password only. - Add SSO providers to enable users to sign in with their existing accounts from external services like Google, Azure AD, or any OIDC-compliant provider. - - - )} + {/* Authentication Methods Overview */} + + + Active Authentication Methods + + +
+ {/* Email & Password - Always enabled */} +
+
+
+ Email & Password + Default +
+ Always enabled +
+ + {/* Header Authentication Status */} + {headerAuthEnabled && ( +
+
+
+ Header Authentication + Auto-login +
+ Via reverse proxy +
+ )} + + {/* SSO Providers Status */} +
+
+
0 ? 'bg-green-500' : 'bg-muted'}`} /> + SSO/OIDC Providers +
+ + {providers.length > 0 ? `${providers.length} provider${providers.length !== 1 ? 's' : ''} configured` : 'Not configured'} + +
+
+ + {/* Header Auth Info */} + {headerAuthEnabled && ( + + + + Header authentication is enabled. Users authenticated by your reverse proxy will be automatically logged in. + + + )} + + {/* SSO Providers */} diff --git a/src/lib/auth-header.ts b/src/lib/auth-header.ts new file mode 100644 index 0000000..cf51926 --- /dev/null +++ b/src/lib/auth-header.ts @@ -0,0 +1,135 @@ +import { db, users } from "./db"; +import { eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; + +export interface HeaderAuthConfig { + enabled: boolean; + userHeader: string; + emailHeader?: string; + nameHeader?: string; + autoProvision: boolean; + allowedDomains?: string[]; +} + +// Default configuration - DISABLED by default +export const defaultHeaderAuthConfig: HeaderAuthConfig = { + enabled: false, + userHeader: "X-Authentik-Username", // Common header name + emailHeader: "X-Authentik-Email", + nameHeader: "X-Authentik-Name", + autoProvision: false, + allowedDomains: [], +}; + +// Get header auth config from environment or database +export function getHeaderAuthConfig(): HeaderAuthConfig { + // Check environment variables for header auth config + const envConfig: Partial = { + enabled: process.env.HEADER_AUTH_ENABLED === "true", + userHeader: process.env.HEADER_AUTH_USER_HEADER || defaultHeaderAuthConfig.userHeader, + emailHeader: process.env.HEADER_AUTH_EMAIL_HEADER || defaultHeaderAuthConfig.emailHeader, + nameHeader: process.env.HEADER_AUTH_NAME_HEADER || defaultHeaderAuthConfig.nameHeader, + autoProvision: process.env.HEADER_AUTH_AUTO_PROVISION === "true", + allowedDomains: process.env.HEADER_AUTH_ALLOWED_DOMAINS?.split(",").map(d => d.trim()), + }; + + return { + ...defaultHeaderAuthConfig, + ...envConfig, + }; +} + +// Check if header authentication is enabled +export function isHeaderAuthEnabled(): boolean { + const config = getHeaderAuthConfig(); + return config.enabled === true; +} + +// Extract user info from headers +export function extractUserFromHeaders(headers: Headers): { + username?: string; + email?: string; + name?: string; +} | null { + const config = getHeaderAuthConfig(); + + if (!config.enabled) { + return null; + } + + const username = headers.get(config.userHeader); + const email = config.emailHeader ? headers.get(config.emailHeader) : undefined; + const name = config.nameHeader ? headers.get(config.nameHeader) : undefined; + + if (!username) { + return null; + } + + // If allowed domains are configured, check email domain + if (config.allowedDomains && config.allowedDomains.length > 0 && email) { + const domain = email.split("@")[1]; + if (!config.allowedDomains.includes(domain)) { + console.warn(`Header auth rejected: email domain ${domain} not in allowed list`); + return null; + } + } + + return { username, email, name }; +} + +// Find or create user from header auth +export async function authenticateWithHeaders(headers: Headers) { + const userInfo = extractUserFromHeaders(headers); + + if (!userInfo || !userInfo.username) { + return null; + } + + const config = getHeaderAuthConfig(); + + // Try to find existing user by username or email + let existingUser = await db + .select() + .from(users) + .where(eq(users.username, userInfo.username)) + .limit(1); + + if (existingUser.length === 0 && userInfo.email) { + existingUser = await db + .select() + .from(users) + .where(eq(users.email, userInfo.email)) + .limit(1); + } + + if (existingUser.length > 0) { + return existingUser[0]; + } + + // If auto-provisioning is disabled, don't create new users + if (!config.autoProvision) { + console.warn(`Header auth: User ${userInfo.username} not found and auto-provisioning is disabled`); + return null; + } + + // Create new user if auto-provisioning is enabled + try { + const newUser = { + id: nanoid(), + username: userInfo.username, + email: userInfo.email || `${userInfo.username}@header-auth.local`, + emailVerified: true, // Trust the auth provider + name: userInfo.name || userInfo.username, + createdAt: new Date(), + updatedAt: new Date(), + }; + + await db.insert(users).values(newUser); + console.log(`Header auth: Auto-provisioned new user ${userInfo.username}`); + + return newUser; + } catch (error) { + console.error("Failed to auto-provision user from header auth:", error); + return null; + } +} \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index 3f7fc45..d02dbca 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -4,6 +4,7 @@ import { startCleanupService, stopCleanupService } from './lib/cleanup-service'; import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager'; import { setupSignalHandlers } from './lib/signal-handlers'; import { auth } from './lib/auth'; +import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header'; // Flag to track if recovery has been initialized let recoveryInitialized = false; @@ -12,7 +13,7 @@ let cleanupServiceStarted = false; let shutdownManagerInitialized = false; export const onRequest = defineMiddleware(async (context, next) => { - // Handle Better Auth session + // First, try Better Auth session (cookie-based) try { const session = await auth.api.getSession({ headers: context.request.headers, @@ -22,8 +23,35 @@ export const onRequest = defineMiddleware(async (context, next) => { context.locals.user = session.user; context.locals.session = session.session; } else { - context.locals.user = null; - context.locals.session = null; + // No cookie session, check for header authentication + if (isHeaderAuthEnabled()) { + const headerUser = await authenticateWithHeaders(context.request.headers); + if (headerUser) { + // Create a session-like object for header auth + context.locals.user = { + id: headerUser.id, + email: headerUser.email, + emailVerified: headerUser.emailVerified, + name: headerUser.name || headerUser.username, + username: headerUser.username, + createdAt: headerUser.createdAt, + updatedAt: headerUser.updatedAt, + }; + context.locals.session = { + id: `header-${headerUser.id}`, + userId: headerUser.id, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 1 day + ipAddress: context.request.headers.get('x-forwarded-for') || context.clientAddress, + userAgent: context.request.headers.get('user-agent'), + }; + } else { + context.locals.user = null; + context.locals.session = null; + } + } else { + context.locals.user = null; + context.locals.session = null; + } } } catch (error) { // If there's an error getting the session, set to null diff --git a/src/pages/api/auth/header-status.ts b/src/pages/api/auth/header-status.ts new file mode 100644 index 0000000..661eb96 --- /dev/null +++ b/src/pages/api/auth/header-status.ts @@ -0,0 +1,16 @@ +import type { APIRoute } from "astro"; +import { getHeaderAuthConfig } from "@/lib/auth-header"; + +export const GET: APIRoute = async () => { + const config = getHeaderAuthConfig(); + + return new Response(JSON.stringify({ + enabled: config.enabled, + userHeader: config.userHeader, + autoProvision: config.autoProvision, + hasAllowedDomains: config.allowedDomains && config.allowedDomains.length > 0, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +}; \ No newline at end of file