feat(auth): add conditional GitHub authentication support

Implement a check for GitHub OAuth credentials to conditionally render
the GitHub sign-in button. This ensures the UI accurately reflects
available authentication methods based on environment configuration.

- Add `isGithubAuthEnabled` utility to verify server-side credentials
- Propagate GitHub auth status through context providers to the UI
- Conditionally display the GitHub sign-in button in the sign-in page
- Update documentation and examples to use hex encoding for `AUTH_SECRET`
- Add unit tests for the new GitHub authentication configuration logic
This commit is contained in:
Richard R 2026-02-16 14:21:25 -07:00
parent 94d24c4687
commit deaacbd6f0
10 changed files with 85 additions and 10 deletions

View file

@ -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=

View file

@ -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:

View file

@ -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
```

View file

@ -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

View file

@ -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 (
<Providers
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled}
>
<div className="app-shell min-h-screen flex flex-col bg-background">
{authEnabled && <ClaimDataPopup />}

View file

@ -29,7 +29,7 @@ function SignInContent() {
const [rememberMe, setRememberMe] = useState(true);
const [sessionExpired, setSessionExpired] = useState(false);
const [error, setError] = useState<string | null>(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() {
</Button>
{/* GitHub */}
{githubAuthEnabled && (
<Button
type="button"
disabled={isAnyLoading}
@ -224,6 +225,7 @@ function SignInContent() {
</>
)}
</Button>
)}
{/* Anonymous */}
{allowAnonymousAuthSessions && (

View file

@ -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}
>
<ThemeProvider>
<AuthLoader>
@ -50,6 +52,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled}
>
<ThemeProvider>
<AuthLoader>

View file

@ -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<RateLimitStatus | null>(null);
const [loading, setLoading] = useState(true);
@ -220,6 +223,7 @@ export function AuthRateLimitProvider({
authEnabled,
authBaseUrl,
allowAnonymousAuthSessions,
githubAuthEnabled,
status,
loading,
error,

View file

@ -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.
*/

View file

@ -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);
});
});