Skip to content

Commit cad7732

Browse files
committed
Added Header Authentication
1 parent 744064f commit cad7732

File tree

7 files changed

+328
-15
lines changed

7 files changed

+328
-15
lines changed

.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,16 @@ DOCKER_TAG=latest
6666
# TLS/SSL Configuration
6767
# GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing
6868

69+
# ===========================================
70+
# AUTHENTICATION CONFIGURATION
71+
# ===========================================
72+
73+
# Header Authentication (for Reverse Proxy SSO)
74+
# Enable automatic authentication via reverse proxy headers
75+
# HEADER_AUTH_ENABLED=false
76+
# HEADER_AUTH_USER_HEADER=X-Authentik-Username
77+
# HEADER_AUTH_EMAIL_HEADER=X-Authentik-Email
78+
# HEADER_AUTH_NAME_HEADER=X-Authentik-Name
79+
# HEADER_AUTH_AUTO_PROVISION=false
80+
# HEADER_AUTH_ALLOWED_DOMAINS=example.com,company.org
81+

README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,71 @@ If upgrading from a version without token encryption:
218218
bun run migrate:encrypt-tokens
219219
```
220220

221+
## Authentication
222+
223+
Gitea Mirror supports multiple authentication methods. **Email/password authentication is the default and always enabled.**
224+
225+
### 1. Email & Password (Default)
226+
The standard authentication method. First user to sign up becomes the admin.
227+
228+
### 2. Single Sign-On (SSO) with OIDC
229+
Enable users to sign in with external identity providers like Google, Azure AD, Okta, Authentik, or any OIDC-compliant service.
230+
231+
**Configuration:**
232+
1. Navigate to Settings → Authentication & SSO
233+
2. Click "Add Provider"
234+
3. Enter your OIDC provider details:
235+
- Issuer URL (e.g., `https://accounts.google.com`)
236+
- Client ID and Secret from your provider
237+
- Use the "Discover" button to auto-fill endpoints
238+
239+
**Redirect URL for your provider:**
240+
```
241+
https://your-domain.com/api/auth/sso/callback/{provider-id}
242+
```
243+
244+
### 3. Header Authentication (Reverse Proxy)
245+
Perfect for automatic authentication when using reverse proxies like Authentik, Authelia, or Traefik Forward Auth.
246+
247+
**Environment Variables:**
248+
```bash
249+
# Enable header authentication
250+
HEADER_AUTH_ENABLED=true
251+
252+
# Header names (customize based on your proxy)
253+
HEADER_AUTH_USER_HEADER=X-Authentik-Username
254+
HEADER_AUTH_EMAIL_HEADER=X-Authentik-Email
255+
HEADER_AUTH_NAME_HEADER=X-Authentik-Name
256+
257+
# Auto-provision new users
258+
HEADER_AUTH_AUTO_PROVISION=true
259+
260+
# Restrict to specific email domains (optional)
261+
HEADER_AUTH_ALLOWED_DOMAINS=example.com,company.org
262+
```
263+
264+
**How it works:**
265+
- Users authenticated by your reverse proxy are automatically logged in
266+
- No additional login step required
267+
- New users can be auto-provisioned if enabled
268+
- Falls back to regular authentication if headers are missing
269+
270+
**Example Authentik Configuration:**
271+
```nginx
272+
# In your reverse proxy configuration
273+
proxy_set_header X-Authentik-Username $authentik_username;
274+
proxy_set_header X-Authentik-Email $authentik_email;
275+
proxy_set_header X-Authentik-Name $authentik_name;
276+
```
277+
278+
### 4. OAuth Applications (Act as Identity Provider)
279+
Gitea Mirror can also act as an OIDC provider for other applications. Register OAuth applications in Settings → Authentication & SSO → OAuth Applications tab.
280+
281+
**Use cases:**
282+
- Allow other services to authenticate using Gitea Mirror accounts
283+
- Create service-to-service authentication
284+
- Build integrations with your Gitea Mirror instance
285+
221286
## Contributing
222287

223288
Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.

docker-compose.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ services:
5252
- DELAY=${DELAY:-3600}
5353
# Optional: Skip TLS verification (insecure, use only for testing)
5454
# - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false}
55+
# Header Authentication (for Reverse Proxy SSO)
56+
- HEADER_AUTH_ENABLED=${HEADER_AUTH_ENABLED:-false}
57+
- HEADER_AUTH_USER_HEADER=${HEADER_AUTH_USER_HEADER:-X-Authentik-Username}
58+
- HEADER_AUTH_EMAIL_HEADER=${HEADER_AUTH_EMAIL_HEADER:-X-Authentik-Email}
59+
- HEADER_AUTH_NAME_HEADER=${HEADER_AUTH_NAME_HEADER:-X-Authentik-Name}
60+
- HEADER_AUTH_AUTO_PROVISION=${HEADER_AUTH_AUTO_PROVISION:-false}
61+
- HEADER_AUTH_ALLOWED_DOMAINS=${HEADER_AUTH_ALLOWED_DOMAINS:-}
5562
healthcheck:
5663
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/api/health"]
5764
interval: 30s

src/components/config/SSOSettings.tsx

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
99
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
1010
import { apiRequest, showErrorToast } from '@/lib/utils';
1111
import { toast } from 'sonner';
12-
import { Plus, Trash2, ExternalLink, Loader2, AlertCircle, Copy } from 'lucide-react';
12+
import { Plus, Trash2, ExternalLink, Loader2, AlertCircle, Copy, Shield, Info } from 'lucide-react';
1313
import { Separator } from '@/components/ui/separator';
1414
import { Skeleton } from '../ui/skeleton';
15+
import { Badge } from '../ui/badge';
1516

1617
interface SSOProvider {
1718
id: string;
@@ -43,6 +44,7 @@ export function SSOSettings() {
4344
const [isLoading, setIsLoading] = useState(true);
4445
const [showProviderDialog, setShowProviderDialog] = useState(false);
4546
const [isDiscovering, setIsDiscovering] = useState(false);
47+
const [headerAuthEnabled, setHeaderAuthEnabled] = useState(false);
4648

4749
// Form states for new provider
4850
const [providerForm, setProviderForm] = useState({
@@ -66,8 +68,13 @@ export function SSOSettings() {
6668
const loadData = async () => {
6769
setIsLoading(true);
6870
try {
69-
const providersRes = await apiRequest<SSOProvider[]>('/sso/providers');
71+
const [providersRes, headerAuthStatus] = await Promise.all([
72+
apiRequest<SSOProvider[]>('/sso/providers'),
73+
apiRequest<{ enabled: boolean }>('/auth/header-status').catch(() => ({ enabled: false }))
74+
]);
75+
7076
setProviders(providersRes);
77+
setHeaderAuthEnabled(headerAuthStatus.enabled);
7178
} catch (error) {
7279
showErrorToast(error, toast);
7380
} finally {
@@ -183,16 +190,58 @@ export function SSOSettings() {
183190
</div>
184191
</div>
185192

186-
{/* Info Alert for Authentication Flow */}
187-
{providers.length === 0 && (
188-
<Alert>
189-
<AlertCircle className="h-4 w-4" />
190-
<AlertDescription>
191-
<strong>Current authentication:</strong> Users sign in with email and password only.
192-
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.
193-
</AlertDescription>
194-
</Alert>
195-
)}
193+
{/* Authentication Methods Overview */}
194+
<Card className="mb-6">
195+
<CardHeader>
196+
<CardTitle className="text-base">Active Authentication Methods</CardTitle>
197+
</CardHeader>
198+
<CardContent>
199+
<div className="space-y-3">
200+
{/* Email & Password - Always enabled */}
201+
<div className="flex items-center justify-between">
202+
<div className="flex items-center gap-2">
203+
<div className="h-2 w-2 rounded-full bg-green-500" />
204+
<span className="text-sm font-medium">Email & Password</span>
205+
<Badge variant="secondary" className="text-xs">Default</Badge>
206+
</div>
207+
<span className="text-xs text-muted-foreground">Always enabled</span>
208+
</div>
209+
210+
{/* Header Authentication Status */}
211+
{headerAuthEnabled && (
212+
<div className="flex items-center justify-between">
213+
<div className="flex items-center gap-2">
214+
<div className="h-2 w-2 rounded-full bg-green-500" />
215+
<span className="text-sm font-medium">Header Authentication</span>
216+
<Badge variant="secondary" className="text-xs">Auto-login</Badge>
217+
</div>
218+
<span className="text-xs text-muted-foreground">Via reverse proxy</span>
219+
</div>
220+
)}
221+
222+
{/* SSO Providers Status */}
223+
<div className="flex items-center justify-between">
224+
<div className="flex items-center gap-2">
225+
<div className={`h-2 w-2 rounded-full ${providers.length > 0 ? 'bg-green-500' : 'bg-muted'}`} />
226+
<span className="text-sm font-medium">SSO/OIDC Providers</span>
227+
</div>
228+
<span className="text-xs text-muted-foreground">
229+
{providers.length > 0 ? `${providers.length} provider${providers.length !== 1 ? 's' : ''} configured` : 'Not configured'}
230+
</span>
231+
</div>
232+
</div>
233+
234+
{/* Header Auth Info */}
235+
{headerAuthEnabled && (
236+
<Alert className="mt-4">
237+
<Shield className="h-4 w-4" />
238+
<AlertDescription className="text-xs">
239+
Header authentication is enabled. Users authenticated by your reverse proxy will be automatically logged in.
240+
</AlertDescription>
241+
</Alert>
242+
)}
243+
</CardContent>
244+
</Card>
196245

197246
{/* SSO Providers */}
198247
<Card>

src/lib/auth-header.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { db, users } from "./db";
2+
import { eq } from "drizzle-orm";
3+
import { nanoid } from "nanoid";
4+
5+
export interface HeaderAuthConfig {
6+
enabled: boolean;
7+
userHeader: string;
8+
emailHeader?: string;
9+
nameHeader?: string;
10+
autoProvision: boolean;
11+
allowedDomains?: string[];
12+
}
13+
14+
// Default configuration - DISABLED by default
15+
export const defaultHeaderAuthConfig: HeaderAuthConfig = {
16+
enabled: false,
17+
userHeader: "X-Authentik-Username", // Common header name
18+
emailHeader: "X-Authentik-Email",
19+
nameHeader: "X-Authentik-Name",
20+
autoProvision: false,
21+
allowedDomains: [],
22+
};
23+
24+
// Get header auth config from environment or database
25+
export function getHeaderAuthConfig(): HeaderAuthConfig {
26+
// Check environment variables for header auth config
27+
const envConfig: Partial<HeaderAuthConfig> = {
28+
enabled: process.env.HEADER_AUTH_ENABLED === "true",
29+
userHeader: process.env.HEADER_AUTH_USER_HEADER || defaultHeaderAuthConfig.userHeader,
30+
emailHeader: process.env.HEADER_AUTH_EMAIL_HEADER || defaultHeaderAuthConfig.emailHeader,
31+
nameHeader: process.env.HEADER_AUTH_NAME_HEADER || defaultHeaderAuthConfig.nameHeader,
32+
autoProvision: process.env.HEADER_AUTH_AUTO_PROVISION === "true",
33+
allowedDomains: process.env.HEADER_AUTH_ALLOWED_DOMAINS?.split(",").map(d => d.trim()),
34+
};
35+
36+
return {
37+
...defaultHeaderAuthConfig,
38+
...envConfig,
39+
};
40+
}
41+
42+
// Check if header authentication is enabled
43+
export function isHeaderAuthEnabled(): boolean {
44+
const config = getHeaderAuthConfig();
45+
return config.enabled === true;
46+
}
47+
48+
// Extract user info from headers
49+
export function extractUserFromHeaders(headers: Headers): {
50+
username?: string;
51+
email?: string;
52+
name?: string;
53+
} | null {
54+
const config = getHeaderAuthConfig();
55+
56+
if (!config.enabled) {
57+
return null;
58+
}
59+
60+
const username = headers.get(config.userHeader);
61+
const email = config.emailHeader ? headers.get(config.emailHeader) : undefined;
62+
const name = config.nameHeader ? headers.get(config.nameHeader) : undefined;
63+
64+
if (!username) {
65+
return null;
66+
}
67+
68+
// If allowed domains are configured, check email domain
69+
if (config.allowedDomains && config.allowedDomains.length > 0 && email) {
70+
const domain = email.split("@")[1];
71+
if (!config.allowedDomains.includes(domain)) {
72+
console.warn(`Header auth rejected: email domain ${domain} not in allowed list`);
73+
return null;
74+
}
75+
}
76+
77+
return { username, email, name };
78+
}
79+
80+
// Find or create user from header auth
81+
export async function authenticateWithHeaders(headers: Headers) {
82+
const userInfo = extractUserFromHeaders(headers);
83+
84+
if (!userInfo || !userInfo.username) {
85+
return null;
86+
}
87+
88+
const config = getHeaderAuthConfig();
89+
90+
// Try to find existing user by username or email
91+
let existingUser = await db
92+
.select()
93+
.from(users)
94+
.where(eq(users.username, userInfo.username))
95+
.limit(1);
96+
97+
if (existingUser.length === 0 && userInfo.email) {
98+
existingUser = await db
99+
.select()
100+
.from(users)
101+
.where(eq(users.email, userInfo.email))
102+
.limit(1);
103+
}
104+
105+
if (existingUser.length > 0) {
106+
return existingUser[0];
107+
}
108+
109+
// If auto-provisioning is disabled, don't create new users
110+
if (!config.autoProvision) {
111+
console.warn(`Header auth: User ${userInfo.username} not found and auto-provisioning is disabled`);
112+
return null;
113+
}
114+
115+
// Create new user if auto-provisioning is enabled
116+
try {
117+
const newUser = {
118+
id: nanoid(),
119+
username: userInfo.username,
120+
email: userInfo.email || `${userInfo.username}@header-auth.local`,
121+
emailVerified: true, // Trust the auth provider
122+
name: userInfo.name || userInfo.username,
123+
createdAt: new Date(),
124+
updatedAt: new Date(),
125+
};
126+
127+
await db.insert(users).values(newUser);
128+
console.log(`Header auth: Auto-provisioned new user ${userInfo.username}`);
129+
130+
return newUser;
131+
} catch (error) {
132+
console.error("Failed to auto-provision user from header auth:", error);
133+
return null;
134+
}
135+
}

src/middleware.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { startCleanupService, stopCleanupService } from './lib/cleanup-service';
44
import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager';
55
import { setupSignalHandlers } from './lib/signal-handlers';
66
import { auth } from './lib/auth';
7+
import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header';
78

89
// Flag to track if recovery has been initialized
910
let recoveryInitialized = false;
@@ -12,7 +13,7 @@ let cleanupServiceStarted = false;
1213
let shutdownManagerInitialized = false;
1314

1415
export const onRequest = defineMiddleware(async (context, next) => {
15-
// Handle Better Auth session
16+
// First, try Better Auth session (cookie-based)
1617
try {
1718
const session = await auth.api.getSession({
1819
headers: context.request.headers,
@@ -22,8 +23,35 @@ export const onRequest = defineMiddleware(async (context, next) => {
2223
context.locals.user = session.user;
2324
context.locals.session = session.session;
2425
} else {
25-
context.locals.user = null;
26-
context.locals.session = null;
26+
// No cookie session, check for header authentication
27+
if (isHeaderAuthEnabled()) {
28+
const headerUser = await authenticateWithHeaders(context.request.headers);
29+
if (headerUser) {
30+
// Create a session-like object for header auth
31+
context.locals.user = {
32+
id: headerUser.id,
33+
email: headerUser.email,
34+
emailVerified: headerUser.emailVerified,
35+
name: headerUser.name || headerUser.username,
36+
username: headerUser.username,
37+
createdAt: headerUser.createdAt,
38+
updatedAt: headerUser.updatedAt,
39+
};
40+
context.locals.session = {
41+
id: `header-${headerUser.id}`,
42+
userId: headerUser.id,
43+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 1 day
44+
ipAddress: context.request.headers.get('x-forwarded-for') || context.clientAddress,
45+
userAgent: context.request.headers.get('user-agent'),
46+
};
47+
} else {
48+
context.locals.user = null;
49+
context.locals.session = null;
50+
}
51+
} else {
52+
context.locals.user = null;
53+
context.locals.session = null;
54+
}
2755
}
2856
} catch (error) {
2957
// If there's an error getting the session, set to null

0 commit comments

Comments
 (0)