Compare commits

...

3 Commits

Author SHA1 Message Date
Arunavo Ray
2b78a6a4a8 v3.5.4 2025-09-07 19:11:50 +05:30
Arunavo Ray
c2f6e73054 Testing Authentik SSO Issues 2025-09-07 19:09:00 +05:30
Arunavo Ray
c4b353aae8 Added docs around scheduling using corn 2025-09-07 16:51:51 +05:30
10 changed files with 203 additions and 364 deletions

View File

@@ -216,28 +216,32 @@ Gitea Mirror provides powerful automatic synchronization features:
- **Repository cleanup**: Removes repositories that no longer exist in GitHub - **Repository cleanup**: Removes repositories that no longer exist in GitHub
- **Proper intervals**: Mirrors respect your configured sync intervals (not Gitea's default 24h) - **Proper intervals**: Mirrors respect your configured sync intervals (not Gitea's default 24h)
- **Smart scheduling**: Only syncs repositories that need updating - **Smart scheduling**: Only syncs repositories that need updating
- **Auto-start on boot** (v3.5.3+): Automatically imports and mirrors all repositories when `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set - **Auto-start on boot** (v3.5.3+): Automatically imports and mirrors all repositories when `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set - no manual clicks required!
#### Configuration via Web Interface (Recommended) #### Configuration via Web Interface (Recommended)
Navigate to the Configuration page and enable "Automatic Syncing" with your preferred interval. Navigate to the Configuration page and enable "Automatic Syncing" with your preferred interval.
#### Configuration via Environment Variables #### Configuration via Environment Variables
**Set it and forget it!** With these environment variables, Gitea Mirror will automatically: **🚀 Set it and forget it!** With these environment variables, Gitea Mirror will automatically:
1. Import all your GitHub repositories on startup 1. **Import** all your GitHub repositories on startup (no manual import needed!)
2. Mirror them to Gitea immediately 2. **Mirror** them to Gitea immediately
3. Keep them synchronized based on your interval 3. **Keep them synchronized** based on your interval
4. Auto-discover new repos you create/star on GitHub 4. **Auto-discover** new repos you create/star on GitHub
5. Clean up repos you delete from GitHub 5. **Clean up** repos you delete from GitHub
```bash ```bash
# Enable automatic scheduling (required for auto features) # Option 1: Enable automatic scheduling (triggers auto-start)
SCHEDULE_ENABLED=true SCHEDULE_ENABLED=true
SCHEDULE_INTERVAL=3600 # Check every hour (or use cron: "0 * * * *")
# Mirror interval (how often to sync) # Option 2: Set mirror interval (also triggers auto-start)
GITEA_MIRROR_INTERVAL=8h # Every 8 hours (default) GITEA_MIRROR_INTERVAL=8h # Every 8 hours
# Other examples: 5m, 30m, 1h, 24h, 1d, 7d # Other examples: 5m, 30m, 1h, 24h, 1d, 7d
# Advanced: Use cron expressions for specific times
SCHEDULE_INTERVAL="0 2 * * *" # Daily at 2 AM (optimize bandwidth usage)
# Auto-import new repositories (default: true) # Auto-import new repositories (default: true)
AUTO_IMPORT_REPOS=true AUTO_IMPORT_REPOS=true

View File

@@ -8,7 +8,7 @@
"@astrojs/mdx": "4.3.4", "@astrojs/mdx": "4.3.4",
"@astrojs/node": "9.4.3", "@astrojs/node": "9.4.3",
"@astrojs/react": "^4.3.0", "@astrojs/react": "^4.3.0",
"@better-auth/sso": "^1.3.7", "@better-auth/sso": "^1.3.8",
"@octokit/rest": "^22.0.0", "@octokit/rest": "^22.0.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
@@ -34,7 +34,7 @@
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"astro": "^5.13.4", "astro": "^5.13.4",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"better-auth": "^1.3.7", "better-auth": "^1.3.8",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -147,7 +147,7 @@
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], "@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"@better-auth/sso": ["@better-auth/sso@1.3.7", "", { "dependencies": { "@better-fetch/fetch": "^1.1.18", "better-auth": "^1.3.7", "fast-xml-parser": "^5.2.5", "jose": "^5.9.6", "oauth2-mock-server": "^7.2.0", "samlify": "^2.10.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-MTwBiNash7HN0nLtQiL1tvYgWBn6GjYj6EYvtrQeb0/+UW0tjBDgsl39ojiFFSWGuT0gxPv+ij8tQNaFmQ1+2g=="], "@better-auth/sso": ["@better-auth/sso@1.3.8", "", { "dependencies": { "@better-fetch/fetch": "^1.1.18", "fast-xml-parser": "^5.2.5", "jose": "^5.10.0", "oauth2-mock-server": "^7.2.1", "samlify": "^2.10.1", "zod": "^4.1.5" }, "peerDependencies": { "better-auth": "1.3.8" } }, "sha512-ohJl4uTRwVACu8840A5Ys/z2jus/vEsCrWvOj/RannsZ6CxQAjr8utYYXXs6lVn08ynOcuT4m0OsYRbrw7a42g=="],
"@better-auth/utils": ["@better-auth/utils@0.2.6", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-3y/vaL5Ox33dBwgJ6ub3OPkVqr6B5xL2kgxNHG8eHZuryLyG/4JSPGqjbdRSgjuy9kALUZYDFl+ORIAxlWMSuA=="], "@better-auth/utils": ["@better-auth/utils@0.2.6", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-3y/vaL5Ox33dBwgJ6ub3OPkVqr6B5xL2kgxNHG8eHZuryLyG/4JSPGqjbdRSgjuy9kALUZYDFl+ORIAxlWMSuA=="],
@@ -683,7 +683,7 @@
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"better-auth": ["better-auth@1.3.7", "", { "dependencies": { "@better-auth/utils": "0.2.6", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "^1.0.13", "defu": "^6.1.4", "jose": "^5.10.0", "kysely": "^0.28.5", "nanostores": "^0.11.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-/1fEyx2SGgJQM5ujozDCh9eJksnVkNU/J7Fk/tG5Y390l8nKbrPvqiFlCjlMM+scR+UABJbQzA6An7HT50LHyQ=="], "better-auth": ["better-auth@1.3.8", "", { "dependencies": { "@better-auth/utils": "0.2.6", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.16", "defu": "^6.1.4", "jose": "^5.10.0", "kysely": "^0.28.5", "nanostores": "^0.11.4", "zod": "^4.1.5" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-uRFzHbWkhr8eWNy+BJwyMnrZPOvQjwrcLND3nc6jusRteYA9cjeRGElgCPTWTIyWUfzaQ708Lb5Mdq9Gv41Qpw=="],
"better-call": ["better-call@1.0.16", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-42dgJ1rOtc0anOoxjXPOWuel/Z/4aeO7EJ2SiXNwvlkySSgjXhNjAjTMWa8DL1nt6EXS3jl3VKC3mPsU/lUgVA=="], "better-call": ["better-call@1.0.16", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-42dgJ1rOtc0anOoxjXPOWuel/Z/4aeO7EJ2SiXNwvlkySSgjXhNjAjTMWa8DL1nt6EXS3jl3VKC3mPsU/lUgVA=="],

View File

@@ -1,174 +0,0 @@
version: "3.8"
services:
# PostgreSQL database for Authentik
authentik-db:
image: postgres:15-alpine
container_name: authentik-db
restart: unless-stopped
environment:
POSTGRES_USER: authentik
POSTGRES_PASSWORD: authentik-db-password
POSTGRES_DB: authentik
volumes:
- authentik-db-data:/var/lib/postgresql/data
networks:
- authentik-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U authentik"]
interval: 10s
timeout: 5s
retries: 5
# Redis cache for Authentik
authentik-redis:
image: redis:7-alpine
container_name: authentik-redis
restart: unless-stopped
command: redis-server --save 60 1 --loglevel warning
volumes:
- authentik-redis-data:/data
networks:
- authentik-net
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# Authentik Server
authentik-server:
image: ghcr.io/goauthentik/server:2024.2
container_name: authentik-server
restart: unless-stopped
command: server
environment:
# Core Settings
AUTHENTIK_SECRET_KEY: "change-me-to-a-random-50-char-string-for-production"
AUTHENTIK_ERROR_REPORTING__ENABLED: false
# Database
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: authentik-db-password
# Redis
AUTHENTIK_REDIS__HOST: authentik-redis
# Email (optional - for testing, uses console backend)
AUTHENTIK_EMAIL__HOST: localhost
AUTHENTIK_EMAIL__PORT: 25
AUTHENTIK_EMAIL__USE_TLS: false
AUTHENTIK_EMAIL__USE_SSL: false
AUTHENTIK_EMAIL__TIMEOUT: 10
AUTHENTIK_EMAIL__FROM: authentik@localhost
# Log Level
AUTHENTIK_LOG_LEVEL: info
# Disable analytics
AUTHENTIK_DISABLE_UPDATE_CHECK: true
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: true
# Default admin user (only created on first run)
AUTHENTIK_BOOTSTRAP_PASSWORD: admin-password
AUTHENTIK_BOOTSTRAP_TOKEN: initial-admin-token
AUTHENTIK_BOOTSTRAP_EMAIL: admin@example.com
volumes:
- authentik-media:/media
- authentik-templates:/templates
ports:
- "9000:9000" # HTTP
- "9443:9443" # HTTPS (if configured)
networks:
- authentik-net
- gitea-mirror-net
depends_on:
authentik-db:
condition: service_healthy
authentik-redis:
condition: service_healthy
# Authentik Worker (background tasks)
authentik-worker:
image: ghcr.io/goauthentik/server:2024.2
container_name: authentik-worker
restart: unless-stopped
command: worker
environment:
# Same environment as server
AUTHENTIK_SECRET_KEY: "change-me-to-a-random-50-char-string-for-production"
AUTHENTIK_ERROR_REPORTING__ENABLED: false
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: authentik-db-password
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_EMAIL__HOST: localhost
AUTHENTIK_EMAIL__PORT: 25
AUTHENTIK_EMAIL__USE_TLS: false
AUTHENTIK_EMAIL__USE_SSL: false
AUTHENTIK_EMAIL__TIMEOUT: 10
AUTHENTIK_EMAIL__FROM: authentik@localhost
AUTHENTIK_LOG_LEVEL: info
AUTHENTIK_DISABLE_UPDATE_CHECK: true
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: true
volumes:
- authentik-media:/media
- authentik-templates:/templates
networks:
- authentik-net
depends_on:
authentik-db:
condition: service_healthy
authentik-redis:
condition: service_healthy
# Gitea Mirror Application (uncomment to run together)
# gitea-mirror:
# build: .
# # OR use pre-built image:
# # image: ghcr.io/raylabshq/gitea-mirror:latest
# container_name: gitea-mirror
# restart: unless-stopped
# environment:
# # Core Settings
# BETTER_AUTH_URL: http://localhost:4321
# BETTER_AUTH_TRUSTED_ORIGINS: http://localhost:4321,http://localhost:9000
# BETTER_AUTH_SECRET: "your-32-character-secret-key-here"
#
# # GitHub Settings (configure as needed)
# GITHUB_USERNAME: ${GITHUB_USERNAME}
# GITHUB_TOKEN: ${GITHUB_TOKEN}
#
# # Gitea Settings (configure as needed)
# GITEA_URL: ${GITEA_URL}
# GITEA_USERNAME: ${GITEA_USERNAME}
# GITEA_TOKEN: ${GITEA_TOKEN}
# volumes:
# - ./data:/app/data
# ports:
# - "4321:4321"
# networks:
# - gitea-mirror-net
# depends_on:
# - authentik-server
volumes:
authentik-db-data:
name: authentik-db-data
authentik-redis-data:
name: authentik-redis-data
authentik-media:
name: authentik-media
authentik-templates:
name: authentik-templates
networks:
authentik-net:
name: authentik-net
driver: bridge
gitea-mirror-net:
name: gitea-mirror-net
driver: bridge

View File

@@ -1,130 +0,0 @@
version: "3.8"
services:
# PostgreSQL database for Keycloak
keycloak-db:
image: postgres:15-alpine
container_name: keycloak-db
restart: unless-stopped
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak-db-password
volumes:
- keycloak-db-data:/var/lib/postgresql/data
networks:
- keycloak-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 10s
timeout: 5s
retries: 5
# Keycloak Identity Provider
keycloak:
image: quay.io/keycloak/keycloak:23.0
container_name: keycloak
restart: unless-stopped
command: start-dev # Use 'start' for production with HTTPS
environment:
# Admin credentials
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin-password
# Database configuration
KC_DB: postgres
KC_DB_URL_HOST: keycloak-db
KC_DB_URL_DATABASE: keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak-db-password
# HTTP settings
KC_HTTP_ENABLED: true
KC_HTTP_PORT: 8080
KC_HOSTNAME_STRICT: false
KC_HOSTNAME_STRICT_HTTPS: false
KC_PROXY: edge # If behind a proxy
# Development settings (remove for production)
KC_HOSTNAME: localhost
KC_HOSTNAME_PORT: 8080
KC_HOSTNAME_ADMIN: localhost
# Features
KC_FEATURES: token-exchange,admin-fine-grained-authz
# Health and metrics
KC_HEALTH_ENABLED: true
KC_METRICS_ENABLED: true
# Log level
KC_LOG_LEVEL: INFO
# Uncomment for debug logging
# KC_LOG_LEVEL: DEBUG
# QUARKUS_LOG_CATEGORY__ORG_KEYCLOAK_SERVICES: DEBUG
ports:
- "8080:8080" # HTTP
- "8443:8443" # HTTPS (if configured)
- "9000:9000" # Management
networks:
- keycloak-net
- gitea-mirror-net
depends_on:
keycloak-db:
condition: service_healthy
volumes:
# For custom themes (optional)
- keycloak-themes:/opt/keycloak/themes
# For importing realm configurations
- ./keycloak-realm-export.json:/opt/keycloak/data/import/realm.json:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
interval: 15s
timeout: 10s
retries: 10
start_period: 60s
# Gitea Mirror Application (uncomment to run together)
# gitea-mirror:
# build: .
# # OR use pre-built image:
# # image: ghcr.io/raylabshq/gitea-mirror:latest
# container_name: gitea-mirror
# restart: unless-stopped
# environment:
# # Core Settings
# BETTER_AUTH_URL: http://localhost:4321
# BETTER_AUTH_TRUSTED_ORIGINS: http://localhost:4321,http://localhost:8080
# BETTER_AUTH_SECRET: "your-32-character-secret-key-here"
#
# # GitHub Settings (configure as needed)
# GITHUB_USERNAME: ${GITHUB_USERNAME}
# GITHUB_TOKEN: ${GITHUB_TOKEN}
#
# # Gitea Settings (configure as needed)
# GITEA_URL: ${GITEA_URL}
# GITEA_USERNAME: ${GITEA_USERNAME}
# GITEA_TOKEN: ${GITEA_TOKEN}
# volumes:
# - ./data:/app/data
# ports:
# - "4321:4321"
# networks:
# - gitea-mirror-net
# depends_on:
# keycloak:
# condition: service_healthy
volumes:
keycloak-db-data:
name: keycloak-db-data
keycloak-themes:
name: keycloak-themes
networks:
keycloak-net:
name: keycloak-net
driver: bridge
gitea-mirror-net:
name: gitea-mirror-net
driver: bridge

View File

@@ -151,15 +151,27 @@ Configure automatic scheduled mirroring.
| Variable | Description | Default | Options | | Variable | Description | Default | Options |
|----------|-------------|---------|---------| |----------|-------------|---------|---------|
| `SCHEDULE_ENABLED` | Enable automatic mirroring. **When set to `true`, automatically imports and mirrors all repositories on startup** (v3.5.3+) | `false` | `true`, `false` | | `SCHEDULE_ENABLED` | Enable automatic mirroring. **When set to `true`, automatically imports and mirrors all repositories on startup** (v3.5.3+) | `false` | `true`, `false` |
| `SCHEDULE_INTERVAL` | Interval in seconds or cron expression | `3600` | Number or cron string (e.g., `"0 2 * * *"`) | | `SCHEDULE_INTERVAL` | Interval in seconds or cron expression. **Supports cron syntax for scheduled runs** (e.g., `"0 2 * * *"` for 2 AM daily) | `3600` | Number (seconds) or cron string |
| `DELAY` | Legacy: same as SCHEDULE_INTERVAL | `3600` | Number (seconds) | | `DELAY` | Legacy: same as SCHEDULE_INTERVAL | `3600` | Number (seconds) |
> **Note**: Setting either `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` triggers auto-start functionality where the service will: > **🚀 Auto-Start Feature (v3.5.3+)**
> 1. Import all GitHub repositories on startup > Setting either `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` triggers auto-start functionality where the service will:
> 2. Mirror them to Gitea immediately > 1. **Import** all GitHub repositories on startup
> 3. Continue syncing at the configured interval > 2. **Mirror** them to Gitea immediately
> 4. Auto-discover new repositories > 3. **Continue syncing** at the configured interval
> 5. Clean up deleted repositories (if configured) > 4. **Auto-discover** new repositories
> 5. **Clean up** deleted repositories (if configured)
>
> This eliminates the need for manual button clicks - perfect for Docker/Kubernetes deployments!
> **⏰ Scheduling with Cron Expressions**
> Use cron expressions in `SCHEDULE_INTERVAL` to run at specific times:
> - `"0 2 * * *"` - Daily at 2 AM
> - `"0 */6 * * *"` - Every 6 hours
> - `"0 0 * * 0"` - Weekly on Sunday at midnight
> - `"0 3 * * 1-5"` - Weekdays at 3 AM (Monday-Friday)
>
> This is useful for optimizing bandwidth usage during low-activity periods.
### Execution Settings ### Execution Settings

View File

@@ -1,7 +1,7 @@
{ {
"name": "gitea-mirror", "name": "gitea-mirror",
"type": "module", "type": "module",
"version": "3.5.3", "version": "3.5.4",
"engines": { "engines": {
"bun": ">=1.2.9" "bun": ">=1.2.9"
}, },
@@ -46,7 +46,7 @@
"@astrojs/mdx": "4.3.4", "@astrojs/mdx": "4.3.4",
"@astrojs/node": "9.4.3", "@astrojs/node": "9.4.3",
"@astrojs/react": "^4.3.0", "@astrojs/react": "^4.3.0",
"@better-auth/sso": "^1.3.7", "@better-auth/sso": "^1.3.8",
"@octokit/rest": "^22.0.0", "@octokit/rest": "^22.0.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
@@ -72,7 +72,7 @@
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"astro": "^5.13.4", "astro": "^5.13.4",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"better-auth": "^1.3.7", "better-auth": "^1.3.8",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -7,15 +7,30 @@ export const authClient = createAuthClient({
// Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin // Use PUBLIC_BETTER_AUTH_URL if set (for multi-origin access), otherwise use current origin
// This allows the client to connect to the auth server even when accessed from different origins // This allows the client to connect to the auth server even when accessed from different origins
baseURL: (() => { baseURL: (() => {
let url: string | undefined;
// Check for public environment variable first (for client-side access) // Check for public environment variable first (for client-side access)
if (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_BETTER_AUTH_URL) { if (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_BETTER_AUTH_URL) {
return import.meta.env.PUBLIC_BETTER_AUTH_URL; url = import.meta.env.PUBLIC_BETTER_AUTH_URL;
} }
// Validate and clean the URL if provided
if (url && typeof url === 'string' && url.trim() !== '') {
try {
// Validate URL format and remove trailing slash
const validatedUrl = new URL(url.trim());
return validatedUrl.origin; // Use origin to ensure clean URL without path
} catch (e) {
console.warn(`Invalid PUBLIC_BETTER_AUTH_URL: ${url}, falling back to default`);
}
}
// Fall back to current origin if running in browser // Fall back to current origin if running in browser
if (typeof window !== 'undefined') { if (typeof window !== 'undefined' && window.location?.origin) {
return window.location.origin; return window.location.origin;
} }
// Default for SSR
// Default for SSR - always return a valid URL
return 'http://localhost:4321'; return 'http://localhost:4321';
})(), })(),
basePath: '/api/auth', // Explicitly set the base path basePath: '/api/auth', // Explicitly set the base path

View File

@@ -19,42 +19,71 @@ export const auth = betterAuth({
// Base URL configuration - use the primary URL (Better Auth only supports single baseURL) // Base URL configuration - use the primary URL (Better Auth only supports single baseURL)
baseURL: (() => { baseURL: (() => {
const url = process.env.BETTER_AUTH_URL || "http://localhost:4321"; const url = process.env.BETTER_AUTH_URL;
const defaultUrl = "http://localhost:4321";
// Check if URL is provided and not empty
if (!url || typeof url !== 'string' || url.trim() === '') {
console.info('BETTER_AUTH_URL not set, using default:', defaultUrl);
return defaultUrl;
}
try { try {
// Validate URL format // Validate URL format and ensure it's a proper origin
new URL(url); const validatedUrl = new URL(url.trim());
return url; const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths
} catch { console.info('Using BETTER_AUTH_URL:', cleanUrl);
console.warn(`Invalid BETTER_AUTH_URL: ${url}, falling back to localhost`); return cleanUrl;
return "http://localhost:4321"; } catch (e) {
console.error(`Invalid BETTER_AUTH_URL format: "${url}"`);
console.error('Error:', e);
console.info('Falling back to default:', defaultUrl);
return defaultUrl;
} }
})(), })(),
basePath: "/api/auth", // Specify the base path for auth endpoints basePath: "/api/auth", // Specify the base path for auth endpoints
// Trusted origins - this is how we support multiple access URLs // Trusted origins - this is how we support multiple access URLs
trustedOrigins: (() => { trustedOrigins: (() => {
const origins = [ const origins: string[] = [
"http://localhost:4321", "http://localhost:4321",
"http://localhost:8080", // Keycloak "http://localhost:8080", // Keycloak
]; ];
// Add the primary URL from BETTER_AUTH_URL // Add the primary URL from BETTER_AUTH_URL
const primaryUrl = process.env.BETTER_AUTH_URL || "http://localhost:4321"; const primaryUrl = process.env.BETTER_AUTH_URL;
try { if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') {
new URL(primaryUrl); try {
origins.push(primaryUrl); const validatedUrl = new URL(primaryUrl.trim());
} catch { origins.push(validatedUrl.origin);
// Skip if invalid } catch {
// Skip if invalid
}
} }
// Add additional trusted origins from environment // Add additional trusted origins from environment
// This is where users can specify multiple access URLs // This is where users can specify multiple access URLs
if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) { if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) {
origins.push(...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map(o => o.trim())); const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(o => o !== '');
// Validate each additional origin
for (const origin of additionalOrigins) {
try {
const validatedUrl = new URL(origin);
origins.push(validatedUrl.origin);
} catch {
console.warn(`Invalid trusted origin: ${origin}, skipping`);
}
}
} }
// Remove duplicates and return // Remove duplicates and empty strings, then return
return [...new Set(origins.filter(Boolean))]; const uniqueOrigins = [...new Set(origins.filter(Boolean))];
console.info('Trusted origins:', uniqueOrigins);
return uniqueOrigins;
})(), })(),
// Authentication methods // Authentication methods

View File

@@ -25,9 +25,34 @@ export async function POST(context: APIContext) {
); );
} }
// Validate issuer URL format
let validatedIssuer = issuer;
if (issuer && typeof issuer === 'string' && issuer.trim() !== '') {
try {
const issuerUrl = new URL(issuer.trim());
validatedIssuer = issuerUrl.toString().replace(/\/$/, ''); // Remove trailing slash
} catch (e) {
return new Response(
JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
} else {
return new Response(
JSON.stringify({ error: "Issuer URL cannot be empty" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
let registrationBody: any = { let registrationBody: any = {
providerId, providerId,
issuer, issuer: validatedIssuer,
domain, domain,
organizationId, organizationId,
}; };
@@ -91,14 +116,27 @@ export async function POST(context: APIContext) {
// Use provided scopes or default if not specified // Use provided scopes or default if not specified
const finalScopes = scopes || ["openid", "email", "profile"]; const finalScopes = scopes || ["openid", "email", "profile"];
// Validate endpoint URLs if provided
const validateUrl = (url: string | undefined, name: string): string | undefined => {
if (!url) return undefined;
if (typeof url !== 'string' || url.trim() === '') return undefined;
try {
const validatedUrl = new URL(url.trim());
return validatedUrl.toString();
} catch (e) {
console.warn(`Invalid ${name} URL: ${url}, skipping`);
return undefined;
}
};
registrationBody.oidcConfig = { registrationBody.oidcConfig = {
clientId, clientId: clientId || undefined,
clientSecret, clientSecret: clientSecret || undefined,
authorizationEndpoint, authorizationEndpoint: validateUrl(authorizationEndpoint, 'authorization endpoint'),
tokenEndpoint, tokenEndpoint: validateUrl(tokenEndpoint, 'token endpoint'),
jwksEndpoint, jwksEndpoint: validateUrl(jwksEndpoint, 'JWKS endpoint'),
discoveryEndpoint, discoveryEndpoint: validateUrl(discoveryEndpoint, 'discovery endpoint'),
userInfoEndpoint, userInfoEndpoint: validateUrl(userInfoEndpoint, 'userinfo endpoint'),
scopes: finalScopes, scopes: finalScopes,
pkce, pkce,
}; };

View File

@@ -10,26 +10,71 @@ export async function POST(context: APIContext) {
const { issuer } = await context.request.json(); const { issuer } = await context.request.json();
if (!issuer) { if (!issuer || typeof issuer !== 'string' || issuer.trim() === '') {
return new Response(JSON.stringify({ error: "Issuer URL is required" }), { return new Response(JSON.stringify({ error: "Issuer URL is required and must be a valid string" }), {
status: 400, status: 400,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
// Ensure issuer URL ends without trailing slash for well-known discovery // Validate issuer URL format
const cleanIssuer = issuer.replace(/\/$/, ""); let cleanIssuer: string;
try {
const issuerUrl = new URL(issuer.trim());
cleanIssuer = issuerUrl.toString().replace(/\/$/, ""); // Remove trailing slash
} catch (e) {
return new Response(
JSON.stringify({
error: "Invalid issuer URL format",
details: `The provided URL "${issuer}" is not a valid URL. For Authentik, use format: https://your-authentik-domain/application/o/<app-slug>/`
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const discoveryUrl = `${cleanIssuer}/.well-known/openid-configuration`; const discoveryUrl = `${cleanIssuer}/.well-known/openid-configuration`;
try { try {
// Fetch OIDC discovery document // Fetch OIDC discovery document with timeout
const response = await fetch(discoveryUrl); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
let response: Response;
try {
response = await fetch(discoveryUrl, {
signal: controller.signal,
headers: {
'Accept': 'application/json',
}
});
} catch (fetchError) {
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
throw new Error(`Request timeout: The OIDC provider at ${cleanIssuer} did not respond within 10 seconds`);
}
throw new Error(`Network error: Could not connect to ${cleanIssuer}. Please verify the URL is correct and accessible.`);
} finally {
clearTimeout(timeoutId);
}
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch discovery document: ${response.status}`); if (response.status === 404) {
throw new Error(`OIDC discovery document not found at ${discoveryUrl}. For Authentik, ensure you're using the correct application slug in the URL.`);
} else if (response.status >= 500) {
throw new Error(`OIDC provider error (${response.status}): The server at ${cleanIssuer} returned an error.`);
} else {
throw new Error(`Failed to fetch discovery document (${response.status}): ${response.statusText}`);
}
} }
const config = await response.json(); let config: any;
try {
config = await response.json();
} catch (parseError) {
throw new Error(`Invalid response: The discovery document from ${cleanIssuer} is not valid JSON.`);
}
// Extract the essential endpoints // Extract the essential endpoints
const discoveredConfig = { const discoveredConfig = {