mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 11:36:44 +03:00
Added SSO and OIDC
This commit is contained in:
337
certs/README.md
337
certs/README.md
@@ -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 document explains how to configure custom Certificate Authority (CA) certificates for Gitea Mirror when connecting to self-signed or privately signed Gitea instances.
|
||||||
|
|
||||||
> **📁 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.
|
|
||||||
|
|
||||||
## Overview
|
## 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
|
## Configuration by Deployment Method
|
||||||
# From the project root:
|
|
||||||
cp /path/to/your/ca-certificate.crt ./certs/
|
|
||||||
|
|
||||||
# Or if you're already in the certs directory:
|
### Docker
|
||||||
cp /path/to/your/ca-certificate.crt .
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
1. Create a certificates directory:
|
||||||
|
|
||||||
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:
|
|
||||||
```bash
|
```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
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
gitea-mirror:
|
gitea-mirror:
|
||||||
image: ghcr.io/raylabshq/gitea-mirror:latest
|
image: raylabs/gitea-mirror:latest
|
||||||
volumes:
|
volumes:
|
||||||
- gitea-mirror-data:/app/data
|
- ./data:/app/data
|
||||||
- ./certs:/app/certs:ro
|
- ./certs:/usr/local/share/ca-certificates:ro
|
||||||
environment:
|
environment:
|
||||||
- GITEA_URL=https://gitea.mycompany.local
|
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt
|
||||||
- GITEA_TOKEN=your-token
|
|
||||||
# ... other configuration ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start the service:
|
4. Restart the container:
|
||||||
```bash
|
```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
205
docs/SSO-OIDC-SETUP.md
Normal 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
|
||||||
64
drizzle/0001_polite_exodus.sql
Normal file
64
drizzle/0001_polite_exodus.sql
Normal 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`);
|
||||||
1722
drizzle/meta/0001_snapshot.json
Normal file
1722
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1752171873627,
|
"when": 1752171873627,
|
||||||
"tag": "0000_init",
|
"tag": "0000_init",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1752173351102,
|
||||||
|
"tag": "0001_polite_exodus",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
31
scripts/run-migration.ts
Normal file
31
scripts/run-migration.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -5,14 +5,27 @@ import { useState } from 'react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
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 { toast, Toaster } from 'sonner';
|
||||||
import { showErrorToast } from '@/lib/utils';
|
import { showErrorToast } from '@/lib/utils';
|
||||||
|
import { Loader2, Mail, Globe } from 'lucide-react';
|
||||||
|
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [ssoEmail, setSsoEmail] = useState('');
|
||||||
const { login } = useAuth();
|
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>) {
|
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
@@ -63,45 +96,182 @@ export function LoginForm() {
|
|||||||
Log in to manage your GitHub to Gitea mirroring
|
Log in to manage your GitHub to Gitea mirroring
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
|
||||||
<form id="login-form" onSubmit={handleLogin}>
|
{isLoadingMethods ? (
|
||||||
<div className="space-y-4">
|
<CardContent>
|
||||||
<div>
|
<div className="flex justify-center py-8">
|
||||||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
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>
|
</div>
|
||||||
</form>
|
</CardContent>
|
||||||
</CardContent>
|
) : (
|
||||||
<CardFooter>
|
<>
|
||||||
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
|
{/* Show tabs only if multiple auth methods are available */}
|
||||||
{isLoading ? 'Logging in...' : 'Log In'}
|
{authMethods.sso.enabled && authMethods.emailPassword ? (
|
||||||
</Button>
|
<Tabs defaultValue={getDefaultTab()} className="w-full">
|
||||||
</CardFooter>
|
<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">
|
<div className="px-6 pb-6 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Don't have an account? Contact your administrator.
|
Don't have an account? Contact your administrator.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
|||||||
import { GitHubConfigForm } from './GitHubConfigForm';
|
import { GitHubConfigForm } from './GitHubConfigForm';
|
||||||
import { GiteaConfigForm } from './GiteaConfigForm';
|
import { GiteaConfigForm } from './GiteaConfigForm';
|
||||||
import { AutomationSettings } from './AutomationSettings';
|
import { AutomationSettings } from './AutomationSettings';
|
||||||
|
import { SSOSettings } from './SSOSettings';
|
||||||
import type {
|
import type {
|
||||||
ConfigApiResponse,
|
ConfigApiResponse,
|
||||||
GiteaConfig,
|
GiteaConfig,
|
||||||
@@ -20,6 +21,7 @@ import { RefreshCw } from 'lucide-react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { invalidateConfigCache } from '@/hooks/useConfigStatus';
|
import { invalidateConfigCache } from '@/hooks/useConfigStatus';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
type ConfigState = {
|
type ConfigState = {
|
||||||
githubConfig: GitHubConfig;
|
githubConfig: GitHubConfig;
|
||||||
@@ -601,65 +603,71 @@ export function ConfigTabs() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content section - Grid layout */}
|
{/* Content section - Tabs layout */}
|
||||||
<div className="space-y-6">
|
<Tabs defaultValue="connections" className="space-y-4">
|
||||||
{/* GitHub & Gitea connections - Side by side */}
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-stretch">
|
<TabsTrigger value="connections">Connections</TabsTrigger>
|
||||||
<GitHubConfigForm
|
<TabsTrigger value="automation">Automation</TabsTrigger>
|
||||||
config={config.githubConfig}
|
<TabsTrigger value="sso">Authentication</TabsTrigger>
|
||||||
setConfig={update =>
|
</TabsList>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Automation & Maintenance - Full width */}
|
<TabsContent value="connections" className="space-y-4">
|
||||||
<div>
|
<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
|
<AutomationSettings
|
||||||
scheduleConfig={config.scheduleConfig}
|
scheduleConfig={config.scheduleConfig}
|
||||||
cleanupConfig={config.cleanupConfig}
|
cleanupConfig={config.cleanupConfig}
|
||||||
@@ -674,8 +682,12 @@ export function ConfigTabs() {
|
|||||||
isAutoSavingSchedule={isAutoSavingSchedule}
|
isAutoSavingSchedule={isAutoSavingSchedule}
|
||||||
isAutoSavingCleanup={isAutoSavingCleanup}
|
isAutoSavingCleanup={isAutoSavingCleanup}
|
||||||
/>
|
/>
|
||||||
</div>
|
</TabsContent>
|
||||||
</div>
|
|
||||||
|
<TabsContent value="sso" className="space-y-4">
|
||||||
|
<SSOSettings />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
634
src/components/config/SSOSettings.tsx
Normal file
634
src/components/config/SSOSettings.tsx
Normal 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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
276
src/components/oauth/ConsentPage.tsx
Normal file
276
src/components/oauth/ConsentPage.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/hooks/useAuthMethods.ts
Normal file
65
src/hooks/useAuthMethods.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
import { oidcClient } from "better-auth/client/plugins";
|
||||||
|
import { ssoClient } from "better-auth/client/plugins";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
// The base URL is optional when running on the same domain
|
// The base URL is optional when running on the same domain
|
||||||
// Better Auth will use the current domain by default
|
// Better Auth will use the current domain by default
|
||||||
|
plugins: [
|
||||||
|
oidcClient(),
|
||||||
|
ssoClient(),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export commonly used methods for convenience
|
// Export commonly used methods for convenience
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
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 { db, users } from "./db";
|
||||||
import * as schema from "./db/schema";
|
import * as schema from "./db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
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 configuration
|
||||||
// plugins: [],
|
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
|
// Trusted origins for CORS
|
||||||
trustedOrigins: [
|
trustedOrigins: [
|
||||||
|
|||||||
@@ -70,5 +70,9 @@ export {
|
|||||||
organizations,
|
organizations,
|
||||||
sessions,
|
sessions,
|
||||||
accounts,
|
accounts,
|
||||||
verificationTokens
|
verificationTokens,
|
||||||
|
oauthApplications,
|
||||||
|
oauthAccessTokens,
|
||||||
|
oauthConsent,
|
||||||
|
ssoProviders
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|||||||
@@ -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 definitions
|
||||||
export type User = z.infer<typeof userSchema>;
|
export type User = z.infer<typeof userSchema>;
|
||||||
export type Config = z.infer<typeof configSchema>;
|
export type Config = z.infer<typeof configSchema>;
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs));
|
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 {
|
export function formatDate(date?: Date | string | null): string {
|
||||||
if (!date) return "Never";
|
if (!date) return "Never";
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
|||||||
176
src/pages/api/sso/applications.ts
Normal file
176
src/pages/api/sso/applications.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/pages/api/sso/discover.ts
Normal file
69
src/pages/api/sso/discover.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/pages/api/sso/providers.ts
Normal file
152
src/pages/api/sso/providers.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
467
src/pages/docs/advanced.astro
Normal file
467
src/pages/docs/advanced.astro
Normal 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">←</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>
|
||||||
@@ -47,7 +47,8 @@ import MainLayout from '../../layouts/main.astro';
|
|||||||
{ name: 'Shadcn UI', desc: 'UI component library built on Tailwind CSS' },
|
{ name: 'Shadcn UI', desc: 'UI component library built on Tailwind CSS' },
|
||||||
{ name: 'SQLite', desc: 'Database for storing configuration, state, and events' },
|
{ name: 'SQLite', desc: 'Database for storing configuration, state, and events' },
|
||||||
{ name: 'Bun', desc: 'JavaScript runtime and package manager' },
|
{ 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 => (
|
].map(tech => (
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div class="w-2 h-2 rounded-full bg-primary mt-2"></div>
|
<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">
|
<div class="space-y-3">
|
||||||
{[
|
{[
|
||||||
'Authentication and user management',
|
'Authentication with Better Auth (email/password, SSO, OIDC)',
|
||||||
|
'OAuth2/OIDC provider functionality',
|
||||||
'GitHub API integration',
|
'GitHub API integration',
|
||||||
'Gitea API integration',
|
'Gitea API integration',
|
||||||
'Mirroring operations and job queue',
|
'Mirroring operations and job queue',
|
||||||
@@ -213,11 +215,13 @@ import MainLayout from '../../layouts/main.astro';
|
|||||||
|
|
||||||
<div class="space-y-3">
|
<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',
|
'GitHub and Gitea configuration',
|
||||||
'Repository and organization information',
|
'Repository and organization information',
|
||||||
'Mirroring job history and status',
|
'Mirroring job history and status',
|
||||||
'Event notifications and their read status'
|
'Event notifications and their read status',
|
||||||
|
'OAuth tokens and consent records'
|
||||||
].map(item => (
|
].map(item => (
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<span class="text-primary font-mono text-sm">▸</span>
|
<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">
|
<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">
|
<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: '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: '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.' },
|
{ title: 'Mirroring Process', desc: 'When triggered, the backend fetches repository data from GitHub and pushes it to Gitea.' },
|
||||||
|
|||||||
535
src/pages/docs/authentication.astro
Normal file
535
src/pages/docs/authentication.astro
Normal 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">←</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>
|
||||||
475
src/pages/docs/ca-certificates.astro
Normal file
475
src/pages/docs/ca-certificates.astro
Normal 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">←</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 <container-id></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>
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
---
|
---
|
||||||
import MainLayout from '../../layouts/main.astro';
|
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
|
// Define our documentation pages directly
|
||||||
const docs = [
|
const docs = [
|
||||||
{
|
{
|
||||||
slug: 'architecture',
|
slug: 'quickstart',
|
||||||
title: 'Architecture',
|
title: 'Quick Start Guide',
|
||||||
description: 'Comprehensive overview of the Gitea Mirror application architecture.',
|
description: 'Get started with Gitea Mirror quickly.',
|
||||||
order: 1,
|
order: 1,
|
||||||
icon: LuBookOpen,
|
icon: LuRocket,
|
||||||
href: '/docs/architecture'
|
href: '/docs/quickstart'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'configuration',
|
slug: 'configuration',
|
||||||
@@ -21,12 +21,36 @@ const docs = [
|
|||||||
href: '/docs/configuration'
|
href: '/docs/configuration'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'quickstart',
|
slug: 'authentication',
|
||||||
title: 'Quick Start Guide',
|
title: 'Authentication & SSO',
|
||||||
description: 'Get started with Gitea Mirror quickly.',
|
description: 'Configure authentication methods, SSO providers, and OIDC.',
|
||||||
order: 3,
|
order: 3,
|
||||||
icon: LuRocket,
|
icon: LuKey,
|
||||||
href: '/docs/quickstart'
|
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'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ bun run start</code></pre>
|
|||||||
title: 'Create Admin Account',
|
title: 'Create Admin Account',
|
||||||
items: [
|
items: [
|
||||||
"You'll be prompted on first access",
|
"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'
|
'This will be your administrator account'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
28
src/pages/oauth/consent.astro
Normal file
28
src/pages/oauth/consent.astro
Normal 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>
|
||||||
Reference in New Issue
Block a user