Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 8 additions & 9 deletions web/.env.example
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
69 changes: 69 additions & 0 deletions web/src/app/api/auth/[...all]/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 13 additions & 1 deletion web/src/app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Comment on lines +11 to +14
return handlers.POST(request);
}
80 changes: 80 additions & 0 deletions web/src/app/api/auth/config/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
12 changes: 12 additions & 0 deletions web/src/app/api/auth/config/route.ts
Original file line number Diff line number Diff line change
@@ -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' } },
);
}
4 changes: 4 additions & 0 deletions web/src/app/api/auth/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
64 changes: 46 additions & 18 deletions web/src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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');
Expand All @@ -31,13 +28,42 @@ 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('');
const [name, setName] = useState('');
const [formError, setFormError] = useState<string | null>(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 */});
Comment on lines +47 to +61
return () => {
mounted = false;
};
}, []);

const handleCredentialsLogin = useCallback(
async (e: FormEvent) => {
e.preventDefault();
Expand Down Expand Up @@ -198,27 +224,29 @@ function LoginForm() {
</Button>
</Box>

<Button
variant="text"
size="small"
onClick={() => {
setMode(mode === 'login' ? 'register' : 'login');
setFormError(null);
}}
sx={{ textTransform: 'none' }}
>
{mode === 'login' ? 'Create an account' : 'Already have an account? Sign in'}
</Button>
{authConfig.registrationEnabled && (
<Button
variant="text"
size="small"
onClick={() => {
setMode(mode === 'login' ? 'register' : 'login');
setFormError(null);
}}
sx={{ textTransform: 'none' }}
>
{mode === 'login' ? 'Create an account' : 'Already have an account? Sign in'}
</Button>
)}

{(showGithub || showGoogle) && (
{(authConfig.github || authConfig.google) && (
<>
<Divider sx={{ width: '100%' }}>
<Typography variant="body2" color="text.secondary">
or
</Typography>
</Divider>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, width: '100%' }}>
{showGithub && (
{authConfig.github && (
<Button
variant="outlined"
size="large"
Expand All @@ -235,7 +263,7 @@ function LoginForm() {
Sign in with GitHub
</Button>
)}
{showGoogle && (
{authConfig.google && (
<Button
variant="outlined"
size="large"
Expand Down
Loading