diff --git a/.env.example b/.env.example index 3d5dae7..017368e 100644 --- a/.env.example +++ b/.env.example @@ -116,10 +116,14 @@ CACHE_DIR=/app/data/cache BETTER_AUTH_URL=http://localhost:3000 # Public URL of the web app (used for cookie security + trusted origins) FRONTEND_URL= # Set if different from BETTER_AUTH_URL (e.g. custom domain) AUTH_SECRET=change-me-in-development +# Leave blank to hide "Sign in with GitHub" GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= +# Leave blank to hide "Sign in with Google" GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# Set to false to disable self-service account creation +ALLOW_REGISTRATION=true # RS256 JWT keys (cross-service auth: app → backend) # Generate with: diff --git a/web/.env.example b/web/.env.example index 4d6a24f..e0135dd 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,17 +1,16 @@ # NextAuth.js AUTH_SECRET=change-me-in-development # Generate for production with: openssl rand -base64 32 -# GitHub OAuth App -GITHUB_CLIENT_ID=your-github-client-id -GITHUB_CLIENT_SECRET=your-github-client-secret +# GitHub OAuth App — leave blank to hide the "Sign in with GitHub" button +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= -# Google OAuth App -GOOGLE_CLIENT_ID=your-google-client-id -GOOGLE_CLIENT_SECRET=your-google-client-secret +# Google OAuth App — leave blank to hide the "Sign in with Google" button +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= -# Login page — show OAuth buttons (omit to show credentials-only) -NEXT_PUBLIC_SHOW_GITHUB=true -NEXT_PUBLIC_SHOW_GOOGLE=true +# Set to false to disable self-service account creation (UI toggle and API both blocked) +ALLOW_REGISTRATION=true # Atlassian OAuth 2.0 + PKCE — client-side only, no secret required. # Register an OAuth 2.0 (3LO) app at https://developer.atlassian.com/console/myapps/ diff --git a/web/src/app/api/auth/[...all]/__tests__/route.test.ts b/web/src/app/api/auth/[...all]/__tests__/route.test.ts new file mode 100644 index 0000000..890c5d6 --- /dev/null +++ b/web/src/app/api/auth/[...all]/__tests__/route.test.ts @@ -0,0 +1,69 @@ +// Mock Better-Auth and the auth instance so the route can be imported without DB setup. +jest.mock('@/lib/auth', () => ({ auth: {} })); +jest.mock('better-auth/next-js', () => ({ + toNextJsHandler: () => ({ + GET: jest.fn().mockResolvedValue(new Response('ok', { status: 200 })), + POST: jest.fn().mockResolvedValue(new Response('ok', { status: 200 })), + }), +})); + +import { NextRequest } from 'next/server'; +import { POST } from '../route'; + +function makePost(pathname: string) { + return new NextRequest(`http://localhost${pathname}`, { method: 'POST' }); +} + +describe('POST /api/auth/[...all] — registration guard', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('blocks /sign-up/email with 403 when ALLOW_REGISTRATION=false', async () => { + process.env.ALLOW_REGISTRATION = 'false'; + + const res = await POST(makePost('/api/auth/sign-up/email'), undefined as never); + + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.message).toBe('Registration is disabled'); + }); + + it('passes through /sign-up/email when ALLOW_REGISTRATION is unset', async () => { + delete process.env.ALLOW_REGISTRATION; + + const res = await POST(makePost('/api/auth/sign-up/email'), undefined as never); + + expect(res.status).toBe(200); + }); + + it('passes through /sign-up/email when ALLOW_REGISTRATION=true', async () => { + process.env.ALLOW_REGISTRATION = 'true'; + + const res = await POST(makePost('/api/auth/sign-up/email'), undefined as never); + + expect(res.status).toBe(200); + }); + + it('does not block /sign-in/email regardless of ALLOW_REGISTRATION', async () => { + process.env.ALLOW_REGISTRATION = 'false'; + + const res = await POST(makePost('/api/auth/sign-in/email'), undefined as never); + + expect(res.status).toBe(200); + }); + + it('does not block paths that merely contain sign-up/email as a substring', async () => { + process.env.ALLOW_REGISTRATION = 'false'; + + const res = await POST(makePost('/api/auth/bulk-sign-up/email'), undefined as never); + + expect(res.status).toBe(200); + }); +}); diff --git a/web/src/app/api/auth/[...all]/route.ts b/web/src/app/api/auth/[...all]/route.ts index 9900a3b..3cb2781 100644 --- a/web/src/app/api/auth/[...all]/route.ts +++ b/web/src/app/api/auth/[...all]/route.ts @@ -1,4 +1,16 @@ import { auth } from '@/lib/auth'; import { toNextJsHandler } from 'better-auth/next-js'; +import { NextRequest, NextResponse } from 'next/server'; -export const { GET, POST } = toNextJsHandler(auth); +const handlers = toNextJsHandler(auth); + +export const GET = handlers.GET; + +// Next.js requires the context param for catch-all routes; Better-Auth routes by URL so we don't forward it. +export async function POST(request: NextRequest, _context: { params: Promise<{ all: string[] }> }) { + const url = new URL(request.url); + if (process.env.ALLOW_REGISTRATION === 'false' && url.pathname === '/api/auth/sign-up/email') { + return NextResponse.json({ message: 'Registration is disabled' }, { status: 403 }); + } + return handlers.POST(request); +} diff --git a/web/src/app/api/auth/config/__tests__/route.test.ts b/web/src/app/api/auth/config/__tests__/route.test.ts new file mode 100644 index 0000000..99042bc --- /dev/null +++ b/web/src/app/api/auth/config/__tests__/route.test.ts @@ -0,0 +1,80 @@ +import { GET } from '../route'; + +describe('GET /api/auth/config', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('returns github=true when both GitHub secrets are set', async () => { + process.env.GITHUB_CLIENT_ID = 'id'; + process.env.GITHUB_CLIENT_SECRET = 'secret'; + delete process.env.GOOGLE_CLIENT_ID; + delete process.env.GOOGLE_CLIENT_SECRET; + + const res = await GET(); + const body = await res.json(); + + expect(body.github).toBe(true); + expect(body.google).toBe(false); + }); + + it('returns github=false when only client ID is set (no secret)', async () => { + process.env.GITHUB_CLIENT_ID = 'id'; + delete process.env.GITHUB_CLIENT_SECRET; + + const res = await GET(); + const body = await res.json(); + + expect(body.github).toBe(false); + }); + + it('returns google=true when both Google secrets are set', async () => { + delete process.env.GITHUB_CLIENT_ID; + process.env.GOOGLE_CLIENT_ID = 'gid'; + process.env.GOOGLE_CLIENT_SECRET = 'gsecret'; + + const res = await GET(); + const body = await res.json(); + + expect(body.google).toBe(true); + expect(body.github).toBe(false); + }); + + it('registrationEnabled defaults to true when ALLOW_REGISTRATION is unset', async () => { + delete process.env.ALLOW_REGISTRATION; + + const res = await GET(); + const body = await res.json(); + + expect(body.registrationEnabled).toBe(true); + }); + + it('registrationEnabled is false when ALLOW_REGISTRATION=false', async () => { + process.env.ALLOW_REGISTRATION = 'false'; + + const res = await GET(); + const body = await res.json(); + + expect(body.registrationEnabled).toBe(false); + }); + + it('registrationEnabled is true when ALLOW_REGISTRATION=true', async () => { + process.env.ALLOW_REGISTRATION = 'true'; + + const res = await GET(); + const body = await res.json(); + + expect(body.registrationEnabled).toBe(true); + }); + + it('includes Cache-Control header', async () => { + const res = await GET(); + expect(res.headers.get('Cache-Control')).toBe('public, max-age=300, stale-while-revalidate=60'); + }); +}); diff --git a/web/src/app/api/auth/config/route.ts b/web/src/app/api/auth/config/route.ts new file mode 100644 index 0000000..4244f1a --- /dev/null +++ b/web/src/app/api/auth/config/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json( + { + github: !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET), + google: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET), + registrationEnabled: process.env.ALLOW_REGISTRATION !== 'false', + }, + { headers: { 'Cache-Control': 'public, max-age=300, stale-while-revalidate=60' } }, + ); +} diff --git a/web/src/app/api/auth/register/route.ts b/web/src/app/api/auth/register/route.ts index 2f4981c..19da03d 100644 --- a/web/src/app/api/auth/register/route.ts +++ b/web/src/app/api/auth/register/route.ts @@ -2,6 +2,10 @@ import { NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; export async function POST(request: Request) { + if (process.env.ALLOW_REGISTRATION === 'false') { + return NextResponse.json({ error: 'Registration is disabled' }, { status: 403 }); + } + let body: { username?: string; password?: string; email?: string }; try { body = await request.json(); diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 30252d2..67ed1d3 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useSearchParams } from 'next/navigation'; -import { FormEvent, Suspense, useCallback, useState } from 'react'; +import { FormEvent, Suspense, useCallback, useEffect, useState } from 'react'; import { Box, Button, @@ -17,9 +17,6 @@ import GoogleIcon from '@mui/icons-material/Google'; import { createAppTheme } from '../../spa/theme'; import { useThemeMode } from '../../spa/hooks/useThemeMode'; -const showGithub = process.env.NEXT_PUBLIC_SHOW_GITHUB !== 'false'; -const showGoogle = process.env.NEXT_PUBLIC_SHOW_GOOGLE !== 'false'; - function LoginForm() { const searchParams = useSearchParams(); const error = searchParams.get('error'); @@ -31,6 +28,13 @@ function LoginForm() { ? rawCallback : '/'; + // Optimistic defaults: registration visible immediately (avoid layout shift on load), + // OAuth buttons hidden until confirmed (they can't be guessed server-side). + const [authConfig, setAuthConfig] = useState({ + github: false, + google: false, + registrationEnabled: true, + }); const [mode, setMode] = useState<'login' | 'register'>('login'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -38,6 +42,28 @@ function LoginForm() { const [formError, setFormError] = useState(null); const [loading, setLoading] = useState(false); + useEffect(() => { + let mounted = true; + fetch('/api/auth/config') + .then((r) => { + if (!r.ok) return undefined; + return r.json(); + }) + .then((cfg) => { + if (!mounted || !cfg) return; + setAuthConfig({ + github: !!cfg.github, + google: !!cfg.google, + registrationEnabled: cfg.registrationEnabled !== false, + }); + if (!cfg.registrationEnabled) setMode('login'); + }) + .catch(() => {/* keep optimistic defaults on network error */}); + return () => { + mounted = false; + }; + }, []); + const handleCredentialsLogin = useCallback( async (e: FormEvent) => { e.preventDefault(); @@ -198,19 +224,21 @@ function LoginForm() { - + {authConfig.registrationEnabled && ( + + )} - {(showGithub || showGoogle) && ( + {(authConfig.github || authConfig.google) && ( <> @@ -218,7 +246,7 @@ function LoginForm() { - {showGithub && ( + {authConfig.github && (