diff --git a/.env.example b/.env.example index c286132..35d62af 100644 --- a/.env.example +++ b/.env.example @@ -214,6 +214,9 @@ DOCKER_TAG=latest # AUTHENTICATION CONFIGURATION # =========================================== +# Login page: tab that opens first when both email and SSO are available +# AUTH_DEFAULT_METHOD=email # email or sso + # Header Authentication (for Reverse Proxy SSO) # Enable automatic authentication via reverse proxy headers # HEADER_AUTH_ENABLED=false diff --git a/docker-compose.yml b/docker-compose.yml index 1c647dc..82232d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,8 @@ services: - CLEANUP_DRY_RUN=${CLEANUP_DRY_RUN:-true} # Optional: Skip TLS verification (insecure, use only for testing) # - GITEA_SKIP_TLS_VERIFY=${GITEA_SKIP_TLS_VERIFY:-false} + # Login page: which tab opens first when both methods are available + - AUTH_DEFAULT_METHOD=${AUTH_DEFAULT_METHOD:-email} # Header Authentication (for Reverse Proxy SSO) - HEADER_AUTH_ENABLED=${HEADER_AUTH_ENABLED:-false} - HEADER_AUTH_USER_HEADER=${HEADER_AUTH_USER_HEADER:-X-Authentik-Username} diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 27e5a6b..575f025 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -342,6 +342,15 @@ Configure automatic cleanup of old events and data. Configure authentication methods and SSO. +### Login Page + +| Variable | Description | Default | Options | +|----------|-------------|---------|---------| +| `AUTH_DEFAULT_METHOD` | Tab the login page opens on when both email and SSO are available. Any other value falls back to `email`. | `email` | `email`, `sso` | + +The login page also remembers the method last used in that browser, which wins +over this default. Anything unrecognised falls back to `email`. + ### Header Authentication (Reverse Proxy SSO) | Variable | Description | Default | Options | diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index d7a3ac3..f698629 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { useAuth } from '@/hooks/useAuth'; @@ -13,6 +13,13 @@ import { toast, Toaster } from 'sonner'; import { showErrorToast } from '@/lib/utils'; import { Loader2, Mail, Globe, Eye, EyeOff } from 'lucide-react'; import { withBase } from '@/lib/base-path'; +import { + DEFAULT_AUTH_METHOD, + getLastAuthMethod, + resolveInitialAuthMethod, + setLastAuthMethod, + type AuthMethod, +} from '@/lib/utils/auth-method'; export function LoginForm() { @@ -21,13 +28,27 @@ export function LoginForm() { const [ssoEmail, setSsoEmail] = useState(''); const { login } = useAuth(); const { authMethods, isLoading: isLoadingMethods } = useAuthMethods(); + const [activeTab, setActiveTab] = useState(DEFAULT_AUTH_METHOD); + // The tab is only resolved once, so a manual switch is not overridden. + const hasResolvedTab = useRef(false); - // Determine which tab to show by default - const getDefaultTab = () => { - if (authMethods.emailPassword) return 'email'; - if (authMethods.sso.enabled) return 'sso'; - return 'email'; // fallback - }; + // Which tab to open on: the method this browser last used, else the server + // default (AUTH_DEFAULT_METHOD), else whichever method is available. This + // runs once the available methods are known, before the tabs are rendered. + useEffect(() => { + if (isLoadingMethods || hasResolvedTab.current) return; + hasResolvedTab.current = true; + setActiveTab( + resolveInitialAuthMethod({ + remembered: getLastAuthMethod(), + serverDefault: authMethods.defaultMethod, + available: { + email: authMethods.emailPassword, + sso: authMethods.sso.enabled, + }, + }) + ); + }, [isLoadingMethods, authMethods]); async function handleLogin(e: React.FormEvent) { e.preventDefault(); @@ -45,6 +66,7 @@ export function LoginForm() { try { await login(email, password); + setLastAuthMethod('email'); toast.success('Login successful!'); // Small delay before redirecting to see the success message setTimeout(() => { @@ -65,6 +87,8 @@ export function LoginForm() { return; } + setLastAuthMethod('sso'); + const callbackURL = typeof window !== 'undefined' ? new URL(withBase('/'), window.location.origin).toString() @@ -119,7 +143,11 @@ export function LoginForm() { <> {/* Show tabs only if multiple auth methods are available */} {authMethods.sso.enabled && authMethods.emailPassword ? ( - + setActiveTab(value as AuthMethod)} + className="w-full" + > diff --git a/src/hooks/useAuthMethods.ts b/src/hooks/useAuthMethods.ts index 273fc1b..4f8a7fb 100644 --- a/src/hooks/useAuthMethods.ts +++ b/src/hooks/useAuthMethods.ts @@ -1,5 +1,10 @@ import { useState, useEffect } from 'react'; import { apiRequest } from '@/lib/utils'; +import { + DEFAULT_AUTH_METHOD, + parseDefaultAuthMethod, + type AuthMethod, +} from '@/lib/utils/auth-method'; interface AuthMethods { emailPassword: boolean; @@ -14,6 +19,8 @@ interface AuthMethods { oidc: { enabled: boolean; }; + /** Instance default from AUTH_DEFAULT_METHOD, used when nothing is remembered. */ + defaultMethod: AuthMethod; } export function useAuthMethods() { @@ -26,6 +33,7 @@ export function useAuthMethods() { oidc: { enabled: false, }, + defaultMethod: DEFAULT_AUTH_METHOD, }); const [isLoading, setIsLoading] = useState(true); @@ -35,9 +43,9 @@ export function useAuthMethods() { const loadAuthMethods = async () => { try { - // Check SSO providers - use public endpoint since this is used on login page - const providers = await apiRequest('/sso/providers/public').catch(() => []); - const applications = await apiRequest('/sso/applications').catch(() => []); + // Public endpoint: the login page calls it before anyone is signed in. + const methods = await apiRequest>('/auth/methods'); + const providers = methods.sso?.providers ?? []; setAuthMethods({ emailPassword: true, // Always enabled @@ -50,8 +58,9 @@ export function useAuthMethods() { })), }, oidc: { - enabled: applications.length > 0, + enabled: methods.oidc?.enabled === true, }, + defaultMethod: parseDefaultAuthMethod(methods.defaultMethod), }); } catch (error) { // If we can't load auth methods, default to email/password only @@ -62,4 +71,4 @@ export function useAuthMethods() { }; return { authMethods, isLoading }; -} \ No newline at end of file +} diff --git a/src/lib/utils/auth-method.test.ts b/src/lib/utils/auth-method.test.ts new file mode 100644 index 0000000..99ffa3c --- /dev/null +++ b/src/lib/utils/auth-method.test.ts @@ -0,0 +1,176 @@ +import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test"; +import { + LAST_AUTH_METHOD_STORAGE_KEY, + isAuthMethod, + parseDefaultAuthMethod, + resolveInitialAuthMethod, + getLastAuthMethod, + setLastAuthMethod, +} from "./auth-method"; + +const BOTH = { email: true, sso: true }; +const EMAIL_ONLY = { email: true, sso: false }; +const SSO_ONLY = { email: false, sso: true }; + +describe("parseDefaultAuthMethod", () => { + test("accepts the two known values", () => { + expect(parseDefaultAuthMethod("email")).toBe("email"); + expect(parseDefaultAuthMethod("sso")).toBe("sso"); + }); + + test("ignores case and surrounding whitespace", () => { + expect(parseDefaultAuthMethod(" SSO ")).toBe("sso"); + expect(parseDefaultAuthMethod("Email")).toBe("email"); + }); + + test("falls back to email for anything else", () => { + expect(parseDefaultAuthMethod(undefined)).toBe("email"); + expect(parseDefaultAuthMethod(null)).toBe("email"); + expect(parseDefaultAuthMethod("")).toBe("email"); + expect(parseDefaultAuthMethod("oidc")).toBe("email"); + expect(parseDefaultAuthMethod(42)).toBe("email"); + }); +}); + +describe("isAuthMethod", () => { + test("recognises only the two methods", () => { + expect(isAuthMethod("email")).toBe(true); + expect(isAuthMethod("sso")).toBe(true); + expect(isAuthMethod("saml")).toBe(false); + expect(isAuthMethod(null)).toBe(false); + }); +}); + +describe("resolveInitialAuthMethod", () => { + test("uses the remembered method when it is available", () => { + expect( + resolveInitialAuthMethod({ + remembered: "sso", + serverDefault: "email", + available: BOTH, + }) + ).toBe("sso"); + expect( + resolveInitialAuthMethod({ + remembered: "email", + serverDefault: "sso", + available: BOTH, + }) + ).toBe("email"); + }); + + test("ignores a remembered method that is no longer available", () => { + expect( + resolveInitialAuthMethod({ + remembered: "sso", + serverDefault: "email", + available: EMAIL_ONLY, + }) + ).toBe("email"); + }); + + test("uses the server default when nothing is remembered", () => { + expect( + resolveInitialAuthMethod({ serverDefault: "sso", available: BOTH }) + ).toBe("sso"); + expect( + resolveInitialAuthMethod({ + remembered: null, + serverDefault: "sso", + available: BOTH, + }) + ).toBe("sso"); + }); + + test("falls back to email when the server default is unavailable", () => { + expect( + resolveInitialAuthMethod({ serverDefault: "sso", available: EMAIL_ONLY }) + ).toBe("email"); + }); + + test("falls back to sso when email is unavailable", () => { + expect( + resolveInitialAuthMethod({ serverDefault: "email", available: SSO_ONLY }) + ).toBe("sso"); + expect( + resolveInitialAuthMethod({ + remembered: "email", + serverDefault: "email", + available: SSO_ONLY, + }) + ).toBe("sso"); + }); + + test("defaults to email when no server default is given", () => { + expect(resolveInitialAuthMethod({ available: BOTH })).toBe("email"); + }); +}); + +// The Bun test runtime has no DOM. Install a minimal localStorage shim for the +// storage tests and remove it afterwards. +const g = globalThis as any; +const createdLocalStorage = typeof g.localStorage === "undefined"; + +beforeAll(() => { + if (createdLocalStorage) { + const store = new Map(); + g.localStorage = { + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + }; + } +}); + +afterAll(() => { + if (createdLocalStorage) { + delete g.localStorage; + } +}); + +describe("last auth method storage", () => { + beforeEach(() => { + g.localStorage.clear(); + }); + + test("round trips a stored method", () => { + setLastAuthMethod("sso"); + expect(getLastAuthMethod()).toBe("sso"); + setLastAuthMethod("email"); + expect(getLastAuthMethod()).toBe("email"); + }); + + test("returns null when nothing is stored", () => { + expect(getLastAuthMethod()).toBeNull(); + }); + + test("ignores a bogus stored value", () => { + g.localStorage.setItem(LAST_AUTH_METHOD_STORAGE_KEY, "carrier-pigeon"); + expect(getLastAuthMethod()).toBeNull(); + }); + + test("swallows storage errors", () => { + const original = g.localStorage; + g.localStorage = { + getItem: () => { + throw new Error("storage disabled"); + }, + setItem: () => { + throw new Error("storage disabled"); + }, + }; + try { + expect(getLastAuthMethod()).toBeNull(); + expect(() => setLastAuthMethod("sso")).not.toThrow(); + } finally { + g.localStorage = original; + } + }); +}); diff --git a/src/lib/utils/auth-method.ts b/src/lib/utils/auth-method.ts new file mode 100644 index 0000000..ff019d6 --- /dev/null +++ b/src/lib/utils/auth-method.ts @@ -0,0 +1,85 @@ +/** + * Login method selection: which tab the login page opens on. + * + * Three inputs decide it: + * - The method this browser last signed in with (localStorage, per browser). + * - The server default from the AUTH_DEFAULT_METHOD environment variable, + * served by GET /api/auth/methods. + * - Which methods the instance actually offers right now. + * + * The parsing and resolution helpers are pure so they can run on the server + * (the API route) and in tests; only the storage helpers touch the browser. + */ + +export type AuthMethod = "email" | "sso"; + +/** Used whenever nothing else applies, and for an unrecognised env value. */ +export const DEFAULT_AUTH_METHOD: AuthMethod = "email"; + +export const LAST_AUTH_METHOD_STORAGE_KEY = "lastAuthMethod"; + +export function isAuthMethod(value: unknown): value is AuthMethod { + return value === "email" || value === "sso"; +} + +/** + * Read AUTH_DEFAULT_METHOD (or any other source). Anything that is not + * "email" or "sso", case and whitespace insensitive, falls back to "email". + */ +export function parseDefaultAuthMethod(value: unknown): AuthMethod { + if (typeof value !== "string") return DEFAULT_AUTH_METHOD; + const normalized = value.trim().toLowerCase(); + return isAuthMethod(normalized) ? normalized : DEFAULT_AUTH_METHOD; +} + +export interface AvailableAuthMethods { + email: boolean; + sso: boolean; +} + +export interface ResolveInitialAuthMethodInput { + /** What this browser used last, or null when nothing is stored. */ + remembered?: AuthMethod | null; + /** The instance default from the server. */ + serverDefault?: AuthMethod; + available: AvailableAuthMethods; +} + +/** + * Pick the method the login page opens on: the remembered one if it is still + * available, else the server default if it is available, else email, else SSO. + */ +export function resolveInitialAuthMethod({ + remembered, + serverDefault = DEFAULT_AUTH_METHOD, + available, +}: ResolveInitialAuthMethodInput): AuthMethod { + if (remembered && available[remembered]) return remembered; + if (available[serverDefault]) return serverDefault; + if (available.email) return "email"; + return "sso"; +} + +/** + * Read the method this browser last signed in with. Safe during SSR and when + * storage is unavailable (private window, blocked site data): returns null. + */ +export function getLastAuthMethod(): AuthMethod | null { + if (typeof localStorage === "undefined") return null; + try { + const stored = localStorage.getItem(LAST_AUTH_METHOD_STORAGE_KEY); + return isAuthMethod(stored) ? stored : null; + } catch { + return null; + } +} + +/** Remember the method that was just used. Best effort, never throws. */ +export function setLastAuthMethod(method: AuthMethod): void { + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(LAST_AUTH_METHOD_STORAGE_KEY, method); + } catch { + // Storage disabled or full; the login page just falls back to the default. + } +} diff --git a/src/pages/api/auth/methods.ts b/src/pages/api/auth/methods.ts new file mode 100644 index 0000000..331e451 --- /dev/null +++ b/src/pages/api/auth/methods.ts @@ -0,0 +1,43 @@ +import type { APIRoute } from "astro"; +import { db, ssoProviders } from "@/lib/db"; +import { createSecureErrorResponse } from "@/lib/utils"; +import { parseDefaultAuthMethod } from "@/lib/utils/auth-method"; + +// GET /api/auth/methods - which sign-in methods this instance offers, and +// which one the login page should open on. Public: the login page calls it +// before anyone is signed in, so it only exposes what a login form needs. +export const GET: APIRoute = async () => { + try { + // Same public projection as /api/sso/providers/public. + const providers = await db + .select({ + id: ssoProviders.id, + providerId: ssoProviders.providerId, + domain: ssoProviders.domain, + }) + .from(ssoProviders); + + return new Response( + JSON.stringify({ + // Email and password sign-in is always available. + emailPassword: true, + sso: { + enabled: providers.length > 0, + providers, + }, + // Registered OAuth applications are not a login method for this + // page and are not disclosed to anonymous callers. + oidc: { + enabled: false, + }, + defaultMethod: parseDefaultAuthMethod(process.env.AUTH_DEFAULT_METHOD), + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } catch (error) { + return createSecureErrorResponse(error, "Auth methods API"); + } +}; diff --git a/www/src/pages/docs/environment-variables.mdx b/www/src/pages/docs/environment-variables.mdx index 6678e7e..cbcac30 100644 --- a/www/src/pages/docs/environment-variables.mdx +++ b/www/src/pages/docs/environment-variables.mdx @@ -42,6 +42,7 @@ Variables fall into two groups. | `BETTER_AUTH_TRUSTED_ORIGINS` | none | Comma-separated additional origins allowed to make auth requests. The origin of `BETTER_AUTH_URL` is always trusted. | | `BETTER_AUTH_LOG_LEVEL` | `warn` | Better Auth logger verbosity: `debug`, `info`, `warn`, or `error`. Use `debug` to trace SSO and OIDC sign-in problems. Better Auth does not read the `DEBUG` variable. | | `ENCRYPTION_SECRET` | none | Key for AES-256-GCM encryption of stored tokens. Generate with `openssl rand -base64 48`. | +| `AUTH_DEFAULT_METHOD` | `email` | Tab the login page opens on when both email and SSO are available: `email` or `sso`. The method last used in that browser wins over this default. | In Docker, `BETTER_AUTH_SECRET` and `ENCRYPTION_SECRET` are generated on first boot if unset and saved to `/app/data/.better_auth_secret` and `/app/data/.encryption_secret` so they persist across restarts. Outside Docker there is no fallback: a missing `BETTER_AUTH_SECRET` logs a security warning in production and uses a known insecure development value.