Added Header Authentication

This commit is contained in:
Arunavo Ray
2025-07-17 16:19:56 +05:30
parent 744064f3aa
commit cad77320f3
7 changed files with 328 additions and 15 deletions

View File

@@ -66,3 +66,16 @@ DOCKER_TAG=latest
# TLS/SSL Configuration # TLS/SSL Configuration
# GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing # 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

View File

@@ -218,6 +218,71 @@ If upgrading from a version without token encryption:
bun run migrate:encrypt-tokens 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 ## 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. Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.

View File

@@ -52,6 +52,13 @@ services:
- DELAY=${DELAY:-3600} - DELAY=${DELAY:-3600}
# Optional: Skip TLS verification (insecure, use only for testing) # Optional: Skip TLS verification (insecure, use only for testing)
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false} # - 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: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"] test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
interval: 30s interval: 30s

View File

@@ -9,9 +9,10 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { apiRequest, showErrorToast } from '@/lib/utils'; import { apiRequest, showErrorToast } from '@/lib/utils';
import { toast } from 'sonner'; 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 { Separator } from '@/components/ui/separator';
import { Skeleton } from '../ui/skeleton'; import { Skeleton } from '../ui/skeleton';
import { Badge } from '../ui/badge';
interface SSOProvider { interface SSOProvider {
id: string; id: string;
@@ -43,6 +44,7 @@ export function SSOSettings() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [showProviderDialog, setShowProviderDialog] = useState(false); const [showProviderDialog, setShowProviderDialog] = useState(false);
const [isDiscovering, setIsDiscovering] = useState(false); const [isDiscovering, setIsDiscovering] = useState(false);
const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false);
// Form states for new provider // Form states for new provider
const [providerForm, setProviderForm] = useState({ const [providerForm, setProviderForm] = useState({
@@ -66,8 +68,13 @@ export function SSOSettings() {
const loadData = async () => { const loadData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const providersRes = await apiRequest<SSOProvider[]>('/sso/providers'); const [providersRes, headerAuthStatus] = await Promise.all([
apiRequest<SSOProvider[]>('/sso/providers'),
apiRequest<{ enabled: boolean }>('/auth/header-status').catch(() => ({ enabled: false }))
]);
setProviders(providersRes); setProviders(providersRes);
setHeaderAuthEnabled(headerAuthStatus.enabled);
} catch (error) { } catch (error) {
showErrorToast(error, toast); showErrorToast(error, toast);
} finally { } finally {
@@ -183,16 +190,58 @@ export function SSOSettings() {
</div> </div>
</div> </div>
{/* Info Alert for Authentication Flow */} {/* Authentication Methods Overview */}
{providers.length === 0 && ( <Card className="mb-6">
<Alert> <CardHeader>
<AlertCircle className="h-4 w-4" /> <CardTitle className="text-base">Active Authentication Methods</CardTitle>
<AlertDescription> </CardHeader>
<strong>Current authentication:</strong> Users sign in with email and password only. <CardContent>
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. <div className="space-y-3">
{/* Email & Password - Always enabled */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm font-medium">Email & Password</span>
<Badge variant="secondary" className="text-xs">Default</Badge>
</div>
<span className="text-xs text-muted-foreground">Always enabled</span>
</div>
{/* Header Authentication Status */}
{headerAuthEnabled && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm font-medium">Header Authentication</span>
<Badge variant="secondary" className="text-xs">Auto-login</Badge>
</div>
<span className="text-xs text-muted-foreground">Via reverse proxy</span>
</div>
)}
{/* SSO Providers Status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${providers.length > 0 ? 'bg-green-500' : 'bg-muted'}`} />
<span className="text-sm font-medium">SSO/OIDC Providers</span>
</div>
<span className="text-xs text-muted-foreground">
{providers.length > 0 ? `${providers.length} provider${providers.length !== 1 ? 's' : ''} configured` : 'Not configured'}
</span>
</div>
</div>
{/* Header Auth Info */}
{headerAuthEnabled && (
<Alert className="mt-4">
<Shield className="h-4 w-4" />
<AlertDescription className="text-xs">
Header authentication is enabled. Users authenticated by your reverse proxy will be automatically logged in.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
</CardContent>
</Card>
{/* SSO Providers */} {/* SSO Providers */}
<Card> <Card>

135
src/lib/auth-header.ts Normal file
View File

@@ -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<HeaderAuthConfig> = {
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;
}
}

View File

@@ -4,6 +4,7 @@ import { startCleanupService, stopCleanupService } from './lib/cleanup-service';
import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager'; import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager';
import { setupSignalHandlers } from './lib/signal-handlers'; import { setupSignalHandlers } from './lib/signal-handlers';
import { auth } from './lib/auth'; import { auth } from './lib/auth';
import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header';
// Flag to track if recovery has been initialized // Flag to track if recovery has been initialized
let recoveryInitialized = false; let recoveryInitialized = false;
@@ -12,7 +13,7 @@ let cleanupServiceStarted = false;
let shutdownManagerInitialized = false; let shutdownManagerInitialized = false;
export const onRequest = defineMiddleware(async (context, next) => { export const onRequest = defineMiddleware(async (context, next) => {
// Handle Better Auth session // First, try Better Auth session (cookie-based)
try { try {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: context.request.headers, headers: context.request.headers,
@@ -21,10 +22,37 @@ export const onRequest = defineMiddleware(async (context, next) => {
if (session) { if (session) {
context.locals.user = session.user; context.locals.user = session.user;
context.locals.session = session.session; context.locals.session = session.session;
} else {
// 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 { } else {
context.locals.user = null; context.locals.user = null;
context.locals.session = null; context.locals.session = null;
} }
} else {
context.locals.user = null;
context.locals.session = null;
}
}
} catch (error) { } catch (error) {
// If there's an error getting the session, set to null // If there's an error getting the session, set to null
context.locals.user = null; context.locals.user = null;

View File

@@ -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" },
});
};