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
# 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
```
## 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.

View File

@@ -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

View File

@@ -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<SSOProvider[]>('/sso/providers');
const [providersRes, headerAuthStatus] = await Promise.all([
apiRequest<SSOProvider[]>('/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() {
</div>
</div>
{/* Info Alert for Authentication Flow */}
{providers.length === 0 && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong>Current authentication:</strong> 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.
</AlertDescription>
</Alert>
)}
{/* Authentication Methods Overview */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-base">Active Authentication Methods</CardTitle>
</CardHeader>
<CardContent>
<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>
</Alert>
)}
</CardContent>
</Card>
{/* SSO Providers */}
<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 { 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

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