diff --git a/.env.example b/.env.example index 5ae749e..5cfebc7 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,26 @@ DATABASE_URL=sqlite://data/gitea-mirror.db # Generate with: openssl rand -base64 32 BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production BETTER_AUTH_URL=http://localhost:4321 -# PUBLIC_BETTER_AUTH_URL=https://your-domain.com # Optional: Set this if accessing from different origins (e.g., IP and domain) # ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48 +# =========================================== +# REVERSE PROXY CONFIGURATION +# =========================================== +# REQUIRED when accessing Gitea Mirror through a reverse proxy (Nginx, Caddy, Traefik, etc.). +# Without these, sign-in will fail with "invalid origin" errors and pages may appear blank. +# +# Set all three to your external URL, e.g.: +# BETTER_AUTH_URL=https://gitea-mirror.example.com +# PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com +# BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com +# +# BETTER_AUTH_URL - Used server-side for auth callbacks and redirects +# PUBLIC_BETTER_AUTH_URL - Used client-side (browser) for auth API calls +# BETTER_AUTH_TRUSTED_ORIGINS - Comma-separated list of origins allowed to make auth requests +# (e.g. https://gitea-mirror.example.com,https://alt.example.com) +PUBLIC_BETTER_AUTH_URL=http://localhost:4321 +# BETTER_AUTH_TRUSTED_ORIGINS= + # =========================================== # DOCKER CONFIGURATION (Optional) # =========================================== diff --git a/docker-compose.alt.yml b/docker-compose.alt.yml index 2d412d1..296bfba 100644 --- a/docker-compose.alt.yml +++ b/docker-compose.alt.yml @@ -18,6 +18,10 @@ services: - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} # Min 32 chars, required for sessions - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321} - BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:4321} + # REVERSE PROXY: If accessing via a reverse proxy, set all three to your external URL: + # BETTER_AUTH_URL=https://gitea-mirror.example.com + # PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com + # BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com # === CORE SETTINGS === # These are technically required but have working defaults diff --git a/docker-compose.yml b/docker-compose.yml index 00d8cd0..c278b44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,13 @@ services: - PORT=4321 - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-your-secret-key-change-this-in-production} - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:4321} + # REVERSE PROXY: If you access Gitea Mirror through a reverse proxy (e.g. Nginx, Caddy, Traefik), + # you MUST set these three variables to your external URL. Example: + # BETTER_AUTH_URL=https://gitea-mirror.example.com + # PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com + # BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com + - PUBLIC_BETTER_AUTH_URL=${PUBLIC_BETTER_AUTH_URL:-http://localhost:4321} + - BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-} # Optional: ENCRYPTION_SECRET will be auto-generated if not provided # - ENCRYPTION_SECRET=${ENCRYPTION_SECRET:-} # GitHub/Gitea Mirror Config diff --git a/src/lib/auth-origins.test.ts b/src/lib/auth-origins.test.ts new file mode 100644 index 0000000..933bd47 --- /dev/null +++ b/src/lib/auth-origins.test.ts @@ -0,0 +1,119 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { resolveTrustedOrigins } from "./auth"; + +// Helper to create a mock Request with specific headers +function mockRequest(headers: Record): Request { + return new Request("http://localhost:4321/api/auth/sign-in", { + headers: new Headers(headers), + }); +} + +describe("resolveTrustedOrigins", () => { + const savedEnv: Record = {}; + + beforeEach(() => { + // Save and clear relevant env vars + for (const key of ["BETTER_AUTH_URL", "BETTER_AUTH_TRUSTED_ORIGINS"]) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + // Restore env vars + for (const [key, val] of Object.entries(savedEnv)) { + if (val === undefined) delete process.env[key]; + else process.env[key] = val; + } + }); + + test("includes localhost defaults when called without request", async () => { + const origins = await resolveTrustedOrigins(); + expect(origins).toContain("http://localhost:4321"); + expect(origins).toContain("http://localhost:8080"); + }); + + test("includes BETTER_AUTH_URL from env", async () => { + process.env.BETTER_AUTH_URL = "https://gitea-mirror.example.com"; + const origins = await resolveTrustedOrigins(); + expect(origins).toContain("https://gitea-mirror.example.com"); + }); + + test("includes BETTER_AUTH_TRUSTED_ORIGINS (comma-separated)", async () => { + process.env.BETTER_AUTH_TRUSTED_ORIGINS = "https://a.example.com, https://b.example.com"; + const origins = await resolveTrustedOrigins(); + expect(origins).toContain("https://a.example.com"); + expect(origins).toContain("https://b.example.com"); + }); + + test("skips invalid URLs in env vars", async () => { + process.env.BETTER_AUTH_URL = "not-a-url"; + process.env.BETTER_AUTH_TRUSTED_ORIGINS = "also-invalid, https://valid.example.com"; + const origins = await resolveTrustedOrigins(); + expect(origins).not.toContain("not-a-url"); + expect(origins).not.toContain("also-invalid"); + expect(origins).toContain("https://valid.example.com"); + }); + + test("auto-detects origin from x-forwarded-host + x-forwarded-proto", async () => { + const req = mockRequest({ + "x-forwarded-host": "gitea-mirror.mydomain.tld", + "x-forwarded-proto": "https", + }); + const origins = await resolveTrustedOrigins(req); + expect(origins).toContain("https://gitea-mirror.mydomain.tld"); + }); + + test("falls back to host header when x-forwarded-host is absent", async () => { + const req = mockRequest({ + host: "myserver.local:4321", + }); + const origins = await resolveTrustedOrigins(req); + expect(origins).toContain("http://myserver.local:4321"); + }); + + test("handles multi-value x-forwarded-host (chained proxies)", async () => { + const req = mockRequest({ + "x-forwarded-host": "external.example.com, internal.proxy.local", + "x-forwarded-proto": "https", + }); + const origins = await resolveTrustedOrigins(req); + expect(origins).toContain("https://external.example.com"); + expect(origins).not.toContain("https://internal.proxy.local"); + }); + + test("handles multi-value x-forwarded-proto (chained proxies)", async () => { + const req = mockRequest({ + "x-forwarded-host": "gitea.example.com", + "x-forwarded-proto": "https, http", + }); + const origins = await resolveTrustedOrigins(req); + expect(origins).toContain("https://gitea.example.com"); + // Should NOT create an origin with "https, http" as proto + expect(origins).not.toContain("https, http://gitea.example.com"); + }); + + test("rejects invalid x-forwarded-proto values", async () => { + const req = mockRequest({ + "x-forwarded-host": "gitea.example.com", + "x-forwarded-proto": "ftp", + }); + const origins = await resolveTrustedOrigins(req); + expect(origins).not.toContain("ftp://gitea.example.com"); + }); + + test("deduplicates origins", async () => { + process.env.BETTER_AUTH_URL = "http://localhost:4321"; + const origins = await resolveTrustedOrigins(); + const count = origins.filter(o => o === "http://localhost:4321").length; + expect(count).toBe(1); + }); + + test("defaults proto to http when x-forwarded-proto is absent", async () => { + const req = mockRequest({ + "x-forwarded-host": "gitea.internal", + }); + const origins = await resolveTrustedOrigins(req); + expect(origins).toContain("http://gitea.internal"); + }); +}); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 6c25927..17fe5c9 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -6,6 +6,72 @@ import { db, users } from "./db"; import * as schema from "./db/schema"; import { eq } from "drizzle-orm"; +/** + * Resolves the list of trusted origins for Better Auth CSRF validation. + * Exported for testing. Called per-request with the incoming Request, + * or at startup with no request (static origins only). + */ +export async function resolveTrustedOrigins(request?: Request): Promise { + const origins: string[] = [ + "http://localhost:4321", + "http://localhost:8080", // Keycloak + ]; + + // Add the primary URL from BETTER_AUTH_URL + const primaryUrl = process.env.BETTER_AUTH_URL; + if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') { + try { + const validatedUrl = new URL(primaryUrl.trim()); + origins.push(validatedUrl.origin); + } catch { + // Skip if invalid + } + } + + // Add additional trusted origins from environment + if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) { + const additionalOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS + .split(',') + .map(o => o.trim()) + .filter(o => o !== ''); + + for (const origin of additionalOrigins) { + try { + const validatedUrl = new URL(origin); + origins.push(validatedUrl.origin); + } catch { + console.warn(`Invalid trusted origin: ${origin}, skipping`); + } + } + } + + // Auto-detect origin from the incoming request's Host header when running + // behind a reverse proxy. Helps with Better Auth's per-request CSRF check. + if (request?.headers) { + // Take first value only — headers can be comma-separated in chained proxy setups + const rawHost = request.headers.get("x-forwarded-host") || request.headers.get("host"); + const host = rawHost?.split(",")[0].trim(); + if (host) { + const rawProto = request.headers.get("x-forwarded-proto") || "http"; + const proto = rawProto.split(",")[0].trim().toLowerCase(); + if (proto === "http" || proto === "https") { + try { + const detected = new URL(`${proto}://${host}`); + origins.push(detected.origin); + } catch { + // Malformed header, ignore + } + } + } + } + + const uniqueOrigins = [...new Set(origins.filter(Boolean))]; + if (!request) { + console.info("Trusted origins (static):", uniqueOrigins); + } + return uniqueOrigins; +} + export const auth = betterAuth({ // Database configuration database: drizzleAdapter(db, { @@ -43,48 +109,11 @@ export const auth = betterAuth({ })(), basePath: "/api/auth", // Specify the base path for auth endpoints - // Trusted origins - this is how we support multiple access URLs - trustedOrigins: (() => { - const origins: string[] = [ - "http://localhost:4321", - "http://localhost:8080", // Keycloak - ]; - - // Add the primary URL from BETTER_AUTH_URL - const primaryUrl = process.env.BETTER_AUTH_URL; - if (primaryUrl && typeof primaryUrl === 'string' && primaryUrl.trim() !== '') { - try { - const validatedUrl = new URL(primaryUrl.trim()); - origins.push(validatedUrl.origin); - } catch { - // Skip if invalid - } - } - - // Add additional trusted origins from environment - // This is where users can specify multiple access URLs - if (process.env.BETTER_AUTH_TRUSTED_ORIGINS) { - 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 empty strings, then return - const uniqueOrigins = [...new Set(origins.filter(Boolean))]; - console.info('Trusted origins:', uniqueOrigins); - return uniqueOrigins; - })(), + // Trusted origins - this is how we support multiple access URLs. + // Uses the function form so that the origin can be auto-detected from + // the incoming request's Host / X-Forwarded-* headers, which makes the + // app work behind a reverse proxy without manual env var configuration. + trustedOrigins: (request?: Request) => resolveTrustedOrigins(request), // Authentication methods emailAndPassword: { diff --git a/src/pages/api/sse/index.ts b/src/pages/api/sse/index.ts index ce47816..11ccbed 100644 --- a/src/pages/api/sse/index.ts +++ b/src/pages/api/sse/index.ts @@ -95,6 +95,7 @@ export const GET: APIRoute = async ({ request, locals }) => { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", + "X-Accel-Buffering": "no", // Prevent Nginx from buffering SSE stream }, }); }; diff --git a/src/pages/docs/advanced.astro b/src/pages/docs/advanced.astro index 9212221..ad4895b 100644 --- a/src/pages/docs/advanced.astro +++ b/src/pages/docs/advanced.astro @@ -202,13 +202,55 @@ import MainLayout from '../../layouts/main.astro';

Reverse Proxy Configuration

- +

For production deployments, it's recommended to use a reverse proxy like Nginx or Caddy.

+
+
+
+ + + +
+
+

Required Environment Variables for Reverse Proxy

+

+ When running Gitea Mirror behind a reverse proxy, you must set these environment variables to your external URL. + Without them, sign-in will fail with "invalid origin" errors and pages may appear blank. +

+
+
{`# All three MUST be set to your external URL:
+BETTER_AUTH_URL=https://gitea-mirror.example.com
+PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
+BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com`}
+
+
    +
  • BETTER_AUTH_URL — Server-side auth base URL for callbacks and redirects
  • +
  • PUBLIC_BETTER_AUTH_URL — Client-side (browser) URL for auth API calls
  • +
  • BETTER_AUTH_TRUSTED_ORIGINS — Comma-separated origins allowed to make auth requests
  • +
+
+
+
+ +

Docker Compose Example

+ +
+
{`services:
+  gitea-mirror:
+    image: ghcr.io/raylabshq/gitea-mirror:latest
+    environment:
+      - BETTER_AUTH_SECRET=your-secret-key-min-32-chars
+      - BETTER_AUTH_URL=https://gitea-mirror.example.com
+      - PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
+      - BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com
+      # ... other settings ...`}
+
+

Nginx Example

- +
{`server {
     listen 80;
@@ -242,13 +284,16 @@ server {
         proxy_set_header Connection '';
         proxy_set_header Cache-Control 'no-cache';
         proxy_set_header X-Accel-Buffering 'no';
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
         proxy_read_timeout 86400;
     }
 }`}

Caddy Example

- +
{`gitea-mirror.example.com {
     reverse_proxy localhost:4321