mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-03-29 00:58:01 +03:00
* fix: improve reverse proxy support for subdomain deployments (#63) - Add X-Accel-Buffering: no header to SSE endpoint to prevent Nginx from buffering the event stream - Auto-detect trusted origin from Host/X-Forwarded-* request headers so the app works behind a proxy without manual env var configuration - Add prominent reverse proxy documentation to advanced docs page explaining BETTER_AUTH_URL, PUBLIC_BETTER_AUTH_URL, and BETTER_AUTH_TRUSTED_ORIGINS are mandatory for proxy deployments - Add reverse proxy env var comments and entries to both docker-compose.yml and docker-compose.alt.yml - Add dedicated reverse proxy configuration section to .env.example * fix: address review findings for reverse proxy origin detection - Fix x-forwarded-proto multi-value handling: take first value only and validate it is "http" or "https" before using - Update comment to accurately describe auto-detection scope: helps with per-request CSRF checks but not callback URL validation - Restore startup logging of static trusted origins for debugging * fix: handle multi-value x-forwarded-host in chained proxy setups x-forwarded-host can be comma-separated (e.g. "proxy1.example.com, proxy2.example.com") in chained proxy setups. Take only the first value, matching the same handling already applied to x-forwarded-proto. * test: add unit tests for reverse proxy origin detection Extract resolveTrustedOrigins into a testable exported function and add 11 tests covering: - Default localhost origins - BETTER_AUTH_URL and BETTER_AUTH_TRUSTED_ORIGINS env vars - Invalid URL handling - Auto-detection from x-forwarded-host + x-forwarded-proto - Multi-value header handling (chained proxy setups) - Invalid proto rejection (only http/https allowed) - Deduplication - Fallback to host header when x-forwarded-host absent
206 lines
7.0 KiB
TypeScript
206 lines
7.0 KiB
TypeScript
import { betterAuth } from "better-auth";
|
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
import { oidcProvider } from "better-auth/plugins";
|
|
import { sso } from "@better-auth/sso";
|
|
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<string[]> {
|
|
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, {
|
|
provider: "sqlite",
|
|
usePlural: true, // Our tables use plural names (users, not user)
|
|
schema, // Pass the schema explicitly
|
|
}),
|
|
|
|
// Secret for signing tokens
|
|
secret: process.env.BETTER_AUTH_SECRET,
|
|
|
|
// Base URL configuration - use the primary URL (Better Auth only supports single baseURL)
|
|
baseURL: (() => {
|
|
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 {
|
|
// Validate URL format and ensure it's a proper origin
|
|
const validatedUrl = new URL(url.trim());
|
|
const cleanUrl = validatedUrl.origin; // Use origin to ensure no trailing paths
|
|
console.info('Using BETTER_AUTH_URL:', cleanUrl);
|
|
return cleanUrl;
|
|
} 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
|
|
|
|
// 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: {
|
|
enabled: true,
|
|
requireEmailVerification: false, // We'll enable this later
|
|
sendResetPassword: async ({ user, url }) => {
|
|
// TODO: Implement email sending for password reset
|
|
console.log("Password reset requested for:", user.email);
|
|
console.log("Reset URL:", url);
|
|
},
|
|
},
|
|
|
|
|
|
// Session configuration
|
|
session: {
|
|
cookieName: "better-auth-session",
|
|
updateSessionCookieAge: true,
|
|
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
|
},
|
|
|
|
// User configuration
|
|
user: {
|
|
additionalFields: {
|
|
// Keep the username field from our existing schema
|
|
username: {
|
|
type: "string",
|
|
required: false,
|
|
input: false, // Don't show in signup form - we'll derive from email
|
|
}
|
|
},
|
|
},
|
|
|
|
// Plugins configuration
|
|
plugins: [
|
|
// OIDC Provider plugin - allows this app to act as an OIDC provider
|
|
oidcProvider({
|
|
loginPage: "/login",
|
|
consentPage: "/oauth/consent",
|
|
// Allow dynamic client registration for flexibility
|
|
allowDynamicClientRegistration: true,
|
|
// Note: trustedClients would be configured here if Better Auth supports it
|
|
// For now, we'll use dynamic registration
|
|
// 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 }: { user: any, userInfo: any }) => {
|
|
// Derive username from email if not provided
|
|
const username = user.name || user.email?.split('@')[0] || 'user';
|
|
|
|
// Update user in database if needed
|
|
await db.update(users)
|
|
.set({ username })
|
|
.where(eq(users.id, user.id))
|
|
.catch(() => {}); // Ignore errors if user doesn't exist yet
|
|
},
|
|
// Organization provisioning settings
|
|
organizationProvisioning: {
|
|
disabled: false,
|
|
defaultRole: "member",
|
|
getRole: async ({ userInfo }: { user: any, userInfo: any }) => {
|
|
// Check if user has admin attribute from SSO provider
|
|
const isAdmin = userInfo.attributes?.role === 'admin' ||
|
|
userInfo.attributes?.groups?.includes('admins');
|
|
|
|
return isAdmin ? "admin" : "member";
|
|
},
|
|
},
|
|
// Override user info with provider data by default
|
|
defaultOverrideUserInfo: true,
|
|
// Allow implicit sign up for new users
|
|
disableImplicitSignUp: false,
|
|
// Trust email_verified claims from the upstream provider so we can link by matching email
|
|
trustEmailVerified: true,
|
|
}),
|
|
],
|
|
});
|
|
|
|
// Export type for use in other parts of the app
|
|
export type Auth = typeof auth;
|