feat(auth): first account on a new server needs the setup token from the server log (M462 #4982)
test-go / test (push) Successful in 2m3s
test-web / test (push) Successful in 1m14s
test-go / integration (push) Successful in 4m38s
release / Build signed APK (releases and dev) (push) Successful in 5m36s
release / Build + push container image (push) Successful in 1m24s
release / Verify release artifacts (tag releases only) (push) Skipped

While no accounts exist, the server mints a random setup token at boot and
logs it. Registering the first account (which becomes admin) must carry it,
so whoever reaches a freshly exposed instance first cannot claim it. The
register page asks GET /api/auth/setup-status and shows a "Setup token"
field in place of the invite field while setup is pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:35:52 -04:00
co-authored by Claude Opus 5.5
parent 46194a609d
commit 2f3fbccab6
7 changed files with 276 additions and 16 deletions
+10
View File
@@ -47,10 +47,19 @@ export async function login(username: string, password: string): Promise<void> {
void sendTimezoneIfStale();
}
/**
* Whether the server has no accounts yet, in which case the first
* registration must carry the setup token printed in the server log.
*/
export async function getSetupStatus(): Promise<{ setup_required: boolean }> {
return api.get<{ setup_required: boolean }>('/api/auth/setup-status');
}
export async function register(opts: {
username: string;
password: string;
inviteToken?: string;
setupToken?: string;
displayName?: string;
}): Promise<void> {
const body: Record<string, string> = {
@@ -58,6 +67,7 @@ export async function register(opts: {
password: opts.password,
};
if (opts.inviteToken) body.invite_token = opts.inviteToken;
if (opts.setupToken) body.setup_token = opts.setupToken;
if (opts.displayName) body.display_name = opts.displayName;
const res = await api.post<LoginResponse>('/api/auth/register', body);
setUser(res.user);
+47 -12
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { pageTitle } from '$lib/branding';
import { goto } from '$app/navigation';
import { register } from '$lib/auth/store.svelte';
import { onMount } from 'svelte';
import { getSetupStatus, register } from '$lib/auth/store.svelte';
import { errCode } from '$lib/api/errors';
import { rateLimitMessage } from '$lib/api/client';
@@ -9,6 +10,19 @@
let password = $state('');
let confirmPassword = $state('');
let inviteToken = $state('');
let setupToken = $state('');
// True on a brand-new server: the first account becomes admin and must
// carry the setup token from the server log.
let setupRequired = $state(false);
onMount(async () => {
try {
setupRequired = (await getSetupStatus()).setup_required;
} catch {
// Unknown: leave the ordinary form. If a token turns out to be needed,
// the server says so and the error below explains where to find it.
}
});
let displayName = $state('');
let submitting = $state(false);
let error = $state<string | null>(null);
@@ -25,6 +39,8 @@
return 'That invite token is invalid, expired, or already used.';
case 'username_taken':
return 'That username is already taken.';
case 'setup_token_invalid':
return "That setup token doesn't match. Copy it from the server log; it changes each time the server restarts.";
default:
return 'Registration failed. Please try again.';
}
@@ -44,6 +60,7 @@
username,
password,
inviteToken: inviteToken || undefined,
setupToken: setupToken.trim() || undefined,
displayName: displayName || undefined,
});
await goto('/', { replaceState: true });
@@ -107,17 +124,35 @@
bind:value={confirmPassword}
/>
</label>
<label class="block">
<span class="mb-1 block text-sm text-text-secondary">
Invite token <span class="text-text-secondary opacity-60">(if required by your server)</span>
</span>
<input
type="text"
autocomplete="off"
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
bind:value={inviteToken}
/>
</label>
{#if setupRequired}
<label class="block">
<span class="mb-1 block text-sm text-text-secondary">Setup token</span>
<input
type="text"
autocomplete="off"
spellcheck="false"
required
class="w-full rounded border border-border bg-background px-3 py-2 font-mono outline-none focus:border-accent"
bind:value={setupToken}
/>
<span class="mt-1 block text-xs text-text-secondary">
This is a new server, so this account will be its admin. The setup token is printed in the
server log (look for "setup_token").
</span>
</label>
{:else}
<label class="block">
<span class="mb-1 block text-sm text-text-secondary">
Invite token <span class="text-text-secondary opacity-60">(if required by your server)</span>
</span>
<input
type="text"
autocomplete="off"
class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
bind:value={inviteToken}
/>
</label>
{/if}
<button
type="submit"
+35 -1
View File
@@ -11,15 +11,17 @@ vi.mock('$app/navigation', () => ({
vi.mock('$lib/auth/store.svelte', () => ({
register: vi.fn(),
getSetupStatus: vi.fn().mockResolvedValue({ setup_required: false }),
user: { value: null }
}));
import RegisterPage from './+page.svelte';
import { register } from '$lib/auth/store.svelte';
import { getSetupStatus, register } from '$lib/auth/store.svelte';
import { goto } from '$app/navigation';
afterEach(() => {
vi.clearAllMocks();
(getSetupStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ setup_required: false });
});
describe('/register page', () => {
@@ -115,6 +117,38 @@ describe('/register page', () => {
await waitFor(() => expect(screen.getByRole('button', { name: /create account/i })).not.toBeDisabled());
});
test('on a new server, asks for the setup token instead of an invite and sends it', async () => {
(getSetupStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ setup_required: true });
(register as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
render(RegisterPage);
const tokenField = await screen.findByLabelText(/setup token/i);
expect(screen.queryByLabelText(/invite token/i)).toBeNull();
await fireEvent.input(screen.getByLabelText(/username/i), { target: { value: 'operator' } });
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'abcd1234' } });
await fireEvent.input(screen.getByLabelText(/confirm password/i), { target: { value: 'abcd1234' } });
await fireEvent.input(tokenField, { target: { value: ' 0123abcd ' } });
await fireEvent.click(screen.getByRole('button', { name: /create account/i }));
await waitFor(() => {
expect(register).toHaveBeenCalledWith(expect.objectContaining({ setupToken: '0123abcd' }));
});
});
test('explains a rejected setup token', async () => {
(getSetupStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ setup_required: true });
(register as ReturnType<typeof vi.fn>).mockRejectedValue({ code: 'setup_token_invalid' });
render(RegisterPage);
const tokenField = await screen.findByLabelText(/setup token/i);
await fireEvent.input(screen.getByLabelText(/username/i), { target: { value: 'operator' } });
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'abcd1234' } });
await fireEvent.input(screen.getByLabelText(/confirm password/i), { target: { value: 'abcd1234' } });
await fireEvent.input(tokenField, { target: { value: 'nope' } });
await fireEvent.click(screen.getByRole('button', { name: /create account/i }));
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toMatch(/server log/i);
});
});
test('login page has link to register', () => {
// The login page symmetrically has a "Register" link - tested here via the
// register page having a "Sign in" link back to /login.