diff --git a/.env.example b/.env.example index df58460..216d86e 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,7 @@ API_KEY=api_key_optional # Auth configuration (recommended for contributors and public instances) # (Optional) Auth is only enabled when AUTH_SECRET and BASE_URL are set BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) -AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -base64 32` +AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -hex 32` AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted) # (Optional) Allow anonymous auth sessions when auth is enabled (default: `false`) USE_ANONYMOUS_AUTH_SESSIONS= diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 36d5fc4..cc1c0c5 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -81,7 +81,7 @@ cp .env.example .env Then edit `.env`. - No auth mode: leave `BASE_URL` or `AUTH_SECRET` unset. -- Auth enabled mode: set both `BASE_URL` (typically `http://localhost:3003`) and `AUTH_SECRET` (generate with `openssl rand -base64 32`). +- Auth enabled mode: set both `BASE_URL` (typically `http://localhost:3003`) and `AUTH_SECRET` (generate with `openssl rand -hex 32`). Optional: diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index dd21287..c08d7fb 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -44,7 +44,7 @@ docker run --name openreader-webui \ -e API_BASE=http://host.docker.internal:8880/v1 \ -e API_KEY=none \ -e BASE_URL=http://localhost:3003 \ - -e AUTH_SECRET=$(openssl rand -base64 32) \ + -e AUTH_SECRET=$(openssl rand -hex 32) \ ghcr.io/richardr1126/openreader-webui:latest ``` diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 59044bb..da227d7 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -188,7 +188,7 @@ External base URL for this OpenReader instance. Secret key used by auth/session handling. - Required with `BASE_URL` to enable auth -- Generate with `openssl rand -base64 32` +- Generate with `openssl rand -hex 32` - Related docs: [Auth](../configure/auth) ### AUTH_TRUSTED_ORIGINS diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index b07c0c4..fe10418 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -4,7 +4,7 @@ import { Toaster } from 'react-hot-toast'; import { Providers } from '@/app/providers'; import ClaimDataPopup from '@/components/auth/ClaimDataModal'; -import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled } from '@/lib/server/auth-config'; +import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth-config'; export const dynamic = 'force-dynamic'; @@ -26,12 +26,14 @@ export default function AppLayout({ children }: { children: ReactNode }) { const authEnabled = isAuthEnabled(); const authBaseUrl = getAuthBaseUrl(); const allowAnonymousAuthSessions = isAnonymousAuthSessionsEnabled(); + const githubAuthEnabled = isGithubAuthEnabled(); return (
{authEnabled && } diff --git a/src/app/(app)/signin/page.tsx b/src/app/(app)/signin/page.tsx index c1a9962..21532e2 100644 --- a/src/app/(app)/signin/page.tsx +++ b/src/app/(app)/signin/page.tsx @@ -29,7 +29,7 @@ function SignInContent() { const [rememberMe, setRememberMe] = useState(true); const [sessionExpired, setSessionExpired] = useState(false); const [error, setError] = useState(null); - const { authEnabled, baseUrl, allowAnonymousAuthSessions } = useAuthConfig(); + const { authEnabled, baseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthConfig(); const { refresh: refreshRateLimit } = useAuthRateLimit(); const isAnyLoading = loadingEmail || loadingGithub || loadingAnonymous; @@ -205,6 +205,7 @@ function SignInContent() { {/* GitHub */} + {githubAuthEnabled && ( + )} {/* Anonymous */} {allowAnonymousAuthSessions && ( diff --git a/src/app/providers.tsx b/src/app/providers.tsx index d75257d..f6f61ca 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -20,9 +20,10 @@ interface ProvidersProps { authEnabled: boolean; authBaseUrl: string | null; allowAnonymousAuthSessions: boolean; + githubAuthEnabled: boolean; } -export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAuthSessions }: ProvidersProps) { +export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled }: ProvidersProps) { const pathname = usePathname(); const isAuthPage = pathname?.startsWith('/signin') || pathname?.startsWith('/signup'); @@ -32,6 +33,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu authEnabled={authEnabled} authBaseUrl={authBaseUrl} allowAnonymousAuthSessions={allowAnonymousAuthSessions} + githubAuthEnabled={githubAuthEnabled} > @@ -50,6 +52,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu authEnabled={authEnabled} authBaseUrl={authBaseUrl} allowAnonymousAuthSessions={allowAnonymousAuthSessions} + githubAuthEnabled={githubAuthEnabled} > diff --git a/src/contexts/AuthRateLimitContext.tsx b/src/contexts/AuthRateLimitContext.tsx index bc8955b..211f40c 100644 --- a/src/contexts/AuthRateLimitContext.tsx +++ b/src/contexts/AuthRateLimitContext.tsx @@ -17,6 +17,7 @@ interface AuthRateLimitContextType { authEnabled: boolean; authBaseUrl: string | null; allowAnonymousAuthSessions: boolean; + githubAuthEnabled: boolean; // Rate Limit status: RateLimitStatus | null; @@ -43,8 +44,8 @@ export function useAuthRateLimit(): AuthRateLimitContextType { // Re-export specific hooks for backward compatibility or convenience if needed export function useAuthConfig() { - const { authEnabled, authBaseUrl, allowAnonymousAuthSessions } = useAuthRateLimit(); - return { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions }; + const { authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthRateLimit(); + return { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled }; } export function useRateLimit() { @@ -88,6 +89,7 @@ interface AuthRateLimitProviderProps { authEnabled: boolean; authBaseUrl: string | null; allowAnonymousAuthSessions: boolean; + githubAuthEnabled: boolean; } export function AuthRateLimitProvider({ @@ -95,6 +97,7 @@ export function AuthRateLimitProvider({ authEnabled, authBaseUrl, allowAnonymousAuthSessions, + githubAuthEnabled, }: AuthRateLimitProviderProps) { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); @@ -220,6 +223,7 @@ export function AuthRateLimitProvider({ authEnabled, authBaseUrl, allowAnonymousAuthSessions, + githubAuthEnabled, status, loading, error, diff --git a/src/lib/server/auth-config.ts b/src/lib/server/auth-config.ts index bbd0dc6..0d70370 100644 --- a/src/lib/server/auth-config.ts +++ b/src/lib/server/auth-config.ts @@ -25,6 +25,15 @@ export function isAnonymousAuthSessionsEnabled(): boolean { return parseBooleanEnv('USE_ANONYMOUS_AUTH_SESSIONS', false); } +/** + * GitHub sign-in is available only when auth is enabled AND + * both GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET are set. + */ +export function isGithubAuthEnabled(): boolean { + if (!isAuthEnabled()) return false; + return !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); +} + /** * Get the auth base URL if auth is enabled, otherwise null. */ diff --git a/tests/unit/auth-config.spec.ts b/tests/unit/auth-config.spec.ts index 0146332..0cc1706 100644 --- a/tests/unit/auth-config.spec.ts +++ b/tests/unit/auth-config.spec.ts @@ -1,9 +1,11 @@ import { test, expect } from '@playwright/test'; -import { isAnonymousAuthSessionsEnabled } from '../../src/lib/server/auth-config'; +import { isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '../../src/lib/server/auth-config'; const ORIGINAL_BASE_URL = process.env.BASE_URL; const ORIGINAL_AUTH_SECRET = process.env.AUTH_SECRET; const ORIGINAL_USE_ANON = process.env.USE_ANONYMOUS_AUTH_SESSIONS; +const ORIGINAL_GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID; +const ORIGINAL_GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET; function restoreEnv() { if (ORIGINAL_BASE_URL === undefined) delete process.env.BASE_URL; @@ -14,6 +16,12 @@ function restoreEnv() { if (ORIGINAL_USE_ANON === undefined) delete process.env.USE_ANONYMOUS_AUTH_SESSIONS; else process.env.USE_ANONYMOUS_AUTH_SESSIONS = ORIGINAL_USE_ANON; + + if (ORIGINAL_GITHUB_CLIENT_ID === undefined) delete process.env.GITHUB_CLIENT_ID; + else process.env.GITHUB_CLIENT_ID = ORIGINAL_GITHUB_CLIENT_ID; + + if (ORIGINAL_GITHUB_CLIENT_SECRET === undefined) delete process.env.GITHUB_CLIENT_SECRET; + else process.env.GITHUB_CLIENT_SECRET = ORIGINAL_GITHUB_CLIENT_SECRET; } function setAuthEnabledEnv() { @@ -62,3 +70,50 @@ test.describe('auth config anonymous-session flag', () => { expect(isAnonymousAuthSessionsEnabled()).toBe(false); }); }); + +test.describe('auth config github-auth flag', () => { + test.afterEach(() => { + restoreEnv(); + }); + + test('returns false when auth is disabled', () => { + delete process.env.BASE_URL; + delete process.env.AUTH_SECRET; + process.env.GITHUB_CLIENT_ID = 'some-id'; + process.env.GITHUB_CLIENT_SECRET = 'some-secret'; + + expect(isGithubAuthEnabled()).toBe(false); + }); + + test('returns false when GITHUB_CLIENT_ID is missing', () => { + setAuthEnabledEnv(); + delete process.env.GITHUB_CLIENT_ID; + process.env.GITHUB_CLIENT_SECRET = 'some-secret'; + + expect(isGithubAuthEnabled()).toBe(false); + }); + + test('returns false when GITHUB_CLIENT_SECRET is missing', () => { + setAuthEnabledEnv(); + process.env.GITHUB_CLIENT_ID = 'some-id'; + delete process.env.GITHUB_CLIENT_SECRET; + + expect(isGithubAuthEnabled()).toBe(false); + }); + + test('returns false when both GitHub env vars are missing', () => { + setAuthEnabledEnv(); + delete process.env.GITHUB_CLIENT_ID; + delete process.env.GITHUB_CLIENT_SECRET; + + expect(isGithubAuthEnabled()).toBe(false); + }); + + test('returns true when auth is enabled and both GitHub env vars are set', () => { + setAuthEnabledEnv(); + process.env.GITHUB_CLIENT_ID = 'some-id'; + process.env.GITHUB_CLIENT_SECRET = 'some-secret'; + + expect(isGithubAuthEnabled()).toBe(true); + }); +});