Added SSO and OIDC

This commit is contained in:
Arunavo Ray
2025-07-11 01:04:50 +05:30
parent 7cb414c7cb
commit fad78516ef
26 changed files with 5598 additions and 244 deletions

View File

@@ -1,149 +1,236 @@
# Custom CA Certificate Support
# CA Certificates Configuration
This guide explains how to configure Gitea Mirror to work with self-signed certificates or custom Certificate Authorities (CAs).
> **📁 This is the certs directory!** Place your `.crt` certificate files directly in this directory and they will be automatically loaded when the Docker container starts.
This document explains how to configure custom Certificate Authority (CA) certificates for Gitea Mirror when connecting to self-signed or privately signed Gitea instances.
## Overview
When connecting to a Gitea instance that uses self-signed certificates or certificates from a private CA, you need to configure the application to trust these certificates. Gitea Mirror supports mounting custom CA certificates that will be automatically configured for use.
When your Gitea instance uses a self-signed certificate or a certificate signed by a private Certificate Authority (CA), you need to configure Gitea Mirror to trust these certificates.
## Configuration Steps
## Common SSL/TLS Errors
### 1. Prepare Your CA Certificates
If you encounter any of these errors, you need to configure CA certificates:
You're already in the right place! Simply copy your CA certificate(s) into this `certs` directory with `.crt` extension:
- `UNABLE_TO_VERIFY_LEAF_SIGNATURE`
- `SELF_SIGNED_CERT_IN_CHAIN`
- `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`
- `CERT_UNTRUSTED`
- `unable to verify the first certificate`
```bash
# From the project root:
cp /path/to/your/ca-certificate.crt ./certs/
## Configuration by Deployment Method
# Or if you're already in the certs directory:
cp /path/to/your/ca-certificate.crt .
```
### Docker
You can add multiple CA certificates - they will all be combined into a single bundle.
#### Method 1: Volume Mount (Recommended)
### 2. Mount Certificates in Docker
Edit your `docker-compose.yml` file to mount the certificates. You have two options:
**Option 1: Mount individual certificates from certs directory**
```yaml
services:
gitea-mirror:
# ... other configuration ...
volumes:
- gitea-mirror-data:/app/data
- ./certs:/app/certs:ro # Mount CA certificates directory
```
**Option 2: Mount system CA bundle (if your CA is already installed system-wide)**
```yaml
services:
gitea-mirror:
# ... other configuration ...
volumes:
- gitea-mirror-data:/app/data
- /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
```
> **Note**: Use Option 2 if you've already added your CA certificate to your system's certificate store using `update-ca-certificates` or similar commands.
> **System CA Bundle Locations**:
> - Debian/Ubuntu: `/etc/ssl/certs/ca-certificates.crt`
> - RHEL/CentOS/Fedora: `/etc/pki/tls/certs/ca-bundle.crt`
> - Alpine Linux: `/etc/ssl/certs/ca-certificates.crt`
> - macOS: `/etc/ssl/cert.pem`
### 3. Start the Container
Start or restart your container:
```bash
docker-compose up -d
```
The container will automatically:
1. Detect any `.crt` files in `/app/certs` (Option 1) OR detect mounted system CA bundle (Option 2)
2. For Option 1: Combine certificates into a CA bundle
3. Configure Node.js to use these certificates via `NODE_EXTRA_CA_CERTS`
You should see log messages like:
**For Option 1 (individual certificates):**
```
Custom CA certificates found, configuring Node.js to use them...
Adding certificate: my-ca.crt
NODE_EXTRA_CA_CERTS set to: /app/certs/ca-bundle.crt
```
**For Option 2 (system CA bundle):**
```
System CA bundle mounted, configuring Node.js to use it...
NODE_EXTRA_CA_CERTS set to: /etc/ssl/certs/ca-certificates.crt
```
## Testing & Troubleshooting
### Disable TLS Verification (Testing Only)
For testing purposes only, you can disable TLS verification entirely:
```yaml
environment:
- GITEA_SKIP_TLS_VERIFY=true
```
**WARNING**: This is insecure and should never be used in production!
### Common Issues
1. **Certificate not recognized**: Ensure your certificate file has a `.crt` extension
2. **Connection still fails**: Check that the certificate is in PEM format
3. **Multiple certificates needed**: Add all required certificates (root and intermediate) to the certs directory
### Verifying Certificate Loading
Check the container logs to confirm certificates are loaded:
```bash
docker-compose logs gitea-mirror | grep "CA certificates"
```
## Security Considerations
- Always use proper CA certificates in production
- Never disable TLS verification in production environments
- Keep your CA certificates secure and limit access to the certs directory
- Regularly update certificates before they expire
## Example Setup
Here's a complete example for a self-hosted Gitea with custom CA:
1. Copy your Gitea server's CA certificate to this directory:
1. Create a certificates directory:
```bash
cp /etc/ssl/certs/my-company-ca.crt ./certs/
mkdir -p ./certs
```
2. Update `docker-compose.yml`:
2. Copy your CA certificate(s):
```bash
cp /path/to/your-ca-cert.crt ./certs/
```
3. Update `docker-compose.yml`:
```yaml
version: '3.8'
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
image: raylabs/gitea-mirror:latest
volumes:
- gitea-mirror-data:/app/data
- ./certs:/app/certs:ro
- ./data:/app/data
- ./certs:/usr/local/share/ca-certificates:ro
environment:
- GITEA_URL=https://gitea.mycompany.local
- GITEA_TOKEN=your-token
# ... other configuration ...
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt
```
3. Start the service:
4. Restart the container:
```bash
docker-compose up -d
docker-compose down && docker-compose up -d
```
The application will now trust your custom CA when connecting to your Gitea instance.
#### Method 2: Custom Docker Image
Create a `Dockerfile`:
```dockerfile
FROM raylabs/gitea-mirror:latest
# Copy CA certificates
COPY ./certs/*.crt /usr/local/share/ca-certificates/
# Update CA certificates
RUN update-ca-certificates
# Set environment variable
ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt
```
Build and use:
```bash
docker build -t my-gitea-mirror .
```
### Native/Bun
#### Method 1: Environment Variable
```bash
export NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt
bun run start
```
#### Method 2: .env File
Add to your `.env` file:
```
NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt
```
#### Method 3: System CA Store
**Ubuntu/Debian:**
```bash
sudo cp your-ca-cert.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
```
**RHEL/CentOS/Fedora:**
```bash
sudo cp your-ca-cert.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust
```
**macOS:**
```bash
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain your-ca-cert.crt
```
### LXC Container (Proxmox VE)
1. Enter the container:
```bash
pct enter <container-id>
```
2. Create certificates directory:
```bash
mkdir -p /usr/local/share/ca-certificates
```
3. Copy your CA certificate:
```bash
cat > /usr/local/share/ca-certificates/your-ca.crt
```
(Paste certificate content and press Ctrl+D)
4. Update the systemd service:
```bash
cat >> /etc/systemd/system/gitea-mirror.service << EOF
Environment="NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca.crt"
EOF
```
5. Reload and restart:
```bash
systemctl daemon-reload
systemctl restart gitea-mirror
```
## Multiple CA Certificates
### Option 1: Bundle Certificates
```bash
cat ca-cert1.crt ca-cert2.crt ca-cert3.crt > ca-bundle.crt
export NODE_EXTRA_CA_CERTS=/path/to/ca-bundle.crt
```
### Option 2: System CA Store
```bash
# Copy all certificates
cp *.crt /usr/local/share/ca-certificates/
update-ca-certificates
```
## Verification
### 1. Test Gitea Connection
Use the "Test Connection" button in the Gitea configuration section.
### 2. Check Logs
**Docker:**
```bash
docker logs gitea-mirror
```
**Native:**
Check terminal output
**LXC:**
```bash
journalctl -u gitea-mirror -f
```
### 3. Manual Certificate Test
```bash
openssl s_client -connect your-gitea-domain.com:443 -CAfile /path/to/ca-cert.crt
```
## Best Practices
1. **Certificate Security**
- Keep CA certificates secure
- Use read-only mounts in Docker
- Limit certificate file permissions
- Regularly update certificates
2. **Certificate Management**
- Use descriptive certificate filenames
- Document certificate purposes
- Track certificate expiration dates
- Maintain certificate backups
3. **Production Deployment**
- Use proper SSL certificates when possible
- Consider Let's Encrypt for public instances
- Implement certificate rotation procedures
- Monitor certificate expiration
## Troubleshooting
### Certificate not being recognized
- Ensure the certificate is in PEM format
- Check that `NODE_EXTRA_CA_CERTS` points to the correct file
- Restart the application after adding certificates
### Still getting SSL errors
- Verify the complete certificate chain is included
- Check if intermediate certificates are needed
- Ensure the certificate matches the server hostname
### Certificate expired
- Check validity: `openssl x509 -in cert.crt -noout -dates`
- Update with new certificate from your CA
- Restart Gitea Mirror after updating
## Certificate Format
Certificates must be in PEM format. Example:
```
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAKl8bUgMdErlMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
[... certificate content ...]
-----END CERTIFICATE-----
```
If your certificate is in DER format, convert it:
```bash
openssl x509 -inform der -in certificate.cer -out certificate.crt
```

205
docs/SSO-OIDC-SETUP.md Normal file
View File

@@ -0,0 +1,205 @@
# SSO and OIDC Setup Guide
This guide explains how to configure Single Sign-On (SSO) and OpenID Connect (OIDC) provider functionality in Gitea Mirror.
## Overview
Gitea Mirror supports three authentication methods:
1. **Email & Password** - Traditional authentication (always enabled)
2. **SSO (Single Sign-On)** - Allow users to authenticate using external OIDC providers
3. **OIDC Provider** - Allow other applications to authenticate users through Gitea Mirror
## Configuration
All SSO and OIDC settings are managed through the web UI in the Configuration page under the "Authentication" tab.
## Setting up SSO (Single Sign-On)
SSO allows your users to sign in using external identity providers like Google, Okta, Azure AD, etc.
### Adding an SSO Provider
1. Navigate to Configuration → Authentication → SSO Providers
2. Click "Add Provider"
3. Fill in the provider details:
#### Required Fields
- **Issuer URL**: The OIDC issuer URL (e.g., `https://accounts.google.com`)
- **Domain**: The email domain for this provider (e.g., `example.com`)
- **Provider ID**: A unique identifier for this provider (e.g., `google-sso`)
- **Client ID**: The OAuth client ID from your provider
- **Client Secret**: The OAuth client secret from your provider
#### Auto-Discovery
If your provider supports OIDC discovery, you can:
1. Enter the Issuer URL
2. Click "Discover"
3. The system will automatically fetch the authorization and token endpoints
#### Manual Configuration
For providers without discovery support, manually enter:
- **Authorization Endpoint**: The OAuth authorization URL
- **Token Endpoint**: The OAuth token exchange URL
- **JWKS Endpoint**: The JSON Web Key Set URL (optional)
- **UserInfo Endpoint**: The user information endpoint (optional)
### Redirect URL
When configuring your SSO provider, use this redirect URL:
```
https://your-domain.com/api/auth/sso/callback/{provider-id}
```
Replace `{provider-id}` with your chosen Provider ID.
### Example: Google SSO Setup
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new OAuth 2.0 Client ID
3. Add authorized redirect URI: `https://your-domain.com/api/auth/sso/callback/google-sso`
4. In Gitea Mirror:
- Issuer URL: `https://accounts.google.com`
- Domain: `your-company.com`
- Provider ID: `google-sso`
- Client ID: [Your Google Client ID]
- Client Secret: [Your Google Client Secret]
- Click "Discover" to auto-fill endpoints
### Example: Okta SSO Setup
1. In Okta Admin Console, create a new OIDC Web Application
2. Set redirect URI: `https://your-domain.com/api/auth/sso/callback/okta-sso`
3. In Gitea Mirror:
- Issuer URL: `https://your-okta-domain.okta.com`
- Domain: `your-company.com`
- Provider ID: `okta-sso`
- Client ID: [Your Okta Client ID]
- Client Secret: [Your Okta Client Secret]
- Click "Discover" to auto-fill endpoints
## Setting up OIDC Provider
The OIDC Provider feature allows other applications to use Gitea Mirror as their authentication provider.
### Creating OAuth Applications
1. Navigate to Configuration → Authentication → OAuth Applications
2. Click "Create Application"
3. Fill in the application details:
- **Application Name**: Display name for the application
- **Application Type**: Web, Mobile, or Desktop
- **Redirect URLs**: One or more redirect URLs (one per line)
4. After creation, you'll receive:
- **Client ID**: Share this with the application
- **Client Secret**: Keep this secure and share only once
### OIDC Endpoints
Applications can use these standard OIDC endpoints:
- **Discovery**: `https://your-domain.com/.well-known/openid-configuration`
- **Authorization**: `https://your-domain.com/api/auth/oauth2/authorize`
- **Token**: `https://your-domain.com/api/auth/oauth2/token`
- **UserInfo**: `https://your-domain.com/api/auth/oauth2/userinfo`
- **JWKS**: `https://your-domain.com/api/auth/jwks`
### Supported Scopes
- `openid` - Required, provides user ID
- `profile` - User's name, username, and profile picture
- `email` - User's email address and verification status
### Example: Configuring Another Application
For an application to use Gitea Mirror as its OIDC provider:
```javascript
// Example configuration for another app
const oidcConfig = {
issuer: 'https://gitea-mirror.example.com',
clientId: 'client_xxxxxxxxxxxxx',
clientSecret: 'secret_xxxxxxxxxxxxx',
redirectUri: 'https://myapp.com/auth/callback',
scope: 'openid profile email'
};
```
## User Experience
### Logging In with SSO
When SSO is configured:
1. Users see tabs for "Email" and "SSO" on the login page
2. In the SSO tab, they can:
- Click a specific provider button (if configured)
- Enter their work email to be redirected to the appropriate provider
### OAuth Consent Flow
When an application requests authentication:
1. Users are redirected to Gitea Mirror
2. If not logged in, they authenticate first
3. They see a consent screen showing:
- Application name
- Requested permissions
- Option to approve or deny
## Security Considerations
1. **Client Secrets**: Store OAuth client secrets securely
2. **Redirect URLs**: Only add trusted redirect URLs for applications
3. **Scopes**: Applications only receive the data for approved scopes
4. **Token Security**: Access tokens expire and can be revoked
## Troubleshooting
### SSO Login Issues
1. **"Invalid origin" error**: Check that your Gitea Mirror URL matches the configured redirect URI
2. **"Provider not found" error**: Ensure the provider is properly configured and enabled
3. **Redirect loop**: Verify the redirect URI in both Gitea Mirror and the SSO provider match exactly
### OIDC Provider Issues
1. **Application not found**: Ensure the client ID is correct
2. **Invalid redirect URI**: The redirect URI must match exactly what's configured
3. **Consent not working**: Check browser cookies are enabled
## Managing Access
### Revoking SSO Access
Currently, SSO sessions are managed through the identity provider. To revoke access:
1. Log out of Gitea Mirror
2. Revoke access in your identity provider's settings
### Disabling OAuth Applications
To disable an application:
1. Go to Configuration → Authentication → OAuth Applications
2. Find the application
3. Click the delete button
This immediately prevents the application from authenticating new users.
## Best Practices
1. **Use HTTPS**: Always use HTTPS in production for security
2. **Regular Audits**: Periodically review configured SSO providers and OAuth applications
3. **Principle of Least Privilege**: Only grant necessary scopes to applications
4. **Monitor Usage**: Keep track of which applications are accessing your OIDC provider
5. **Secure Storage**: Store client secrets in a secure location, never in code
## Migration Notes
If migrating from the previous JWT-based authentication:
- Existing users remain unaffected
- Users can continue using email/password authentication
- SSO can be added as an additional authentication method

View File

@@ -0,0 +1,64 @@
CREATE TABLE `oauth_access_tokens` (
`id` text PRIMARY KEY NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text,
`access_token_expires_at` integer NOT NULL,
`refresh_token_expires_at` integer,
`client_id` text NOT NULL,
`user_id` text NOT NULL,
`scopes` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_oauth_access_tokens_access_token` ON `oauth_access_tokens` (`access_token`);--> statement-breakpoint
CREATE INDEX `idx_oauth_access_tokens_user_id` ON `oauth_access_tokens` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_access_tokens_client_id` ON `oauth_access_tokens` (`client_id`);--> statement-breakpoint
CREATE TABLE `oauth_applications` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`client_secret` text NOT NULL,
`name` text NOT NULL,
`redirect_urls` text NOT NULL,
`metadata` text,
`type` text NOT NULL,
`disabled` integer DEFAULT false NOT NULL,
`user_id` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_applications_client_id_unique` ON `oauth_applications` (`client_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_applications_client_id` ON `oauth_applications` (`client_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_applications_user_id` ON `oauth_applications` (`user_id`);--> statement-breakpoint
CREATE TABLE `oauth_consent` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`client_id` text NOT NULL,
`scopes` text NOT NULL,
`consent_given` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_oauth_consent_user_id` ON `oauth_consent` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_consent_client_id` ON `oauth_consent` (`client_id`);--> statement-breakpoint
CREATE INDEX `idx_oauth_consent_user_client` ON `oauth_consent` (`user_id`,`client_id`);--> statement-breakpoint
CREATE TABLE `sso_providers` (
`id` text PRIMARY KEY NOT NULL,
`issuer` text NOT NULL,
`domain` text NOT NULL,
`oidc_config` text NOT NULL,
`user_id` text NOT NULL,
`provider_id` text NOT NULL,
`organization_id` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `sso_providers_provider_id_unique` ON `sso_providers` (`provider_id`);--> statement-breakpoint
CREATE INDEX `idx_sso_providers_provider_id` ON `sso_providers` (`provider_id`);--> statement-breakpoint
CREATE INDEX `idx_sso_providers_domain` ON `sso_providers` (`domain`);--> statement-breakpoint
CREATE INDEX `idx_sso_providers_issuer` ON `sso_providers` (`issuer`);

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,13 @@
"when": 1752171873627,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1752173351102,
"tag": "0001_polite_exodus",
"breakpoints": true
}
]
}

31
scripts/run-migration.ts Normal file
View File

@@ -0,0 +1,31 @@
import { Database } from "bun:sqlite";
import { readFileSync } from "fs";
import path from "path";
const dbPath = path.join(process.cwd(), "data/gitea-mirror.db");
const db = new Database(dbPath);
// Read the migration file
const migrationPath = path.join(process.cwd(), "drizzle/0001_polite_exodus.sql");
const migration = readFileSync(migrationPath, "utf-8");
// Split by statement-breakpoint and execute each statement
const statements = migration.split("--> statement-breakpoint").map(s => s.trim()).filter(s => s);
try {
db.run("BEGIN TRANSACTION");
for (const statement of statements) {
console.log(`Executing: ${statement.substring(0, 50)}...`);
db.run(statement);
}
db.run("COMMIT");
console.log("Migration completed successfully!");
} catch (error) {
db.run("ROLLBACK");
console.error("Migration failed:", error);
process.exit(1);
} finally {
db.close();
}

View File

@@ -5,14 +5,27 @@ import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuth } from '@/hooks/useAuth';
import { useAuthMethods } from '@/hooks/useAuthMethods';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { authClient } from '@/lib/auth-client';
import { Separator } from '@/components/ui/separator';
import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils';
import { Loader2, Mail, Globe } from 'lucide-react';
export function LoginForm() {
const [isLoading, setIsLoading] = useState(false);
const [ssoEmail, setSsoEmail] = useState('');
const { login } = useAuth();
const { authMethods, isLoading: isLoadingMethods } = useAuthMethods();
// Determine which tab to show by default
const getDefaultTab = () => {
if (authMethods.emailPassword) return 'email';
if (authMethods.sso.enabled) return 'sso';
return 'email'; // fallback
};
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -42,6 +55,26 @@ export function LoginForm() {
}
}
async function handleSSOLogin(domain?: string) {
setIsLoading(true);
try {
if (!domain && !ssoEmail) {
toast.error('Please enter your email or select a provider');
return;
}
await authClient.signIn.sso({
email: ssoEmail || undefined,
domain: domain,
callbackURL: '/',
});
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsLoading(false);
}
}
return (
<>
<Card className="w-full max-w-md">
@@ -63,45 +96,182 @@ export function LoginForm() {
Log in to manage your GitHub to Gitea mirroring
</CardDescription>
</CardHeader>
<CardContent>
<form id="login-form" onSubmit={handleLogin}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Enter your email"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Enter your password"
disabled={isLoading}
/>
</div>
{isLoadingMethods ? (
<CardContent>
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</form>
</CardContent>
<CardFooter>
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log In'}
</Button>
</CardFooter>
</CardContent>
) : (
<>
{/* Show tabs only if multiple auth methods are available */}
{authMethods.sso.enabled && authMethods.emailPassword ? (
<Tabs defaultValue={getDefaultTab()} className="w-full">
<TabsList className="grid w-full grid-cols-2 mx-6" style={{ width: 'calc(100% - 3rem)' }}>
<TabsTrigger value="email">
<Mail className="h-4 w-4 mr-2" />
Email
</TabsTrigger>
<TabsTrigger value="sso">
<Globe className="h-4 w-4 mr-2" />
SSO
</TabsTrigger>
</TabsList>
<TabsContent value="email">
<CardContent>
<form id="login-form" onSubmit={handleLogin}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Enter your email"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Enter your password"
disabled={isLoading}
/>
</div>
</div>
</form>
</CardContent>
<CardFooter>
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log In'}
</Button>
</CardFooter>
</TabsContent>
<TabsContent value="sso">
<CardContent>
<div className="space-y-4">
{authMethods.sso.providers.length > 0 && (
<>
<div className="space-y-2">
<p className="text-sm text-muted-foreground text-center">
Sign in with your organization account
</p>
{authMethods.sso.providers.map(provider => (
<Button
key={provider.id}
variant="outline"
className="w-full"
onClick={() => handleSSOLogin(provider.domain)}
disabled={isLoading}
>
<Globe className="h-4 w-4 mr-2" />
Sign in with {provider.domain}
</Button>
))}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or</span>
</div>
</div>
</>
)}
<div>
<label htmlFor="sso-email" className="block text-sm font-medium mb-1">
Work Email
</label>
<input
id="sso-email"
type="email"
value={ssoEmail}
onChange={(e) => setSsoEmail(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Enter your work email"
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground mt-1">
We'll redirect you to your organization's SSO provider
</p>
</div>
</div>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={() => handleSSOLogin()}
disabled={isLoading || !ssoEmail}
>
{isLoading ? 'Redirecting...' : 'Continue with SSO'}
</Button>
</CardFooter>
</TabsContent>
</Tabs>
) : (
// Single auth method - show email/password only
<>
<CardContent>
<form id="login-form" onSubmit={handleLogin}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Enter your email"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Enter your password"
disabled={isLoading}
/>
</div>
</div>
</form>
</CardContent>
<CardFooter>
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log In'}
</Button>
</CardFooter>
</>
)}
</>
)}
<div className="px-6 pb-6 text-center">
<p className="text-sm text-muted-foreground">
Don't have an account? Contact your administrator.

View File

@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { GitHubConfigForm } from './GitHubConfigForm';
import { GiteaConfigForm } from './GiteaConfigForm';
import { AutomationSettings } from './AutomationSettings';
import { SSOSettings } from './SSOSettings';
import type {
ConfigApiResponse,
GiteaConfig,
@@ -20,6 +21,7 @@ import { RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { Skeleton } from '@/components/ui/skeleton';
import { invalidateConfigCache } from '@/hooks/useConfigStatus';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
type ConfigState = {
githubConfig: GitHubConfig;
@@ -601,65 +603,71 @@ export function ConfigTabs() {
</div>
</div>
{/* Content section - Grid layout */}
<div className="space-y-6">
{/* GitHub & Gitea connections - Side by side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-stretch">
<GitHubConfigForm
config={config.githubConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
githubConfig:
typeof update === 'function'
? update(prev.githubConfig)
: update,
}))
}
mirrorOptions={config.mirrorOptions}
setMirrorOptions={update =>
setConfig(prev => ({
...prev,
mirrorOptions:
typeof update === 'function'
? update(prev.mirrorOptions)
: update,
}))
}
advancedOptions={config.advancedOptions}
setAdvancedOptions={update =>
setConfig(prev => ({
...prev,
advancedOptions:
typeof update === 'function'
? update(prev.advancedOptions)
: update,
}))
}
onAutoSave={autoSaveGitHubConfig}
onMirrorOptionsAutoSave={autoSaveMirrorOptions}
onAdvancedOptionsAutoSave={autoSaveAdvancedOptions}
isAutoSaving={isAutoSavingGitHub}
/>
<GiteaConfigForm
config={config.giteaConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
giteaConfig:
typeof update === 'function'
? update(prev.giteaConfig)
: update,
}))
}
onAutoSave={autoSaveGiteaConfig}
isAutoSaving={isAutoSavingGitea}
githubUsername={config.githubConfig.username}
/>
</div>
{/* Content section - Tabs layout */}
<Tabs defaultValue="connections" className="space-y-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="connections">Connections</TabsTrigger>
<TabsTrigger value="automation">Automation</TabsTrigger>
<TabsTrigger value="sso">Authentication</TabsTrigger>
</TabsList>
{/* Automation & Maintenance - Full width */}
<div>
<TabsContent value="connections" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-stretch">
<GitHubConfigForm
config={config.githubConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
githubConfig:
typeof update === 'function'
? update(prev.githubConfig)
: update,
}))
}
mirrorOptions={config.mirrorOptions}
setMirrorOptions={update =>
setConfig(prev => ({
...prev,
mirrorOptions:
typeof update === 'function'
? update(prev.mirrorOptions)
: update,
}))
}
advancedOptions={config.advancedOptions}
setAdvancedOptions={update =>
setConfig(prev => ({
...prev,
advancedOptions:
typeof update === 'function'
? update(prev.advancedOptions)
: update,
}))
}
onAutoSave={autoSaveGitHubConfig}
onMirrorOptionsAutoSave={autoSaveMirrorOptions}
onAdvancedOptionsAutoSave={autoSaveAdvancedOptions}
isAutoSaving={isAutoSavingGitHub}
/>
<GiteaConfigForm
config={config.giteaConfig}
setConfig={update =>
setConfig(prev => ({
...prev,
giteaConfig:
typeof update === 'function'
? update(prev.giteaConfig)
: update,
}))
}
onAutoSave={autoSaveGiteaConfig}
isAutoSaving={isAutoSavingGitea}
githubUsername={config.githubConfig.username}
/>
</div>
</TabsContent>
<TabsContent value="automation" className="space-y-4">
<AutomationSettings
scheduleConfig={config.scheduleConfig}
cleanupConfig={config.cleanupConfig}
@@ -674,8 +682,12 @@ export function ConfigTabs() {
isAutoSavingSchedule={isAutoSavingSchedule}
isAutoSavingCleanup={isAutoSavingCleanup}
/>
</div>
</div>
</TabsContent>
<TabsContent value="sso" className="space-y-4">
<SSOSettings />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,634 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
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 { Separator } from '@/components/ui/separator';
import { Skeleton } from '../ui/skeleton';
interface SSOProvider {
id: string;
issuer: string;
domain: string;
providerId: string;
organizationId?: string;
oidcConfig: {
clientId: string;
clientSecret: string;
authorizationEndpoint: string;
tokenEndpoint: string;
jwksEndpoint: string;
userInfoEndpoint: string;
mapping: {
id: string;
email: string;
emailVerified: string;
name: string;
image: string;
};
};
createdAt: string;
updatedAt: string;
}
interface OAuthApplication {
id: string;
clientId: string;
clientSecret?: string;
name: string;
redirectURLs: string;
type: string;
disabled: boolean;
metadata?: string;
createdAt: string;
updatedAt: string;
}
export function SSOSettings() {
const [activeTab, setActiveTab] = useState('providers');
const [providers, setProviders] = useState<SSOProvider[]>([]);
const [applications, setApplications] = useState<OAuthApplication[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showProviderDialog, setShowProviderDialog] = useState(false);
const [showAppDialog, setShowAppDialog] = useState(false);
const [isDiscovering, setIsDiscovering] = useState(false);
// Form states for new provider
const [providerForm, setProviderForm] = useState({
issuer: '',
domain: '',
providerId: '',
clientId: '',
clientSecret: '',
authorizationEndpoint: '',
tokenEndpoint: '',
jwksEndpoint: '',
userInfoEndpoint: '',
});
// Form states for new application
const [appForm, setAppForm] = useState({
name: '',
redirectURLs: '',
type: 'web',
});
// Authentication methods state
const [authMethods, setAuthMethods] = useState({
emailPassword: true,
sso: false,
oidc: false,
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setIsLoading(true);
try {
const [providersRes, appsRes] = await Promise.all([
apiRequest<SSOProvider[]>('/sso/providers'),
apiRequest<OAuthApplication[]>('/sso/applications'),
]);
setProviders(providersRes);
setApplications(appsRes);
// Set auth methods based on what's configured
setAuthMethods({
emailPassword: true, // Always enabled
sso: providersRes.length > 0,
oidc: appsRes.length > 0,
});
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsLoading(false);
}
};
const discoverOIDC = async () => {
if (!providerForm.issuer) {
toast.error('Please enter an issuer URL');
return;
}
setIsDiscovering(true);
try {
const discovered = await apiRequest<any>('/sso/discover', {
method: 'POST',
data: { issuer: providerForm.issuer },
});
setProviderForm(prev => ({
...prev,
authorizationEndpoint: discovered.authorizationEndpoint || '',
tokenEndpoint: discovered.tokenEndpoint || '',
jwksEndpoint: discovered.jwksEndpoint || '',
userInfoEndpoint: discovered.userInfoEndpoint || '',
domain: discovered.suggestedDomain || prev.domain,
}));
toast.success('OIDC configuration discovered successfully');
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsDiscovering(false);
}
};
const createProvider = async () => {
try {
const newProvider = await apiRequest<SSOProvider>('/sso/providers', {
method: 'POST',
data: {
...providerForm,
mapping: {
id: 'sub',
email: 'email',
emailVerified: 'email_verified',
name: 'name',
image: 'picture',
},
},
});
setProviders([...providers, newProvider]);
setShowProviderDialog(false);
setProviderForm({
issuer: '',
domain: '',
providerId: '',
clientId: '',
clientSecret: '',
authorizationEndpoint: '',
tokenEndpoint: '',
jwksEndpoint: '',
userInfoEndpoint: '',
});
toast.success('SSO provider created successfully');
// Enable SSO auth method
setAuthMethods(prev => ({ ...prev, sso: true }));
} catch (error) {
showErrorToast(error, toast);
}
};
const deleteProvider = async (id: string) => {
try {
await apiRequest(`/sso/providers?id=${id}`, { method: 'DELETE' });
setProviders(providers.filter(p => p.id !== id));
toast.success('Provider deleted successfully');
// Disable SSO if no providers left
if (providers.length === 1) {
setAuthMethods(prev => ({ ...prev, sso: false }));
}
} catch (error) {
showErrorToast(error, toast);
}
};
const createApplication = async () => {
try {
const newApp = await apiRequest<OAuthApplication>('/sso/applications', {
method: 'POST',
data: {
...appForm,
redirectURLs: appForm.redirectURLs.split('\n').filter(url => url.trim()),
},
});
setApplications([...applications, newApp]);
setShowAppDialog(false);
setAppForm({
name: '',
redirectURLs: '',
type: 'web',
});
toast.success('OAuth application created successfully');
// Enable OIDC auth method
setAuthMethods(prev => ({ ...prev, oidc: true }));
} catch (error) {
showErrorToast(error, toast);
}
};
const deleteApplication = async (id: string) => {
try {
await apiRequest(`/sso/applications?id=${id}`, { method: 'DELETE' });
setApplications(applications.filter(a => a.id !== id));
toast.success('Application deleted successfully');
// Disable OIDC if no applications left
if (applications.length === 1) {
setAuthMethods(prev => ({ ...prev, oidc: false }));
}
} catch (error) {
showErrorToast(error, toast);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="space-y-6">
{/* Authentication Methods Card */}
<Card>
<CardHeader>
<CardTitle>Authentication Methods</CardTitle>
<CardDescription>
Choose which authentication methods are available for users
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Email & Password</Label>
<p className="text-sm text-muted-foreground">
Traditional email and password authentication
</p>
</div>
<Switch
checked={authMethods.emailPassword}
disabled
aria-label="Email & Password authentication"
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Single Sign-On (SSO)</Label>
<p className="text-sm text-muted-foreground">
Allow users to sign in with external OIDC providers
</p>
</div>
<Switch
checked={authMethods.sso}
disabled
aria-label="SSO authentication"
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>OIDC Provider</Label>
<p className="text-sm text-muted-foreground">
Allow other applications to authenticate through this app
</p>
</div>
<Switch
checked={authMethods.oidc}
disabled
aria-label="OIDC Provider"
/>
</div>
</CardContent>
</Card>
{/* SSO Configuration Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="providers">SSO Providers</TabsTrigger>
<TabsTrigger value="applications">OAuth Applications</TabsTrigger>
</TabsList>
<TabsContent value="providers" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>SSO Providers</CardTitle>
<CardDescription>
Configure external OIDC providers for user authentication
</CardDescription>
</div>
<Dialog open={showProviderDialog} onOpenChange={setShowProviderDialog}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Provider
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add SSO Provider</DialogTitle>
<DialogDescription>
Configure an external OIDC provider for user authentication
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="issuer">Issuer URL</Label>
<div className="flex gap-2">
<Input
id="issuer"
value={providerForm.issuer}
onChange={e => setProviderForm(prev => ({ ...prev, issuer: e.target.value }))}
placeholder="https://accounts.google.com"
/>
<Button
variant="outline"
onClick={discoverOIDC}
disabled={isDiscovering}
>
{isDiscovering ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Discover'}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="domain">Domain</Label>
<Input
id="domain"
value={providerForm.domain}
onChange={e => setProviderForm(prev => ({ ...prev, domain: e.target.value }))}
placeholder="example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="providerId">Provider ID</Label>
<Input
id="providerId"
value={providerForm.providerId}
onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))}
placeholder="google-sso"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="clientId">Client ID</Label>
<Input
id="clientId"
value={providerForm.clientId}
onChange={e => setProviderForm(prev => ({ ...prev, clientId: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clientSecret">Client Secret</Label>
<Input
id="clientSecret"
type="password"
value={providerForm.clientSecret}
onChange={e => setProviderForm(prev => ({ ...prev, clientSecret: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="authEndpoint">Authorization Endpoint</Label>
<Input
id="authEndpoint"
value={providerForm.authorizationEndpoint}
onChange={e => setProviderForm(prev => ({ ...prev, authorizationEndpoint: e.target.value }))}
placeholder="https://accounts.google.com/o/oauth2/auth"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tokenEndpoint">Token Endpoint</Label>
<Input
id="tokenEndpoint"
value={providerForm.tokenEndpoint}
onChange={e => setProviderForm(prev => ({ ...prev, tokenEndpoint: e.target.value }))}
placeholder="https://oauth2.googleapis.com/token"
/>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Redirect URL: {window.location.origin}/api/auth/sso/callback/{providerForm.providerId || '{provider-id}'}
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowProviderDialog(false)}>
Cancel
</Button>
<Button onClick={createProvider}>Create Provider</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{providers.length === 0 ? (
<Alert>
<AlertDescription>
No SSO providers configured. Add a provider to enable SSO authentication.
</AlertDescription>
</Alert>
) : (
<div className="space-y-4">
{providers.map(provider => (
<Card key={provider.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">{provider.providerId}</h4>
<p className="text-sm text-muted-foreground">{provider.domain}</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => deleteProvider(provider.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="font-medium">Issuer</p>
<p className="text-muted-foreground">{provider.issuer}</p>
</div>
<div>
<p className="font-medium">Client ID</p>
<p className="text-muted-foreground font-mono">{provider.oidcConfig.clientId}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="applications" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>OAuth Applications</CardTitle>
<CardDescription>
Applications that can authenticate users through this OIDC provider
</CardDescription>
</div>
<Dialog open={showAppDialog} onOpenChange={setShowAppDialog}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Application
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create OAuth Application</DialogTitle>
<DialogDescription>
Register a new application that can use this service for authentication
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="appName">Application Name</Label>
<Input
id="appName"
value={appForm.name}
onChange={e => setAppForm(prev => ({ ...prev, name: e.target.value }))}
placeholder="My Application"
/>
</div>
<div className="space-y-2">
<Label htmlFor="appType">Application Type</Label>
<Select
value={appForm.type}
onValueChange={value => setAppForm(prev => ({ ...prev, type: value }))}
>
<SelectTrigger id="appType">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="web">Web Application</SelectItem>
<SelectItem value="mobile">Mobile Application</SelectItem>
<SelectItem value="desktop">Desktop Application</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="redirectURLs">Redirect URLs (one per line)</Label>
<textarea
id="redirectURLs"
className="flex min-h-[100px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={appForm.redirectURLs}
onChange={e => setAppForm(prev => ({ ...prev, redirectURLs: e.target.value }))}
placeholder="https://example.com/callback&#10;https://example.com/auth/callback"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAppDialog(false)}>
Cancel
</Button>
<Button onClick={createApplication}>Create Application</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{applications.length === 0 ? (
<Alert>
<AlertDescription>
No OAuth applications registered. Create an application to enable OIDC provider functionality.
</AlertDescription>
</Alert>
) : (
<div className="space-y-4">
{applications.map(app => (
<Card key={app.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">{app.name}</h4>
<p className="text-sm text-muted-foreground">{app.type} application</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => deleteApplication(app.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Client ID</p>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(app.clientId)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground font-mono bg-muted p-2 rounded">
{app.clientId}
</p>
</div>
{app.clientSecret && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Client secret is only shown once. Store it securely.
</AlertDescription>
</Alert>
)}
<div>
<p className="text-sm font-medium mb-1">Redirect URLs</p>
<div className="text-sm text-muted-foreground space-y-1">
{app.redirectURLs.split(',').map((url, i) => (
<p key={i} className="font-mono">{url}</p>
))}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,276 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { authClient } from '@/lib/auth-client';
import { apiRequest, showErrorToast } from '@/lib/utils';
import { toast, Toaster } from 'sonner';
import { Shield, User, Mail, ChevronRight, AlertTriangle, Loader2 } from 'lucide-react';
interface OAuthApplication {
id: string;
clientId: string;
name: string;
redirectURLs: string;
type: string;
}
interface ConsentRequest {
clientId: string;
scope: string;
state?: string;
redirectUri?: string;
}
export default function ConsentPage() {
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [application, setApplication] = useState<OAuthApplication | null>(null);
const [scopes, setScopes] = useState<string[]>([]);
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadConsentDetails();
}, []);
const loadConsentDetails = async () => {
try {
const params = new URLSearchParams(window.location.search);
const clientId = params.get('client_id');
const scope = params.get('scope');
if (!clientId) {
setError('Invalid authorization request: missing client ID');
return;
}
// Fetch application details
const apps = await apiRequest<OAuthApplication[]>('/sso/applications');
const app = apps.find(a => a.clientId === clientId);
if (!app) {
setError('Invalid authorization request: unknown application');
return;
}
setApplication(app);
// Parse requested scopes
const requestedScopes = scope ? scope.split(' ').filter(s => s) : ['openid'];
setScopes(requestedScopes);
// By default, select all requested scopes
setSelectedScopes(new Set(requestedScopes));
} catch (error) {
console.error('Failed to load consent details:', error);
setError('Failed to load authorization details');
} finally {
setIsLoading(false);
}
};
const handleConsent = async (accept: boolean) => {
setIsSubmitting(true);
try {
const result = await authClient.oauth2.consent({
accept,
});
if (result.error) {
throw new Error(result.error.message || 'Consent failed');
}
// The consent method should handle the redirect
if (!accept) {
// If denied, redirect back to the application with error
const params = new URLSearchParams(window.location.search);
const redirectUri = params.get('redirect_uri');
if (redirectUri) {
window.location.href = `${redirectUri}?error=access_denied`;
}
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsSubmitting(false);
}
};
const toggleScope = (scope: string) => {
// openid scope is always required
if (scope === 'openid') return;
const newSelected = new Set(selectedScopes);
if (newSelected.has(scope)) {
newSelected.delete(scope);
} else {
newSelected.add(scope);
}
setSelectedScopes(newSelected);
};
const getScopeDescription = (scope: string): { name: string; description: string; icon: any } => {
const scopeDescriptions: Record<string, { name: string; description: string; icon: any }> = {
openid: {
name: 'Basic Information',
description: 'Your user ID (required)',
icon: User,
},
profile: {
name: 'Profile Information',
description: 'Your name, username, and profile picture',
icon: User,
},
email: {
name: 'Email Address',
description: 'Your email address and verification status',
icon: Mail,
},
};
return scopeDescriptions[scope] || {
name: scope,
description: `Access to ${scope} information`,
icon: Shield,
};
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mb-4">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<CardTitle className="text-2xl">Authorization Error</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
<CardFooter>
<Button
variant="outline"
className="w-full"
onClick={() => window.history.back()}
>
Go Back
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
<>
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Shield className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">Authorize {application?.name}</CardTitle>
<CardDescription>
This application is requesting access to your account
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted p-4 rounded-lg">
<p className="text-sm font-medium mb-2">Requested permissions:</p>
<div className="space-y-3">
{scopes.map(scope => {
const scopeInfo = getScopeDescription(scope);
const Icon = scopeInfo.icon;
const isRequired = scope === 'openid';
return (
<div key={scope} className="flex items-start space-x-3">
<Checkbox
id={scope}
checked={selectedScopes.has(scope)}
onCheckedChange={() => toggleScope(scope)}
disabled={isRequired || isSubmitting}
/>
<div className="flex-1">
<Label
htmlFor={scope}
className="flex items-center gap-2 font-medium cursor-pointer"
>
<Icon className="h-4 w-4" />
{scopeInfo.name}
{isRequired && (
<span className="text-xs text-muted-foreground">(required)</span>
)}
</Label>
<p className="text-xs text-muted-foreground mt-1">
{scopeInfo.description}
</p>
</div>
</div>
);
})}
</div>
</div>
<Separator />
<div className="text-sm text-muted-foreground">
<p className="flex items-center gap-1">
<ChevronRight className="h-3 w-3" />
You'll be redirected to {application?.type === 'web' ? 'the website' : 'the application'}
</p>
<p className="flex items-center gap-1 mt-1">
<ChevronRight className="h-3 w-3" />
You can revoke access at any time in your account settings
</p>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => handleConsent(false)}
disabled={isSubmitting}
>
Deny
</Button>
<Button
className="flex-1"
onClick={() => handleConsent(true)}
disabled={isSubmitting || selectedScopes.size === 0}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Authorizing...
</>
) : (
'Authorize'
)}
</Button>
</CardFooter>
</Card>
</div>
<Toaster />
</>
);
}

View File

@@ -0,0 +1,65 @@
import { useState, useEffect } from 'react';
import { apiRequest } from '@/lib/utils';
interface AuthMethods {
emailPassword: boolean;
sso: {
enabled: boolean;
providers: Array<{
id: string;
providerId: string;
domain: string;
}>;
};
oidc: {
enabled: boolean;
};
}
export function useAuthMethods() {
const [authMethods, setAuthMethods] = useState<AuthMethods>({
emailPassword: true,
sso: {
enabled: false,
providers: [],
},
oidc: {
enabled: false,
},
});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadAuthMethods();
}, []);
const loadAuthMethods = async () => {
try {
// Check SSO providers
const providers = await apiRequest<any[]>('/sso/providers').catch(() => []);
const applications = await apiRequest<any[]>('/sso/applications').catch(() => []);
setAuthMethods({
emailPassword: true, // Always enabled
sso: {
enabled: providers.length > 0,
providers: providers.map(p => ({
id: p.id,
providerId: p.providerId,
domain: p.domain,
})),
},
oidc: {
enabled: applications.length > 0,
},
});
} catch (error) {
// If we can't load auth methods, default to email/password only
console.error('Failed to load auth methods:', error);
} finally {
setIsLoading(false);
}
};
return { authMethods, isLoading };
}

View File

@@ -1,8 +1,14 @@
import { createAuthClient } from "better-auth/react";
import { oidcClient } from "better-auth/client/plugins";
import { ssoClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
// The base URL is optional when running on the same domain
// Better Auth will use the current domain by default
plugins: [
oidcClient(),
ssoClient(),
],
});
// Export commonly used methods for convenience

View File

@@ -1,5 +1,7 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { oidcProvider } from "better-auth/plugins";
import { sso } from "better-auth/plugins/sso";
import { db, users } from "./db";
import * as schema from "./db/schema";
import { eq } from "drizzle-orm";
@@ -50,8 +52,42 @@ export const auth = betterAuth({
},
},
// TODO: Add plugins for SSO and OIDC support in the future
// plugins: [],
// Plugins configuration
plugins: [
// OIDC Provider plugin - allows this app to act as an OIDC provider
oidcProvider({
loginPage: "/login",
consentPage: "/oauth/consent",
// Allow dynamic client registration for flexibility
allowDynamicClientRegistration: true,
// Customize user info claims based on scopes
getAdditionalUserInfoClaim: (user, scopes) => {
const claims: Record<string, any> = {};
if (scopes.includes("profile")) {
claims.username = user.username;
}
return claims;
},
}),
// SSO plugin - allows users to authenticate with external OIDC providers
sso({
// Provision new users when they sign in with SSO
provisionUser: async (user) => {
// Derive username from email if not provided
const username = user.name || user.email?.split('@')[0] || 'user';
return {
...user,
username,
};
},
// Organization provisioning settings
organizationProvisioning: {
disabled: false,
defaultRole: "member",
},
}),
],
// Trusted origins for CORS
trustedOrigins: [

View File

@@ -70,5 +70,9 @@ export {
organizations,
sessions,
accounts,
verificationTokens
verificationTokens,
oauthApplications,
oauthAccessTokens,
oauthConsent,
ssoProviders
} from "./schema";

View File

@@ -504,6 +504,102 @@ export const verificationTokens = sqliteTable("verification_tokens", {
};
});
// ===== OIDC Provider Tables =====
// OAuth Applications table
export const oauthApplications = sqliteTable("oauth_applications", {
id: text("id").primaryKey(),
clientId: text("client_id").notNull().unique(),
clientSecret: text("client_secret").notNull(),
name: text("name").notNull(),
redirectURLs: text("redirect_urls").notNull(), // Comma-separated list
metadata: text("metadata"), // JSON string
type: text("type").notNull(), // web, mobile, etc
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
userId: text("user_id"), // Optional - owner of the application
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
clientIdIdx: index("idx_oauth_applications_client_id").on(table.clientId),
userIdIdx: index("idx_oauth_applications_user_id").on(table.userId),
};
});
// OAuth Access Tokens table
export const oauthAccessTokens = sqliteTable("oauth_access_tokens", {
id: text("id").primaryKey(),
accessToken: text("access_token").notNull(),
refreshToken: text("refresh_token"),
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }).notNull(),
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
clientId: text("client_id").notNull(),
userId: text("user_id").notNull().references(() => users.id),
scopes: text("scopes").notNull(), // Comma-separated list
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
accessTokenIdx: index("idx_oauth_access_tokens_access_token").on(table.accessToken),
userIdIdx: index("idx_oauth_access_tokens_user_id").on(table.userId),
clientIdIdx: index("idx_oauth_access_tokens_client_id").on(table.clientId),
};
});
// OAuth Consent table
export const oauthConsent = sqliteTable("oauth_consent", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id),
clientId: text("client_id").notNull(),
scopes: text("scopes").notNull(), // Comma-separated list
consentGiven: integer("consent_given", { mode: "boolean" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
userIdIdx: index("idx_oauth_consent_user_id").on(table.userId),
clientIdIdx: index("idx_oauth_consent_client_id").on(table.clientId),
userClientIdx: index("idx_oauth_consent_user_client").on(table.userId, table.clientId),
};
});
// ===== SSO Provider Tables =====
// SSO Providers table
export const ssoProviders = sqliteTable("sso_providers", {
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
domain: text("domain").notNull(),
oidcConfig: text("oidc_config").notNull(), // JSON string with OIDC configuration
userId: text("user_id").notNull(), // Admin who created this provider
providerId: text("provider_id").notNull().unique(), // Unique identifier for the provider
organizationId: text("organization_id"), // Optional - if provider is linked to an organization
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
providerIdIdx: index("idx_sso_providers_provider_id").on(table.providerId),
domainIdx: index("idx_sso_providers_domain").on(table.domain),
issuerIdx: index("idx_sso_providers_issuer").on(table.issuer),
};
});
// Export type definitions
export type User = z.infer<typeof userSchema>;
export type Config = z.infer<typeof configSchema>;

View File

@@ -9,6 +9,15 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function generateRandomString(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function formatDate(date?: Date | string | null): string {
if (!date) return "Never";
return new Intl.DateTimeFormat("en-US", {

View File

@@ -0,0 +1,176 @@
import type { APIContext } from "astro";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
import { db, oauthApplications } from "@/lib/db";
import { nanoid } from "nanoid";
import { eq } from "drizzle-orm";
import { generateRandomString } from "@/lib/utils";
// GET /api/sso/applications - List all OAuth applications
export async function GET(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const applications = await db.select().from(oauthApplications);
// Don't send client secrets in list response
const sanitizedApps = applications.map(app => ({
...app,
clientSecret: undefined,
}));
return new Response(JSON.stringify(sanitizedApps), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO applications API");
}
}
// POST /api/sso/applications - Create a new OAuth application
export async function POST(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const body = await context.request.json();
const { name, redirectURLs, type = "web", metadata } = body;
// Validate required fields
if (!name || !redirectURLs || redirectURLs.length === 0) {
return new Response(
JSON.stringify({ error: "Name and at least one redirect URL are required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
// Generate client credentials
const clientId = `client_${generateRandomString(32)}`;
const clientSecret = `secret_${generateRandomString(48)}`;
// Insert new application
const [newApp] = await db
.insert(oauthApplications)
.values({
id: nanoid(),
clientId,
clientSecret,
name,
redirectURLs: Array.isArray(redirectURLs) ? redirectURLs.join(",") : redirectURLs,
type,
metadata: metadata ? JSON.stringify(metadata) : null,
userId: user.id,
disabled: false,
})
.returning();
return new Response(JSON.stringify(newApp), {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO applications API");
}
}
// PUT /api/sso/applications/:id - Update an OAuth application
export async function PUT(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const url = new URL(context.request.url);
const appId = url.pathname.split("/").pop();
if (!appId) {
return new Response(
JSON.stringify({ error: "Application ID is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const body = await context.request.json();
const { name, redirectURLs, disabled, metadata } = body;
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (redirectURLs !== undefined) {
updateData.redirectURLs = Array.isArray(redirectURLs)
? redirectURLs.join(",")
: redirectURLs;
}
if (disabled !== undefined) updateData.disabled = disabled;
if (metadata !== undefined) updateData.metadata = JSON.stringify(metadata);
const [updated] = await db
.update(oauthApplications)
.set({
...updateData,
updatedAt: new Date(),
})
.where(eq(oauthApplications.id, appId))
.returning();
if (!updated) {
return new Response(JSON.stringify({ error: "Application not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ ...updated, clientSecret: undefined }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO applications API");
}
}
// DELETE /api/sso/applications/:id - Delete an OAuth application
export async function DELETE(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const url = new URL(context.request.url);
const appId = url.searchParams.get("id");
if (!appId) {
return new Response(
JSON.stringify({ error: "Application ID is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const deleted = await db
.delete(oauthApplications)
.where(eq(oauthApplications.id, appId))
.returning();
if (deleted.length === 0) {
return new Response(JSON.stringify({ error: "Application not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO applications API");
}
}

View File

@@ -0,0 +1,69 @@
import type { APIContext } from "astro";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
// POST /api/sso/discover - Discover OIDC configuration from issuer URL
export async function POST(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const { issuer } = await context.request.json();
if (!issuer) {
return new Response(JSON.stringify({ error: "Issuer URL is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Ensure issuer URL ends without trailing slash for well-known discovery
const cleanIssuer = issuer.replace(/\/$/, "");
const discoveryUrl = `${cleanIssuer}/.well-known/openid-configuration`;
try {
// Fetch OIDC discovery document
const response = await fetch(discoveryUrl);
if (!response.ok) {
throw new Error(`Failed to fetch discovery document: ${response.status}`);
}
const config = await response.json();
// Extract the essential endpoints
const discoveredConfig = {
issuer: config.issuer || cleanIssuer,
authorizationEndpoint: config.authorization_endpoint,
tokenEndpoint: config.token_endpoint,
userInfoEndpoint: config.userinfo_endpoint,
jwksEndpoint: config.jwks_uri,
// Additional useful fields
scopes: config.scopes_supported || ["openid", "profile", "email"],
responseTypes: config.response_types_supported || ["code"],
grantTypes: config.grant_types_supported || ["authorization_code"],
// Suggested domain from issuer
suggestedDomain: new URL(cleanIssuer).hostname.replace("www.", ""),
};
return new Response(JSON.stringify(discoveredConfig), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("OIDC discovery error:", error);
return new Response(
JSON.stringify({
error: "Failed to discover OIDC configuration",
details: error instanceof Error ? error.message : "Unknown error"
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
} catch (error) {
return createSecureErrorResponse(error, "SSO discover API");
}
}

View File

@@ -0,0 +1,152 @@
import type { APIContext } from "astro";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
import { db, ssoProviders } from "@/lib/db";
import { nanoid } from "nanoid";
import { eq } from "drizzle-orm";
// GET /api/sso/providers - List all SSO providers
export async function GET(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const providers = await db.select().from(ssoProviders);
return new Response(JSON.stringify(providers), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO providers API");
}
}
// POST /api/sso/providers - Create a new SSO provider
export async function POST(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const body = await context.request.json();
const {
issuer,
domain,
clientId,
clientSecret,
authorizationEndpoint,
tokenEndpoint,
jwksEndpoint,
userInfoEndpoint,
mapping,
providerId,
organizationId,
} = body;
// Validate required fields
if (!issuer || !domain || !providerId) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
// Check if provider ID already exists
const existing = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.providerId, providerId))
.limit(1);
if (existing.length > 0) {
return new Response(
JSON.stringify({ error: "Provider ID already exists" }),
{
status: 409,
headers: { "Content-Type": "application/json" },
}
);
}
// Create OIDC config object
const oidcConfig = {
clientId,
clientSecret,
authorizationEndpoint,
tokenEndpoint,
jwksEndpoint,
userInfoEndpoint,
mapping: mapping || {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
},
};
// Insert new provider
const [newProvider] = await db
.insert(ssoProviders)
.values({
id: nanoid(),
issuer,
domain,
oidcConfig: JSON.stringify(oidcConfig),
userId: user.id,
providerId,
organizationId,
})
.returning();
return new Response(JSON.stringify(newProvider), {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO providers API");
}
}
// DELETE /api/sso/providers - Delete a provider by ID
export async function DELETE(context: APIContext) {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const url = new URL(context.request.url);
const providerId = url.searchParams.get("id");
if (!providerId) {
return new Response(
JSON.stringify({ error: "Provider ID is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const deleted = await db
.delete(ssoProviders)
.where(eq(ssoProviders.id, providerId))
.returning();
if (deleted.length === 0) {
return new Response(JSON.stringify({ error: "Provider not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return createSecureErrorResponse(error, "SSO providers API");
}
}

View File

@@ -0,0 +1,467 @@
---
import MainLayout from '../../layouts/main.astro';
---
<MainLayout title="Advanced Topics - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>Advanced</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">Advanced Topics</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
Advanced configuration options, deployment strategies, troubleshooting, and performance optimization for Gitea Mirror.
</p>
</div>
<!-- Environment Variables -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Environment Variables</h2>
<p class="text-muted-foreground mb-6">
Gitea Mirror can be configured using environment variables. These are particularly useful for containerized deployments.
</p>
<div class="bg-muted/30 rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border">
<th class="text-left p-3 font-semibold">Variable</th>
<th class="text-left p-3 font-semibold">Description</th>
<th class="text-left p-3 font-semibold">Default</th>
</tr>
</thead>
<tbody>
{[
{ var: 'NODE_ENV', desc: 'Application environment', default: 'production' },
{ var: 'PORT', desc: 'Server port', default: '4321' },
{ var: 'HOST', desc: 'Server host', default: '0.0.0.0' },
{ var: 'BETTER_AUTH_SECRET', desc: 'Authentication secret key', default: 'Auto-generated' },
{ var: 'BETTER_AUTH_URL', desc: 'Authentication base URL', default: 'http://localhost:4321' },
{ var: 'NODE_EXTRA_CA_CERTS', desc: 'Path to CA certificate file', default: 'None' },
{ var: 'DATABASE_URL', desc: 'SQLite database path', default: './data/gitea-mirror.db' },
].map((item, i) => (
<tr class={i % 2 === 0 ? 'bg-muted/20' : ''}>
<td class="p-3 font-mono text-xs">{item.var}</td>
<td class="p-3">{item.desc}</td>
<td class="p-3 text-muted-foreground">{item.default}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Database Management -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Database Management</h2>
<p class="text-muted-foreground mb-6">
Gitea Mirror uses SQLite for data storage. The database is automatically created on first run.
</p>
<h3 class="text-xl font-semibold mb-4">Database Commands</h3>
<div class="space-y-4">
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Initialize Database</h4>
<div class="bg-muted/30 rounded p-3 mb-2">
<code class="text-sm">bun run init-db</code>
</div>
<p class="text-sm text-muted-foreground">Creates or recreates the database schema</p>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Check Database</h4>
<div class="bg-muted/30 rounded p-3 mb-2">
<code class="text-sm">bun run check-db</code>
</div>
<p class="text-sm text-muted-foreground">Verifies database integrity and displays statistics</p>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Fix Database</h4>
<div class="bg-muted/30 rounded p-3 mb-2">
<code class="text-sm">bun run fix-db</code>
</div>
<p class="text-sm text-muted-foreground">Attempts to repair common database issues</p>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Backup Database</h4>
<div class="bg-muted/30 rounded p-3 mb-2">
<code class="text-sm">cp data/gitea-mirror.db data/gitea-mirror.db.backup</code>
</div>
<p class="text-sm text-muted-foreground">Always backup before major changes</p>
</div>
</div>
<h3 class="text-xl font-semibold mb-4 mt-8">Database Schema Management</h3>
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<div class="flex gap-3">
<div class="text-blue-600 dark:text-blue-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="font-semibold text-blue-600 dark:text-blue-500 mb-1">Drizzle Kit</p>
<p class="text-sm">Database schema is managed with Drizzle ORM. Use these commands for schema changes:</p>
<ul class="mt-2 space-y-1 text-sm">
<li><code class="bg-blue-500/10 px-1 rounded">bun run drizzle-kit generate</code> - Generate migration files</li>
<li><code class="bg-blue-500/10 px-1 rounded">bun run drizzle-kit push</code> - Apply schema changes directly</li>
<li><code class="bg-blue-500/10 px-1 rounded">bun run drizzle-kit studio</code> - Open database browser</li>
</ul>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Performance Optimization -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Performance Optimization</h2>
<h3 class="text-xl font-semibold mb-4">Mirroring Performance</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{[
{
title: 'Batch Operations',
tips: [
'Mirror multiple repositories at once',
'Use organization-level mirroring',
'Schedule mirroring during off-peak hours'
]
},
{
title: 'Network Optimization',
tips: [
'Use SSH URLs when possible',
'Enable Git LFS only when needed',
'Consider repository size limits'
]
}
].map(section => (
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-3">{section.title}</h4>
<ul class="space-y-1 text-sm text-muted-foreground">
{section.tips.map(tip => (
<li class="flex gap-2">
<span>•</span>
<span>{tip}</span>
</li>
))}
</ul>
</div>
))}
</div>
<h3 class="text-xl font-semibold mb-4">Database Performance</h3>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">Regular Maintenance</h4>
<ul class="space-y-1 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Enable automatic cleanup in Configuration → Automation</span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Periodically vacuum the SQLite database: <code class="bg-amber-500/10 px-1 rounded">sqlite3 data/gitea-mirror.db "VACUUM;"</code></span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Monitor database size and clean old events regularly</span>
</li>
</ul>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Reverse Proxy Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Reverse Proxy Configuration</h2>
<p class="text-muted-foreground mb-6">
For production deployments, it's recommended to use a reverse proxy like Nginx or Caddy.
</p>
<h3 class="text-xl font-semibold mb-4">Nginx Example</h3>
<div class="bg-muted/30 rounded-lg p-4 mb-6">
<pre class="text-sm overflow-x-auto"><code>{`server {
listen 80;
server_name gitea-mirror.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name gitea-mirror.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:4321;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SSE endpoint needs special handling
location /api/sse {
proxy_pass http://localhost:4321;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Cache-Control 'no-cache';
proxy_set_header X-Accel-Buffering 'no';
proxy_read_timeout 86400;
}
}`}</code></pre>
</div>
<h3 class="text-xl font-semibold mb-4">Caddy Example</h3>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{`gitea-mirror.example.com {
reverse_proxy localhost:4321
}`}</code></pre>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Monitoring and Health Checks -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Monitoring and Health Checks</h2>
<h3 class="text-xl font-semibold mb-4">Health Check Endpoint</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<p class="text-sm text-muted-foreground mb-4">Monitor application health using the built-in endpoint:</p>
<div class="bg-muted/30 rounded p-3 mb-4">
<code class="text-sm">GET /api/health</code>
</div>
<p class="text-sm font-semibold mb-2">Response:</p>
<div class="bg-muted/30 rounded p-3">
<pre class="text-sm"><code>{`{
"status": "ok",
"timestamp": "2024-01-15T10:30:00Z",
"database": "connected",
"version": "1.0.0"
}`}</code></pre>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Monitoring with Prometheus</h3>
<p class="text-sm text-muted-foreground mb-4">
While Gitea Mirror doesn't have built-in Prometheus metrics, you can monitor it using:
</p>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span>•</span>
<span>Blackbox exporter for endpoint monitoring</span>
</li>
<li class="flex gap-2">
<span>•</span>
<span>Node exporter for system metrics</span>
</li>
<li class="flex gap-2">
<span>•</span>
<span>Custom scripts to check database metrics</span>
</li>
</ul>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Backup and Recovery -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Backup and Recovery</h2>
<h3 class="text-xl font-semibold mb-4">What to Backup</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Essential Files</h4>
<ul class="space-y-1 text-sm text-muted-foreground">
<li class="font-mono">• data/gitea-mirror.db</li>
<li class="font-mono">• .env (if using)</li>
<li class="font-mono">• Custom CA certificates</li>
</ul>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Optional Files</h4>
<ul class="space-y-1 text-sm text-muted-foreground">
<li class="font-mono">• Docker volumes</li>
<li class="font-mono">• Custom configurations</li>
<li class="font-mono">• Logs for auditing</li>
</ul>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Backup Script Example</h3>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{`#!/bin/bash
BACKUP_DIR="/backups/gitea-mirror"
DATE=$(date +%Y%m%d_%H%M%S)
# Create backup directory
mkdir -p "$BACKUP_DIR/$DATE"
# Backup database
cp data/gitea-mirror.db "$BACKUP_DIR/$DATE/"
# Backup environment
cp .env "$BACKUP_DIR/$DATE/" 2>/dev/null || true
# Create tarball
tar -czf "$BACKUP_DIR/backup_$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE"
# Clean up
rm -rf "$BACKUP_DIR/$DATE"
# Keep only last 7 backups
ls -t "$BACKUP_DIR"/backup_*.tar.gz | tail -n +8 | xargs rm -f`}</code></pre>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Troubleshooting Guide -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Troubleshooting Guide</h2>
<div class="space-y-4">
{[
{
issue: 'Application won\'t start',
solutions: [
'Check port availability: `lsof -i :4321`',
'Verify environment variables are set correctly',
'Check database file permissions',
'Review logs for startup errors'
]
},
{
issue: 'Authentication failures',
solutions: [
'Ensure BETTER_AUTH_SECRET is set and consistent',
'Check BETTER_AUTH_URL matches your deployment',
'Clear browser cookies and try again',
'Verify database contains user records'
]
},
{
issue: 'Mirroring failures',
solutions: [
'Test GitHub/Gitea connections individually',
'Verify access tokens have correct permissions',
'Check network connectivity and firewall rules',
'Review Activity Log for detailed error messages'
]
},
{
issue: 'Performance issues',
solutions: [
'Check database size and run cleanup',
'Monitor system resources (CPU, memory, disk)',
'Reduce concurrent mirroring operations',
'Consider upgrading deployment resources'
]
}
].map(item => (
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">{item.issue}</h4>
<ul class="space-y-1 text-sm">
{item.solutions.map(solution => (
<li class="flex gap-2">
<span class="text-primary">→</span>
<span>{solution}</span>
</li>
))}
</ul>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Migration Guide -->
<section>
<h2 class="text-2xl font-bold mb-6">Migration Guide</h2>
<h3 class="text-xl font-semibold mb-4">Migrating from JWT to Better Auth</h3>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary">
<p class="mb-4">If you're upgrading from an older version using JWT authentication:</p>
<ol class="space-y-3 text-sm">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs font-semibold">1</span>
<div>
<strong>Backup your database</strong>
<p class="text-muted-foreground">Always create a backup before migration</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs font-semibold">2</span>
<div>
<strong>Update environment variables</strong>
<p class="text-muted-foreground">Replace JWT_SECRET with BETTER_AUTH_SECRET</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs font-semibold">3</span>
<div>
<strong>Run database migrations</strong>
<p class="text-muted-foreground">New auth tables will be created automatically</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs font-semibold">4</span>
<div>
<strong>Users will need to log in again</strong>
<p class="text-muted-foreground">Previous sessions will be invalidated</p>
</div>
</li>
</ol>
</div>
</section>
</article>
</main>
</MainLayout>

View File

@@ -47,7 +47,8 @@ import MainLayout from '../../layouts/main.astro';
{ name: 'Shadcn UI', desc: 'UI component library built on Tailwind CSS' },
{ name: 'SQLite', desc: 'Database for storing configuration, state, and events' },
{ name: 'Bun', desc: 'JavaScript runtime and package manager' },
{ name: 'Drizzle ORM', desc: 'Type-safe ORM for database interactions' }
{ name: 'Drizzle ORM', desc: 'Type-safe ORM for database interactions' },
{ name: 'Better Auth', desc: 'Modern authentication library with SSO/OIDC support' }
].map(tech => (
<div class="flex items-start gap-3">
<div class="w-2 h-2 rounded-full bg-primary mt-2"></div>
@@ -184,7 +185,8 @@ import MainLayout from '../../layouts/main.astro';
<div class="space-y-3">
{[
'Authentication and user management',
'Authentication with Better Auth (email/password, SSO, OIDC)',
'OAuth2/OIDC provider functionality',
'GitHub API integration',
'Gitea API integration',
'Mirroring operations and job queue',
@@ -213,11 +215,13 @@ import MainLayout from '../../layouts/main.astro';
<div class="space-y-3">
{[
'User accounts and authentication data',
'User accounts and authentication data (Better Auth)',
'OAuth applications and SSO provider configurations',
'GitHub and Gitea configuration',
'Repository and organization information',
'Mirroring job history and status',
'Event notifications and their read status'
'Event notifications and their read status',
'OAuth tokens and consent records'
].map(item => (
<div class="flex gap-3">
<span class="text-primary font-mono text-sm">▸</span>
@@ -238,7 +242,7 @@ import MainLayout from '../../layouts/main.astro';
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary">
<ol class="space-y-4">
{[
{ title: 'User Authentication', desc: 'Users authenticate through the frontend, which communicates with the backend to validate credentials.' },
{ title: 'User Authentication', desc: 'Users authenticate via Better Auth using email/password, SSO providers, or as OIDC clients.' },
{ title: 'Configuration', desc: 'Users configure GitHub and Gitea settings through the UI, which are stored in the SQLite database.' },
{ title: 'Repository Discovery', desc: 'The backend queries the GitHub API to discover repositories based on user configuration.' },
{ title: 'Mirroring Process', desc: 'When triggered, the backend fetches repository data from GitHub and pushes it to Gitea.' },

View File

@@ -0,0 +1,535 @@
---
import MainLayout from '../../layouts/main.astro';
---
<MainLayout title="Authentication & SSO - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
<span>Authentication</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">Authentication & SSO Configuration</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
Configure authentication methods including email/password, Single Sign-On (SSO), and OIDC provider functionality for Gitea Mirror.
</p>
</div>
<!-- Overview -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Authentication Overview</h2>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-6">
<p class="text-base leading-relaxed">
Gitea Mirror uses <strong>Better Auth</strong>, a modern authentication library that supports multiple authentication methods.
All authentication settings can be configured through the web UI without editing configuration files.
</p>
</div>
<h3 class="text-lg font-semibold mb-4">Supported Authentication Methods</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{
icon: '✉️',
title: 'Email & Password',
desc: 'Traditional authentication with email and password. Always enabled by default.',
status: 'Always Enabled'
},
{
icon: '🌐',
title: 'Single Sign-On (SSO)',
desc: 'Allow users to sign in using external OIDC providers like Google, Okta, or Azure AD.',
status: 'Optional'
},
{
icon: '🔑',
title: 'OIDC Provider',
desc: 'Act as an OIDC provider, allowing other applications to authenticate through Gitea Mirror.',
status: 'Optional'
}
].map(method => (
<div class="bg-card rounded-lg border border-border p-4 hover:border-primary/50 transition-colors">
<div class="text-2xl mb-3">{method.icon}</div>
<h4 class="font-semibold mb-2">{method.title}</h4>
<p class="text-sm text-muted-foreground mb-3">{method.desc}</p>
<span class={`text-xs px-2 py-1 rounded-full ${method.status === 'Always Enabled' ? 'bg-green-500/10 text-green-600 dark:text-green-500' : 'bg-blue-500/10 text-blue-600 dark:text-blue-500'}`}>
{method.status}
</span>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Accessing Authentication Settings -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Accessing Authentication Settings</h2>
<ol class="space-y-3">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">1</span>
<span>Navigate to the <strong>Configuration</strong> page</span>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">2</span>
<span>Click on the <strong>Authentication</strong> tab</span>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">3</span>
<span>Configure SSO providers or OAuth applications as needed</span>
</li>
</ol>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- SSO Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Single Sign-On (SSO) Configuration</h2>
<p class="text-muted-foreground mb-6">
SSO allows your users to authenticate using external identity providers. This is useful for organizations that already have centralized authentication systems.
</p>
<h3 class="text-xl font-semibold mb-4">Adding an SSO Provider</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<h4 class="font-semibold mb-4">Required Information</h4>
<div class="space-y-4">
{[
{ name: 'Issuer URL', desc: 'The OIDC issuer URL of your provider', example: 'https://accounts.google.com' },
{ name: 'Domain', desc: 'The email domain for this provider', example: 'example.com' },
{ name: 'Provider ID', desc: 'A unique identifier for this provider', example: 'google-sso' },
{ name: 'Client ID', desc: 'OAuth client ID from your provider', example: '123456789.apps.googleusercontent.com' },
{ name: 'Client Secret', desc: 'OAuth client secret from your provider', example: 'GOCSPX-...' }
].map(field => (
<div class="border-l-2 border-muted pl-4">
<div class="flex items-baseline gap-2 mb-1">
<strong class="text-sm">{field.name}</strong>
<span class="text-xs text-muted-foreground">Required</span>
</div>
<p class="text-sm text-muted-foreground">{field.desc}</p>
<code class="text-xs bg-muted px-2 py-0.5 rounded mt-1 inline-block">{field.example}</code>
</div>
))}
</div>
</div>
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4 mb-6">
<div class="flex gap-3">
<div class="text-blue-600 dark:text-blue-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="font-semibold text-blue-600 dark:text-blue-500 mb-1">Auto-Discovery</p>
<p class="text-sm">Most OIDC providers support auto-discovery. Simply enter the Issuer URL and click "Discover" to automatically populate the endpoint URLs.</p>
</div>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Redirect URL Configuration</h3>
<div class="bg-muted/30 rounded-lg p-4">
<p class="text-sm mb-2">When configuring your SSO provider, use this redirect URL:</p>
<code class="bg-muted rounded px-3 py-2 block">https://your-domain.com/api/auth/sso/callback/{`{provider-id}`}</code>
<p class="text-xs text-muted-foreground mt-2">Replace <code>{`{provider-id}`}</code> with your chosen Provider ID (e.g., google-sso)</p>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Example SSO Configurations -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Example SSO Configurations</h2>
<!-- Google Example -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
<img src="https://www.google.com/favicon.ico" alt="Google" class="w-5 h-5" />
Google SSO
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<ol class="space-y-4">
<li>
<strong>1. Create OAuth Client in Google Cloud Console</strong>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li>• Go to <a href="https://console.cloud.google.com/" class="text-primary hover:underline">Google Cloud Console</a></li>
<li>• Create a new OAuth 2.0 Client ID</li>
<li>• Add authorized redirect URI: <code class="bg-muted px-1 rounded">https://your-domain.com/api/auth/sso/callback/google-sso</code></li>
</ul>
</li>
<li>
<strong>2. Configure in Gitea Mirror</strong>
<div class="mt-2 bg-muted/30 rounded-lg p-3 text-sm">
<div class="grid grid-cols-1 gap-2">
<div><strong>Issuer URL:</strong> <code>https://accounts.google.com</code></div>
<div><strong>Domain:</strong> <code>your-company.com</code></div>
<div><strong>Provider ID:</strong> <code>google-sso</code></div>
<div><strong>Client ID:</strong> <code>[Your Google Client ID]</code></div>
<div><strong>Client Secret:</strong> <code>[Your Google Client Secret]</code></div>
</div>
</div>
</li>
<li>
<strong>3. Use Auto-Discovery</strong>
<p class="text-sm text-muted-foreground mt-1">Click "Discover" to automatically populate the endpoint URLs</p>
</li>
</ol>
</div>
</div>
<!-- Okta Example -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
<span class="w-5 h-5 bg-blue-600 rounded flex items-center justify-center text-white text-xs font-bold">O</span>
Okta SSO
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<ol class="space-y-4">
<li>
<strong>1. Create OIDC Application in Okta</strong>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li>• In Okta Admin Console, create a new OIDC Web Application</li>
<li>• Set Sign-in redirect URI: <code class="bg-muted px-1 rounded">https://your-domain.com/api/auth/sso/callback/okta-sso</code></li>
<li>• Note the Client ID and Client Secret</li>
</ul>
</li>
<li>
<strong>2. Configure in Gitea Mirror</strong>
<div class="mt-2 bg-muted/30 rounded-lg p-3 text-sm">
<div class="grid grid-cols-1 gap-2">
<div><strong>Issuer URL:</strong> <code>https://your-okta-domain.okta.com</code></div>
<div><strong>Domain:</strong> <code>your-company.com</code></div>
<div><strong>Provider ID:</strong> <code>okta-sso</code></div>
<div><strong>Client ID:</strong> <code>[Your Okta Client ID]</code></div>
<div><strong>Client Secret:</strong> <code>[Your Okta Client Secret]</code></div>
</div>
</div>
</li>
</ol>
</div>
</div>
<!-- Azure AD Example -->
<div>
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
<span class="w-5 h-5 bg-blue-500 rounded flex items-center justify-center text-white text-xs">M</span>
Azure AD / Microsoft Entra ID
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<ol class="space-y-4">
<li>
<strong>1. Register Application in Azure Portal</strong>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li>• Go to Azure Portal → Azure Active Directory → App registrations</li>
<li>• Create a new registration</li>
<li>• Add redirect URI: <code class="bg-muted px-1 rounded">https://your-domain.com/api/auth/sso/callback/azure-sso</code></li>
</ul>
</li>
<li>
<strong>2. Configure in Gitea Mirror</strong>
<div class="mt-2 bg-muted/30 rounded-lg p-3 text-sm">
<div class="grid grid-cols-1 gap-2">
<div><strong>Issuer URL:</strong> <code>https://login.microsoftonline.com/{`{tenant-id}`}/v2.0</code></div>
<div><strong>Domain:</strong> <code>your-company.com</code></div>
<div><strong>Provider ID:</strong> <code>azure-sso</code></div>
<div><strong>Client ID:</strong> <code>[Your Application ID]</code></div>
<div><strong>Client Secret:</strong> <code>[Your Client Secret]</code></div>
</div>
</div>
</li>
</ol>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- OIDC Provider Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">OIDC Provider Configuration</h2>
<p class="text-muted-foreground mb-6">
The OIDC Provider feature allows Gitea Mirror to act as an authentication provider for other applications.
This is useful when you want to centralize authentication through Gitea Mirror.
</p>
<h3 class="text-xl font-semibold mb-4">Creating OAuth Applications</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<ol class="space-y-4">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">1</span>
<div>
<strong>Navigate to OAuth Applications</strong>
<p class="text-sm text-muted-foreground mt-1">Go to Configuration → Authentication → OAuth Applications</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">2</span>
<div>
<strong>Create New Application</strong>
<p class="text-sm text-muted-foreground mt-1">Click "Create Application" and provide:</p>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li>• Application Name</li>
<li>• Application Type (Web, Mobile, or Desktop)</li>
<li>• Redirect URLs (one per line)</li>
</ul>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">3</span>
<div>
<strong>Save Credentials</strong>
<p class="text-sm text-muted-foreground mt-1">You'll receive a Client ID and Client Secret. Store these securely!</p>
</div>
</li>
</ol>
</div>
<h3 class="text-xl font-semibold mb-4">OIDC Endpoints</h3>
<div class="bg-muted/30 rounded-lg p-4 mb-6">
<p class="text-sm mb-3">Applications can use these standard OIDC endpoints:</p>
<div class="space-y-2 text-sm">
<div class="flex gap-2">
<strong class="w-32">Discovery:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/.well-known/openid-configuration</code>
</div>
<div class="flex gap-2">
<strong class="w-32">Authorization:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/api/auth/oauth2/authorize</code>
</div>
<div class="flex gap-2">
<strong class="w-32">Token:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/api/auth/oauth2/token</code>
</div>
<div class="flex gap-2">
<strong class="w-32">UserInfo:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/api/auth/oauth2/userinfo</code>
</div>
<div class="flex gap-2">
<strong class="w-32">JWKS:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/api/auth/jwks</code>
</div>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Supported Scopes</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{ scope: 'openid', desc: 'Required - provides user ID', claims: 'sub' },
{ scope: 'profile', desc: 'User profile information', claims: 'name, username, picture' },
{ scope: 'email', desc: 'Email address', claims: 'email, email_verified' }
].map(item => (
<div class="bg-card rounded-lg border border-border p-4">
<code class="text-sm font-semibold text-primary">{item.scope}</code>
<p class="text-sm text-muted-foreground mt-2">{item.desc}</p>
<p class="text-xs text-muted-foreground mt-2">Claims: {item.claims}</p>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- User Experience -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">User Experience</h2>
<h3 class="text-xl font-semibold mb-4">Login Flow with SSO</h3>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-6">
<p class="mb-4">When SSO is configured, users will see authentication options on the login page:</p>
<ol class="space-y-2 text-sm">
<li class="flex gap-2"><span class="font-semibold">1.</span> Email & Password tab for traditional login</li>
<li class="flex gap-2"><span class="font-semibold">2.</span> SSO tab with provider buttons or email input</li>
<li class="flex gap-2"><span class="font-semibold">3.</span> Automatic redirect to the appropriate provider</li>
<li class="flex gap-2"><span class="font-semibold">4.</span> Return to Gitea Mirror after successful authentication</li>
</ol>
</div>
<h3 class="text-xl font-semibold mb-4">OAuth Consent Flow</h3>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary">
<p class="mb-4">When an application requests authentication through Gitea Mirror:</p>
<ol class="space-y-2 text-sm">
<li class="flex gap-2"><span class="font-semibold">1.</span> User is redirected to Gitea Mirror</li>
<li class="flex gap-2"><span class="font-semibold">2.</span> Login prompt if not already authenticated</li>
<li class="flex gap-2"><span class="font-semibold">3.</span> Consent screen showing requested permissions</li>
<li class="flex gap-2"><span class="font-semibold">4.</span> User approves or denies the request</li>
<li class="flex gap-2"><span class="font-semibold">5.</span> Redirect back to the application with auth code</li>
</ol>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Security Considerations -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Security Considerations</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
icon: '🔒',
title: 'Client Secrets',
items: [
'Store OAuth client secrets securely',
'Never commit secrets to version control',
'Rotate secrets regularly'
]
},
{
icon: '🔗',
title: 'Redirect URLs',
items: [
'Only add trusted redirect URLs',
'Use HTTPS in production',
'Validate exact URL matches'
]
},
{
icon: '🛡️',
title: 'Scopes & Permissions',
items: [
'Grant minimum required scopes',
'Review requested permissions',
'Users can revoke access anytime'
]
},
{
icon: '⏱️',
title: 'Token Security',
items: [
'Access tokens have expiration',
'Refresh tokens for long-lived access',
'Tokens can be revoked'
]
}
].map(section => (
<div class="bg-card rounded-lg border border-border p-4">
<div class="flex items-center gap-3 mb-3">
<span class="text-2xl">{section.icon}</span>
<h4 class="font-semibold">{section.title}</h4>
</div>
<ul class="space-y-1 text-sm text-muted-foreground">
{section.items.map(item => (
<li class="flex gap-2">
<span>•</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Troubleshooting -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Troubleshooting</h2>
<div class="space-y-4">
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">SSO Login Issues</h4>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>"Invalid origin" error:</strong> Check that your Gitea Mirror URL matches the configured redirect URI
</div>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>"Provider not found" error:</strong> Ensure the provider is properly configured and saved
</div>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>Redirect loop:</strong> Verify the redirect URI in both Gitea Mirror and the SSO provider match exactly
</div>
</li>
</ul>
</div>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">OIDC Provider Issues</h4>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>Application not found:</strong> Ensure the client ID is correct and the app is not disabled
</div>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>Invalid redirect URI:</strong> The redirect URI must match exactly what's configured
</div>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>Consent not working:</strong> Check browser cookies are enabled and not blocked
</div>
</li>
</ul>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Migration from JWT -->
<section>
<h2 class="text-2xl font-bold mb-6">Migration from JWT Authentication</h2>
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<div class="flex gap-3">
<div class="text-blue-600 dark:text-blue-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="font-semibold text-blue-600 dark:text-blue-500 mb-2">For Existing Users</p>
<ul class="space-y-1 text-sm">
<li>• Email/password authentication continues to work</li>
<li>• No action required from existing users</li>
<li>• SSO can be added as an additional option</li>
<li>• JWT_SECRET is no longer required in environment variables</li>
</ul>
</div>
</div>
</div>
</section>
</article>
</main>
</MainLayout>

View File

@@ -0,0 +1,475 @@
---
import MainLayout from '../../layouts/main.astro';
---
<MainLayout title="CA Certificates - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<span>Security</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">CA Certificates Configuration</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
Configure custom Certificate Authority (CA) certificates for connecting to self-signed or privately signed Gitea instances.
</p>
</div>
<!-- Overview -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Overview</h2>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-6">
<p class="text-base leading-relaxed">
When your Gitea instance uses a self-signed certificate or a certificate signed by a private Certificate Authority (CA),
you need to configure Gitea Mirror to trust these certificates. This guide explains how to add custom CA certificates
for different deployment methods.
</p>
</div>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<div class="flex gap-3">
<div class="text-amber-600 dark:text-amber-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div>
<p class="font-semibold text-amber-600 dark:text-amber-500 mb-1">Important</p>
<p class="text-sm">Without proper CA certificate configuration, you'll encounter SSL/TLS errors when connecting to Gitea instances with custom certificates.</p>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Common SSL/TLS Errors -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Common SSL/TLS Errors</h2>
<p class="text-muted-foreground mb-4">If you see any of these errors, you likely need to configure CA certificates:</p>
<div class="space-y-3">
{[
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'SELF_SIGNED_CERT_IN_CHAIN',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'CERT_UNTRUSTED',
'unable to verify the first certificate'
].map(error => (
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-3">
<code class="text-sm text-red-600 dark:text-red-500">{error}</code>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Docker Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Docker Configuration</h2>
<p class="text-muted-foreground mb-6">For Docker deployments, you have several options to add custom CA certificates:</p>
<h3 class="text-xl font-semibold mb-4">Method 1: Volume Mount (Recommended)</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<ol class="space-y-4">
<li>
<strong>1. Create a certificates directory</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>mkdir -p ./certs</code></pre>
</div>
</li>
<li>
<strong>2. Copy your CA certificate(s)</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>cp /path/to/your-ca-cert.crt ./certs/</code></pre>
</div>
</li>
<li>
<strong>3. Update docker-compose.yml</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>{`version: '3.8'
services:
gitea-mirror:
image: raylabs/gitea-mirror:latest
volumes:
- ./data:/app/data
- ./certs:/usr/local/share/ca-certificates:ro
environment:
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt`}</code></pre>
</div>
</li>
<li>
<strong>4. Restart the container</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>docker-compose down && docker-compose up -d</code></pre>
</div>
</li>
</ol>
</div>
<h3 class="text-xl font-semibold mb-4">Method 2: Custom Docker Image</h3>
<div class="bg-card rounded-lg border border-border p-6">
<p class="text-sm text-muted-foreground mb-4">For permanent certificate inclusion, create a custom Docker image:</p>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{`FROM raylabs/gitea-mirror:latest
# Copy CA certificates
COPY ./certs/*.crt /usr/local/share/ca-certificates/
# Update CA certificates
RUN update-ca-certificates
# Set environment variable
ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt`}</code></pre>
</div>
<p class="text-sm text-muted-foreground mt-4">Build and use your custom image:</p>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>docker build -t my-gitea-mirror .</code></pre>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Native/Bun Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Native/Bun Configuration</h2>
<p class="text-muted-foreground mb-6">For native Bun deployments, configure CA certificates using environment variables:</p>
<h3 class="text-xl font-semibold mb-4">Method 1: Environment Variable</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<ol class="space-y-4">
<li>
<strong>1. Export the certificate path</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>export NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt</code></pre>
</div>
</li>
<li>
<strong>2. Run Gitea Mirror</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>bun run start</code></pre>
</div>
</li>
</ol>
</div>
<h3 class="text-xl font-semibold mb-4">Method 2: .env File</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<p class="text-sm text-muted-foreground mb-4">Add to your .env file:</p>
<div class="bg-muted/30 rounded-lg p-3">
<pre class="text-sm"><code>NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt</code></pre>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Method 3: System-wide CA Store</h3>
<div class="bg-card rounded-lg border border-border p-6">
<p class="text-sm text-muted-foreground mb-4">Add certificates to your system's CA store:</p>
<div class="space-y-4">
<div>
<strong>Ubuntu/Debian:</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>{`sudo cp your-ca-cert.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates`}</code></pre>
</div>
</div>
<div>
<strong>RHEL/CentOS/Fedora:</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>{`sudo cp your-ca-cert.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust`}</code></pre>
</div>
</div>
<div>
<strong>macOS:</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>{`sudo security add-trusted-cert -d -r trustRoot \\
-k /Library/Keychains/System.keychain your-ca-cert.crt`}</code></pre>
</div>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- LXC Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">LXC Container Configuration</h2>
<p class="text-muted-foreground mb-6">For LXC deployments on Proxmox VE:</p>
<div class="bg-card rounded-lg border border-border p-6">
<ol class="space-y-4">
<li>
<strong>1. Enter the container</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>pct enter &lt;container-id&gt;</code></pre>
</div>
</li>
<li>
<strong>2. Create certificates directory</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>mkdir -p /usr/local/share/ca-certificates</code></pre>
</div>
</li>
<li>
<strong>3. Copy your CA certificate</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>cat > /usr/local/share/ca-certificates/your-ca.crt</code></pre>
</div>
<p class="text-xs text-muted-foreground mt-1">Paste your certificate content and press Ctrl+D</p>
</li>
<li>
<strong>4. Update the systemd service</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>{`cat >> /etc/systemd/system/gitea-mirror.service << EOF
Environment="NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca.crt"
EOF`}</code></pre>
</div>
</li>
<li>
<strong>5. Reload and restart</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>{`systemctl daemon-reload
systemctl restart gitea-mirror`}</code></pre>
</div>
</li>
</ol>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Multiple CA Certificates -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Multiple CA Certificates</h2>
<p class="text-muted-foreground mb-6">If you need to trust multiple CA certificates:</p>
<h3 class="text-xl font-semibold mb-4">Option 1: Bundle Certificates</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<p class="text-sm text-muted-foreground mb-4">Combine multiple certificates into one file:</p>
<div class="bg-muted/30 rounded-lg p-3">
<pre class="text-sm"><code>{`cat ca-cert1.crt ca-cert2.crt ca-cert3.crt > ca-bundle.crt
export NODE_EXTRA_CA_CERTS=/path/to/ca-bundle.crt`}</code></pre>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Option 2: System CA Store</h3>
<div class="bg-card rounded-lg border border-border p-6">
<p class="text-sm text-muted-foreground mb-4">Add all certificates to the system CA store (recommended for production):</p>
<div class="bg-muted/30 rounded-lg p-3">
<pre class="text-sm"><code>{`# Copy all certificates
cp *.crt /usr/local/share/ca-certificates/
update-ca-certificates`}</code></pre>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Verification -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Verifying Certificate Configuration</h2>
<p class="text-muted-foreground mb-6">Test your certificate configuration:</p>
<div class="bg-card rounded-lg border border-border p-6">
<h4 class="font-semibold mb-4">1. Test Gitea Connection</h4>
<p class="text-sm text-muted-foreground mb-3">Use the "Test Connection" button in the Gitea configuration section</p>
<h4 class="font-semibold mb-4 mt-6">2. Check Logs</h4>
<p class="text-sm text-muted-foreground mb-3">Look for SSL/TLS errors in the application logs:</p>
<div class="space-y-3">
<div>
<strong class="text-sm">Docker:</strong>
<div class="bg-muted/30 rounded-lg p-2 mt-1">
<code class="text-sm">docker logs gitea-mirror</code>
</div>
</div>
<div>
<strong class="text-sm">Native:</strong>
<div class="bg-muted/30 rounded-lg p-2 mt-1">
<code class="text-sm">Check terminal output</code>
</div>
</div>
<div>
<strong class="text-sm">LXC:</strong>
<div class="bg-muted/30 rounded-lg p-2 mt-1">
<code class="text-sm">journalctl -u gitea-mirror -f</code>
</div>
</div>
</div>
<h4 class="font-semibold mb-4 mt-6">3. Manual Certificate Test</h4>
<p class="text-sm text-muted-foreground mb-3">Test SSL connection directly:</p>
<div class="bg-muted/30 rounded-lg p-3">
<pre class="text-sm"><code>openssl s_client -connect your-gitea-domain.com:443 -CAfile /path/to/ca-cert.crt</code></pre>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Best Practices -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Best Practices</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
icon: '🔒',
title: 'Certificate Security',
items: [
'Keep CA certificates secure',
'Use read-only mounts in Docker',
'Limit certificate file permissions',
'Regularly update certificates'
]
},
{
icon: '📁',
title: 'Certificate Management',
items: [
'Use descriptive certificate filenames',
'Document certificate purposes',
'Track certificate expiration dates',
'Maintain certificate backups'
]
},
{
icon: '🏢',
title: 'Production Deployment',
items: [
'Use proper SSL certificates when possible',
'Consider Let\'s Encrypt for public instances',
'Implement certificate rotation procedures',
'Monitor certificate expiration'
]
},
{
icon: '🔍',
title: 'Troubleshooting',
items: [
'Verify certificate format (PEM)',
'Check certificate chain completeness',
'Ensure proper file permissions',
'Test with openssl commands'
]
}
].map(section => (
<div class="bg-card rounded-lg border border-border p-4">
<div class="flex items-center gap-3 mb-3">
<span class="text-2xl">{section.icon}</span>
<h4 class="font-semibold">{section.title}</h4>
</div>
<ul class="space-y-1 text-sm text-muted-foreground">
{section.items.map(item => (
<li class="flex gap-2">
<span>•</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Common Issues -->
<section>
<h2 class="text-2xl font-bold mb-6">Common Issues and Solutions</h2>
<div class="space-y-4">
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">Certificate not being recognized</h4>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Ensure the certificate is in PEM format</span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Check that NODE_EXTRA_CA_CERTS points to the correct file</span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Restart the application after adding certificates</span>
</li>
</ul>
</div>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">Still getting SSL errors</h4>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Verify the complete certificate chain is included</span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Check if intermediate certificates are needed</span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Ensure the certificate matches the server hostname</span>
</li>
</ul>
</div>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">Certificate expired</h4>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Check certificate validity: <code class="bg-amber-500/10 px-1 rounded">openssl x509 -in cert.crt -noout -dates</code></span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Update with new certificate from your CA</span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Restart Gitea Mirror after updating</span>
</li>
</ul>
</div>
</div>
</section>
</article>
</main>
</MainLayout>

View File

@@ -1,16 +1,16 @@
---
import MainLayout from '../../layouts/main.astro';
import { LuSettings, LuRocket, LuBookOpen } from 'react-icons/lu';
import { LuSettings, LuRocket, LuBookOpen, LuShield, LuKey, LuNetwork } from 'react-icons/lu';
// Define our documentation pages directly
const docs = [
{
slug: 'architecture',
title: 'Architecture',
description: 'Comprehensive overview of the Gitea Mirror application architecture.',
slug: 'quickstart',
title: 'Quick Start Guide',
description: 'Get started with Gitea Mirror quickly.',
order: 1,
icon: LuBookOpen,
href: '/docs/architecture'
icon: LuRocket,
href: '/docs/quickstart'
},
{
slug: 'configuration',
@@ -21,12 +21,36 @@ const docs = [
href: '/docs/configuration'
},
{
slug: 'quickstart',
title: 'Quick Start Guide',
description: 'Get started with Gitea Mirror quickly.',
slug: 'authentication',
title: 'Authentication & SSO',
description: 'Configure authentication methods, SSO providers, and OIDC.',
order: 3,
icon: LuRocket,
href: '/docs/quickstart'
icon: LuKey,
href: '/docs/authentication'
},
{
slug: 'architecture',
title: 'Architecture',
description: 'Comprehensive overview of the Gitea Mirror application architecture.',
order: 4,
icon: LuBookOpen,
href: '/docs/architecture'
},
{
slug: 'ca-certificates',
title: 'CA Certificates',
description: 'Configure custom CA certificates for self-signed Gitea instances.',
order: 5,
icon: LuShield,
href: '/docs/ca-certificates'
},
{
slug: 'advanced',
title: 'Advanced Topics',
description: 'Advanced configuration, troubleshooting, and deployment options.',
order: 6,
icon: LuNetwork,
href: '/docs/advanced'
}
];

View File

@@ -244,7 +244,7 @@ bun run start</code></pre>
title: 'Create Admin Account',
items: [
"You'll be prompted on first access",
'Choose a secure username and password',
'Enter your email address and password',
'This will be your administrator account'
]
},

View File

@@ -0,0 +1,28 @@
---
import '@/styles/global.css';
import ConsentPage from '@/components/oauth/ConsentPage';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import Providers from '@/components/layout/Providers';
// Check if user is authenticated
const sessionCookie = Astro.cookies.get('better-auth-session');
if (!sessionCookie) {
return Astro.redirect('/login');
}
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>Authorize Application - Gitea Mirror</title>
<ThemeScript />
</head>
<body>
<Providers>
<ConsentPage client:load />
</Providers>
</body>
</html>