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:
parent
94d24c4687
commit
deaacbd6f0
10 changed files with 85 additions and 10 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue