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)
|
# Auth configuration (recommended for contributors and public instances)
|
||||||
# (Optional) Auth is only enabled when AUTH_SECRET and BASE_URL are set
|
# (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)
|
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)
|
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`)
|
# (Optional) Allow anonymous auth sessions when auth is enabled (default: `false`)
|
||||||
USE_ANONYMOUS_AUTH_SESSIONS=
|
USE_ANONYMOUS_AUTH_SESSIONS=
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ cp .env.example .env
|
||||||
Then edit `.env`.
|
Then edit `.env`.
|
||||||
|
|
||||||
- No auth mode: leave `BASE_URL` or `AUTH_SECRET` unset.
|
- 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:
|
Optional:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ docker run --name openreader-webui \
|
||||||
-e API_BASE=http://host.docker.internal:8880/v1 \
|
-e API_BASE=http://host.docker.internal:8880/v1 \
|
||||||
-e API_KEY=none \
|
-e API_KEY=none \
|
||||||
-e BASE_URL=http://localhost:3003 \
|
-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
|
ghcr.io/richardr1126/openreader-webui:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ External base URL for this OpenReader instance.
|
||||||
Secret key used by auth/session handling.
|
Secret key used by auth/session handling.
|
||||||
|
|
||||||
- Required with `BASE_URL` to enable auth
|
- Required with `BASE_URL` to enable auth
|
||||||
- Generate with `openssl rand -base64 32`
|
- Generate with `openssl rand -hex 32`
|
||||||
- Related docs: [Auth](../configure/auth)
|
- Related docs: [Auth](../configure/auth)
|
||||||
|
|
||||||
### AUTH_TRUSTED_ORIGINS
|
### AUTH_TRUSTED_ORIGINS
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
import { Providers } from '@/app/providers';
|
import { Providers } from '@/app/providers';
|
||||||
import ClaimDataPopup from '@/components/auth/ClaimDataModal';
|
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';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -26,12 +26,14 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
const authEnabled = isAuthEnabled();
|
const authEnabled = isAuthEnabled();
|
||||||
const authBaseUrl = getAuthBaseUrl();
|
const authBaseUrl = getAuthBaseUrl();
|
||||||
const allowAnonymousAuthSessions = isAnonymousAuthSessionsEnabled();
|
const allowAnonymousAuthSessions = isAnonymousAuthSessionsEnabled();
|
||||||
|
const githubAuthEnabled = isGithubAuthEnabled();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Providers
|
<Providers
|
||||||
authEnabled={authEnabled}
|
authEnabled={authEnabled}
|
||||||
authBaseUrl={authBaseUrl}
|
authBaseUrl={authBaseUrl}
|
||||||
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
||||||
|
githubAuthEnabled={githubAuthEnabled}
|
||||||
>
|
>
|
||||||
<div className="app-shell min-h-screen flex flex-col bg-background">
|
<div className="app-shell min-h-screen flex flex-col bg-background">
|
||||||
{authEnabled && <ClaimDataPopup />}
|
{authEnabled && <ClaimDataPopup />}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ function SignInContent() {
|
||||||
const [rememberMe, setRememberMe] = useState(true);
|
const [rememberMe, setRememberMe] = useState(true);
|
||||||
const [sessionExpired, setSessionExpired] = useState(false);
|
const [sessionExpired, setSessionExpired] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { authEnabled, baseUrl, allowAnonymousAuthSessions } = useAuthConfig();
|
const { authEnabled, baseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthConfig();
|
||||||
const { refresh: refreshRateLimit } = useAuthRateLimit();
|
const { refresh: refreshRateLimit } = useAuthRateLimit();
|
||||||
|
|
||||||
const isAnyLoading = loadingEmail || loadingGithub || loadingAnonymous;
|
const isAnyLoading = loadingEmail || loadingGithub || loadingAnonymous;
|
||||||
|
|
@ -205,6 +205,7 @@ function SignInContent() {
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* GitHub */}
|
{/* GitHub */}
|
||||||
|
{githubAuthEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isAnyLoading}
|
disabled={isAnyLoading}
|
||||||
|
|
@ -224,6 +225,7 @@ function SignInContent() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Anonymous */}
|
{/* Anonymous */}
|
||||||
{allowAnonymousAuthSessions && (
|
{allowAnonymousAuthSessions && (
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,10 @@ interface ProvidersProps {
|
||||||
authEnabled: boolean;
|
authEnabled: boolean;
|
||||||
authBaseUrl: string | null;
|
authBaseUrl: string | null;
|
||||||
allowAnonymousAuthSessions: boolean;
|
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 pathname = usePathname();
|
||||||
const isAuthPage = pathname?.startsWith('/signin') || pathname?.startsWith('/signup');
|
const isAuthPage = pathname?.startsWith('/signin') || pathname?.startsWith('/signup');
|
||||||
|
|
||||||
|
|
@ -32,6 +33,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
|
||||||
authEnabled={authEnabled}
|
authEnabled={authEnabled}
|
||||||
authBaseUrl={authBaseUrl}
|
authBaseUrl={authBaseUrl}
|
||||||
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
||||||
|
githubAuthEnabled={githubAuthEnabled}
|
||||||
>
|
>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthLoader>
|
<AuthLoader>
|
||||||
|
|
@ -50,6 +52,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
|
||||||
authEnabled={authEnabled}
|
authEnabled={authEnabled}
|
||||||
authBaseUrl={authBaseUrl}
|
authBaseUrl={authBaseUrl}
|
||||||
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
||||||
|
githubAuthEnabled={githubAuthEnabled}
|
||||||
>
|
>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthLoader>
|
<AuthLoader>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ interface AuthRateLimitContextType {
|
||||||
authEnabled: boolean;
|
authEnabled: boolean;
|
||||||
authBaseUrl: string | null;
|
authBaseUrl: string | null;
|
||||||
allowAnonymousAuthSessions: boolean;
|
allowAnonymousAuthSessions: boolean;
|
||||||
|
githubAuthEnabled: boolean;
|
||||||
|
|
||||||
// Rate Limit
|
// Rate Limit
|
||||||
status: RateLimitStatus | null;
|
status: RateLimitStatus | null;
|
||||||
|
|
@ -43,8 +44,8 @@ export function useAuthRateLimit(): AuthRateLimitContextType {
|
||||||
|
|
||||||
// Re-export specific hooks for backward compatibility or convenience if needed
|
// Re-export specific hooks for backward compatibility or convenience if needed
|
||||||
export function useAuthConfig() {
|
export function useAuthConfig() {
|
||||||
const { authEnabled, authBaseUrl, allowAnonymousAuthSessions } = useAuthRateLimit();
|
const { authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthRateLimit();
|
||||||
return { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions };
|
return { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRateLimit() {
|
export function useRateLimit() {
|
||||||
|
|
@ -88,6 +89,7 @@ interface AuthRateLimitProviderProps {
|
||||||
authEnabled: boolean;
|
authEnabled: boolean;
|
||||||
authBaseUrl: string | null;
|
authBaseUrl: string | null;
|
||||||
allowAnonymousAuthSessions: boolean;
|
allowAnonymousAuthSessions: boolean;
|
||||||
|
githubAuthEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthRateLimitProvider({
|
export function AuthRateLimitProvider({
|
||||||
|
|
@ -95,6 +97,7 @@ export function AuthRateLimitProvider({
|
||||||
authEnabled,
|
authEnabled,
|
||||||
authBaseUrl,
|
authBaseUrl,
|
||||||
allowAnonymousAuthSessions,
|
allowAnonymousAuthSessions,
|
||||||
|
githubAuthEnabled,
|
||||||
}: AuthRateLimitProviderProps) {
|
}: AuthRateLimitProviderProps) {
|
||||||
const [status, setStatus] = useState<RateLimitStatus | null>(null);
|
const [status, setStatus] = useState<RateLimitStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -220,6 +223,7 @@ export function AuthRateLimitProvider({
|
||||||
authEnabled,
|
authEnabled,
|
||||||
authBaseUrl,
|
authBaseUrl,
|
||||||
allowAnonymousAuthSessions,
|
allowAnonymousAuthSessions,
|
||||||
|
githubAuthEnabled,
|
||||||
status,
|
status,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,15 @@ export function isAnonymousAuthSessionsEnabled(): boolean {
|
||||||
return parseBooleanEnv('USE_ANONYMOUS_AUTH_SESSIONS', false);
|
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.
|
* Get the auth base URL if auth is enabled, otherwise null.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { test, expect } from '@playwright/test';
|
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_BASE_URL = process.env.BASE_URL;
|
||||||
const ORIGINAL_AUTH_SECRET = process.env.AUTH_SECRET;
|
const ORIGINAL_AUTH_SECRET = process.env.AUTH_SECRET;
|
||||||
const ORIGINAL_USE_ANON = process.env.USE_ANONYMOUS_AUTH_SESSIONS;
|
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() {
|
function restoreEnv() {
|
||||||
if (ORIGINAL_BASE_URL === undefined) delete process.env.BASE_URL;
|
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;
|
if (ORIGINAL_USE_ANON === undefined) delete process.env.USE_ANONYMOUS_AUTH_SESSIONS;
|
||||||
else process.env.USE_ANONYMOUS_AUTH_SESSIONS = ORIGINAL_USE_ANON;
|
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() {
|
function setAuthEnabledEnv() {
|
||||||
|
|
@ -62,3 +70,50 @@ test.describe('auth config anonymous-session flag', () => {
|
||||||
expect(isAnonymousAuthSessionsEnabled()).toBe(false);
|
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