feat(setup): add a setup token for first-run setup (#3437)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-13 00:17:24 +02:00
1 parent 8e39380ee6
commit 78272521bb
135 files changed
+3045 -771

No files matched your search

@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {createUserID} from '@app/api/BrandedTypes';
import {Config, getConfig} from '@app/api/Config';
import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
@@ -14,6 +15,7 @@ import {
} from '@app/api/instance/InstanceConfigRepository';
import {getLegalUrls, setCachedConfiguredLegalUrls} from '@app/api/instance/LegalUrls';
import {getInstanceProductName, setCachedProductName} from '@app/api/instance/ProductName';
import {ensureSetupToken, isSetupTokenEnforced} from '@app/api/instance/SetupToken';
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
import {dockerAvailable, startPostgresKv, stopPostgresKv} from '@app/api/test/DockerTestContainer';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
@@ -38,6 +40,8 @@ const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
const POSTGRES_KV_TABLE = 'kv_instance_config_races';
const SETUP_TOKEN_HASH_KEY = 'setup_token_hash';
const ENV_SETUP_TOKEN = 'setup-token-for-tests-0123456789-abcdefghijklmnopqrstuvwxyz';
describe('InstanceConfigRepository', () => {
const repositories: Array<InstanceConfigRepository> = [];
@@ -945,6 +949,271 @@ function registrationUrlParams(maxUses: number | null) {
};
}
describe('InstanceConfigRepository setup token', () => {
const repositories: Array<InstanceConfigRepository> = [];
const config = getConfig();
let executor: InstanceConfigWriteRaceExecutor;
let kvProvider: MockKVProvider;
let originalSelfHosted: boolean;
let originalNodeEnv: typeof config.nodeEnv;
let originalSetupToken: typeof config.instance.setup.token;
let originalSetupConfigured: boolean;
beforeEach(() => {
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
setCassandraQueryExecutorForTesting(executor);
kvProvider = new MockKVProvider();
originalSelfHosted = config.instance.selfHosted;
originalNodeEnv = config.nodeEnv;
originalSetupToken = config.instance.setup.token;
originalSetupConfigured = config.instance.setup.configured;
});
afterEach(() => {
config.instance.selfHosted = originalSelfHosted;
config.nodeEnv = originalNodeEnv;
config.instance.setup.token = originalSetupToken;
config.instance.setup.configured = originalSetupConfigured;
for (const repository of repositories) {
repository.shutdown();
}
repositories.length = 0;
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
});
function createPod(): InstanceConfigRepository {
const repository = new InstanceConfigRepository(kvProvider);
repositories.push(repository);
return repository;
}
function useSelfHostedProduction(): void {
config.instance.selfHosted = true;
config.nodeEnv = 'production';
config.instance.setup.token = null;
config.instance.setup.configured = false;
}
function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
const accountExists = async () => true;
const accountMissing = async () => false;
it('generates one setup token when two pods start together and stores only its hash', async () => {
const tokens = await Promise.all([createPod().createSetupTokenIfAbsent(), createPod().createSetupTokenIfAbsent()]);
const generated = tokens.filter((token) => token !== null);
expect(generated).toHaveLength(1);
expect(tokens.filter((token) => token === null)).toHaveLength(1);
const stored = await executor.readDirectly(SETUP_TOKEN_HASH_KEY);
expect(stored).toBe(sha256(generated[0]!));
expect(stored).not.toBe(generated[0]);
expect(await createPod().createSetupTokenIfAbsent()).toBeNull();
});
it('verifies only the generated setup token', async () => {
const pod = createPod();
for (const presented of ['anything', '', null, undefined]) {
expect(await pod.verifySetupToken(presented)).toBe(false);
}
const token = await pod.createSetupTokenIfAbsent();
expect(await pod.verifySetupToken(token)).toBe(true);
expect(await createPod().verifySetupToken(token)).toBe(true);
for (const presented of [`${token}x`, sha256(token!), '', null, undefined]) {
expect(await pod.verifySetupToken(presented)).toBe(false);
}
});
it('takes the environment setup token over a generated one', async () => {
const pod = createPod();
const generated = await pod.createSetupTokenIfAbsent();
config.instance.setup.token = ENV_SETUP_TOKEN;
expect(await pod.verifySetupToken(ENV_SETUP_TOKEN)).toBe(true);
expect(await pod.verifySetupToken(generated)).toBe(false);
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBe(sha256(generated!));
});
it('burns the stored setup token and leaves it alone when the admin bootstrap is only claimed', async () => {
useSelfHostedProduction();
const pod = createPod();
const token = await pod.createSetupTokenIfAbsent();
await pod.claimAdminBootstrap();
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBe(sha256(token!));
await pod.burnSetupToken();
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBe('');
expect(await pod.verifySetupToken(token)).toBe(false);
expect(await pod.verifySetupToken('')).toBe(false);
await ensureSetupToken(pod, accountExists);
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBe('');
});
it('writes nothing when there is no stored setup token to burn', async () => {
await createPod().burnSetupToken();
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBeNull();
});
it('releases a claimed admin bootstrap so it can be claimed again', async () => {
const pod = createPod();
await pod.releaseAdminBootstrap();
expect(await executor.readDirectly('admin_bootstrapped')).toBeNull();
expect(await pod.claimAdminBootstrap()).toBe(true);
expect(await pod.isAdminBootstrapped()).toBe(true);
await pod.releaseAdminBootstrap();
expect(await pod.isAdminBootstrapped()).toBe(false);
expect(await createPod().isAdminBootstrapped()).toBe(false);
expect(await pod.claimAdminBootstrap()).toBe(true);
expect(await pod.claimAdminBootstrap()).toBe(false);
});
it('keeps a won admin bootstrap claim when the refresh cannot be published', async () => {
const pod = createPod();
await pod.isAdminBootstrapped();
vi.spyOn(kvProvider, 'publish').mockRejectedValueOnce(new Error('kv down'));
expect(await pod.claimAdminBootstrap()).toBe(true);
expect(await pod.isAdminBootstrapped()).toBe(true);
expect(await executor.readDirectly('admin_bootstrapped')).toBe('true');
});
it('enforces the setup token only on self-hosted instances that run in production or configure one', async () => {
const pod = createPod();
const gate = async () => ({
enforced: isSetupTokenEnforced(config),
open: await pod.isSetupTokenGateOpen(),
required: await pod.isSetupTokenRequired(),
});
config.instance.setup.configured = false;
config.instance.selfHosted = false;
config.nodeEnv = 'production';
config.instance.setup.token = ENV_SETUP_TOKEN;
expect(await gate()).toEqual({enforced: false, open: false, required: false});
config.instance.selfHosted = true;
config.nodeEnv = 'development';
config.instance.setup.token = null;
expect(await gate()).toEqual({enforced: false, open: false, required: false});
config.instance.setup.token = ENV_SETUP_TOKEN;
expect(await gate()).toEqual({enforced: true, open: true, required: true});
config.nodeEnv = 'production';
config.instance.setup.token = null;
expect(await gate()).toEqual({enforced: true, open: true, required: true});
config.instance.setup.configured = true;
expect(await gate()).toEqual({enforced: true, open: true, required: false});
config.instance.setup.configured = false;
expect(await gate()).toEqual({enforced: true, open: true, required: true});
await pod.setAppPublicConfig({setup: {configured: true}});
expect(await gate()).toEqual({enforced: true, open: true, required: false});
await pod.claimAdminBootstrap();
expect(await gate()).toEqual({enforced: true, open: false, required: false});
});
it('stores a setup token on the first production start and keeps it on the next', async () => {
useSelfHostedProduction();
await ensureSetupToken(createPod(), accountExists);
const stored = await executor.readDirectly(SETUP_TOKEN_HASH_KEY);
expect(stored).toMatch(/^[a-f0-9]{64}$/);
await ensureSetupToken(createPod(), accountExists);
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBe(stored);
});
it('stores a setup token when setup is configured but no admin exists', async () => {
useSelfHostedProduction();
config.instance.setup.configured = true;
await ensureSetupToken(createPod(), accountExists);
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toMatch(/^[a-f0-9]{64}$/);
});
it('stores no setup token when one is set in the environment', async () => {
useSelfHostedProduction();
config.instance.setup.token = ENV_SETUP_TOKEN;
await ensureSetupToken(createPod(), accountExists);
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBeNull();
});
it('stores no setup token on the hosted service or in self-hosted development', async () => {
useSelfHostedProduction();
config.instance.selfHosted = false;
await ensureSetupToken(createPod(), accountExists);
config.instance.selfHosted = true;
config.nodeEnv = 'development';
await ensureSetupToken(createPod(), accountExists);
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBeNull();
});
it('stores no setup token once the admin exists', async () => {
useSelfHostedProduction();
const pod = createPod();
await pod.claimAdminBootstrap();
await ensureSetupToken(pod, accountExists);
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBeNull();
});
it('releases an admin bootstrap claim at start when the claiming account was never created', async () => {
useSelfHostedProduction();
const pod = createPod();
expect(await pod.claimAdminBootstrap(createUserID(4242n))).toBe(true);
expect(await executor.readDirectly('admin_bootstrap_user_id')).toBe('4242');
await ensureSetupToken(createPod(), accountExists);
expect(await executor.readDirectly('admin_bootstrapped')).toBe('true');
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBeNull();
const restarted = createPod();
await ensureSetupToken(restarted, accountMissing);
expect(await executor.readDirectly('admin_bootstrapped')).toBe('false');
expect(await restarted.isSetupTokenGateOpen()).toBe(true);
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toMatch(/^[a-f0-9]{64}$/);
});
it('keeps an admin bootstrap claim at start when no claiming account is recorded', async () => {
useSelfHostedProduction();
const pod = createPod();
await pod.claimAdminBootstrap();
expect(await executor.readDirectly('admin_bootstrap_user_id')).toBeNull();
await ensureSetupToken(createPod(), accountMissing);
expect(await executor.readDirectly('admin_bootstrapped')).toBe('true');
await pod.releaseAdminBootstrap();
await pod.claimAdminBootstrap(createUserID(4242n));
await pod.releaseAdminBootstrap();
await pod.claimAdminBootstrap();
expect(await executor.readDirectly('admin_bootstrap_user_id')).toBe('');
await ensureSetupToken(createPod(), accountMissing);
expect(await executor.readDirectly('admin_bootstrapped')).toBe('true');
});
it('keeps a won admin bootstrap claim when the claiming account cannot be recorded', async () => {
const pod = createPod();
executor.watch('admin_bootstrap_user_id');
executor.competeBeforeEachWrite(async () => {
throw new Error('database down');
});
expect(await pod.claimAdminBootstrap(createUserID(4242n))).toBe(true);
expect(await pod.isAdminBootstrapped()).toBe(true);
expect(await executor.readDirectly('admin_bootstrapped')).toBe('true');
expect(await executor.readDirectly('admin_bootstrap_user_id')).toBeNull();
});
it('confirms the admin bootstrap against the database and repairs a stale snapshot', async () => {
const stale = createPod();
const writer = createPod();
expect(await stale.isAdminBootstrapped()).toBe(false);
vi.spyOn(kvProvider, 'publish').mockRejectedValueOnce(new Error('kv down'));
await writer.claimAdminBootstrap();
expect(await stale.isAdminBootstrapped()).toBe(false);
expect(await stale.confirmAdminBootstrapped()).toBe(true);
expect(await stale.isAdminBootstrapped()).toBe(true);
vi.spyOn(kvProvider, 'publish').mockRejectedValueOnce(new Error('kv down'));
await writer.releaseAdminBootstrap();
expect(await stale.isAdminBootstrapped()).toBe(true);
expect(await stale.confirmAdminBootstrapped()).toBe(false);
expect(await stale.isAdminBootstrapped()).toBe(false);
});
it('refuses to start when the setup token cannot be stored', async () => {
useSelfHostedProduction();
const pod = createPod();
vi.spyOn(pod, 'createSetupTokenIfAbsent').mockRejectedValueOnce(new Error('database down'));
await expect(ensureSetupToken(pod, accountExists)).rejects.toThrow('database down');
});
});
describe('InstanceConfigRepository concurrent writes', () => {
describe('in memory', () => {
describeConcurrentInstanceConfigWrites(async () => new InMemoryCassandraQueryExecutor());
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import crypto from 'node:crypto';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '@app/api/config/APIConfig';
import type {StoredLimitConfig} from '@app/api/constants/LimitConfig';
@@ -32,6 +33,7 @@ import {
import {InstanceConfigCache} from '@app/api/instance/InstanceConfigCache';
import {setCachedConfiguredLegalUrls} from '@app/api/instance/LegalUrls';
import {getDefaultProductName, setCachedProductName} from '@app/api/instance/ProductName';
import {isSetupTokenEnforced} from '@app/api/instance/SetupToken';
import {normalizeSsoAllowedEmailDomains} from '@app/api/instance/SsoConfigValidation';
import {Logger} from '@app/api/Logger';
import {isStoredLimitConfig} from '@app/api/limits/LimitConfigValidation';
@@ -44,6 +46,7 @@ import {
import {InstanceConfiguration, Users} from '@app/api/Tables';
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '@app/api/utils/AttachmentDecay';
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
import {randomBase64Url, secretMatchesHash, sha256Hex} from '@app/api/utils/SecretHash';
import {
type AccountIdentityMode,
AccountIdentityModes,
@@ -120,6 +123,8 @@ const REGISTRATION_URLS_KEY = 'registration_urls';
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
const ADMIN_BOOTSTRAP_KEY = 'admin_bootstrapped';
const ADMIN_BOOTSTRAP_USER_ID_KEY = 'admin_bootstrap_user_id';
const SETUP_TOKEN_HASH_KEY = 'setup_token_hash';
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
const LIMIT_CONFIG_KEY = 'limit_config';
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
@@ -1484,8 +1489,95 @@ export class InstanceConfigRepository {
return (await this.getConfig(ADMIN_BOOTSTRAP_KEY)) === 'true';
}
async markAdminBootstrapped(): Promise<void> {
await this.setConfig(ADMIN_BOOTSTRAP_KEY, 'true');
async confirmAdminBootstrapped(): Promise<boolean> {
const cache = this.configCache;
const stored = (await this.fetchConfigFromDatabase(ADMIN_BOOTSTRAP_KEY)) === 'true';
if (stored !== (await this.isAdminBootstrapped())) cache.update(ADMIN_BOOTSTRAP_KEY, stored ? 'true' : 'false');
return stored;
}
async claimAdminBootstrap(userId: UserID | null = null): Promise<boolean> {
const cache = this.configCache;
const {written} = await this.compareAndSetStoredValue(cache, ADMIN_BOOTSTRAP_KEY, (raw) => ({
value: raw === 'true' ? null : 'true',
result: undefined,
}));
if (!written) return false;
await this.recordAdminBootstrapClaimant(cache, userId);
await this.publishAdminBootstrapRefresh(cache.sourceId);
return true;
}
private async recordAdminBootstrapClaimant(cache: InstanceConfigCache, userId: UserID | null): Promise<void> {
const claimant = userId?.toString() ?? '';
try {
await this.compareAndSetStoredValue(cache, ADMIN_BOOTSTRAP_USER_ID_KEY, (raw) => ({
value: (raw ?? '') === claimant ? null : claimant,
result: undefined,
}));
} catch (error) {
Logger.error({error}, 'Could not record which account claimed the admin bootstrap');
}
}
async getUnconfirmedAdminBootstrapClaimant(): Promise<UserID | null> {
const [bootstrapped, claimant] = await Promise.all([
this.fetchConfigFromDatabase(ADMIN_BOOTSTRAP_KEY),
this.fetchConfigFromDatabase(ADMIN_BOOTSTRAP_USER_ID_KEY),
]);
if (bootstrapped !== 'true' || !claimant || !/^\d+$/.test(claimant)) return null;
return createUserID(BigInt(claimant));
}
async releaseAdminBootstrap(): Promise<void> {
const cache = this.configCache;
const {written} = await this.compareAndSetStoredValue(cache, ADMIN_BOOTSTRAP_KEY, (raw) => ({
value: raw === 'true' ? 'false' : null,
result: undefined,
}));
if (written) await this.publishAdminBootstrapRefresh(cache.sourceId);
}
private async publishAdminBootstrapRefresh(sourceId: string): Promise<void> {
try {
await this.publishRefresh(sourceId);
} catch (error) {
Logger.error(
{error},
'Could not publish the admin bootstrap change. Other API processes see it after the next instance config change or a restart',
);
}
}
async isSetupTokenGateOpen(): Promise<boolean> {
return isSetupTokenEnforced(Config) && !(await this.isAdminBootstrapped());
}
async isSetupTokenRequired(): Promise<boolean> {
return (await this.isSetupTokenGateOpen()) && !(await this.getAppPublicConfig()).setup.configured;
}
async createSetupTokenIfAbsent(): Promise<string | null> {
const token = randomBase64Url(32);
const {written} = await this.compareAndSetStoredValue(this.configCache, SETUP_TOKEN_HASH_KEY, (raw) => ({
value: raw ? null : sha256Hex(token),
result: undefined,
}));
return written ? token : null;
}
async verifySetupToken(presented: string | null | undefined): Promise<boolean> {
const expectedHash = Config.instance.setup.token
? sha256Hex(Config.instance.setup.token)
: await this.fetchConfigFromDatabase(SETUP_TOKEN_HASH_KEY);
return secretMatchesHash(presented, expectedHash);
}
async burnSetupToken(): Promise<void> {
await this.compareAndSetStoredValue(this.configCache, SETUP_TOKEN_HASH_KEY, (raw) => ({
value: raw ? '' : null,
result: undefined,
}));
}
async getAccountIdentity(): Promise<AccountIdentity> {
@@ -9,11 +9,13 @@ import {
isDiscoveryNotModified,
nextDiscoveryValidators,
} from '@app/api/instance/DiscoveryValidators';
import {assertSetupToken} from '@app/api/instance/SetupToken';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import {isBillingActive, isPremiumTieringActive, isStripeServiceable} from '@app/api/stripe/BillingConfigCache';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {requireConfiguredClientIp} from '@app/api/utils/RequestClientIp';
import {Validator} from '@app/api/Validator';
import {AccountIdentityModes, TagStyles} from '@fluxer/constants/src/AccountIdentityConstants';
import {API_CODE_VERSION} from '@fluxer/constants/src/AppConstants';
@@ -130,10 +132,13 @@ export function InstanceController(app: Hono<HonoEnv>) {
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAccountIdentity(),
]);
const accountIdentityLocked =
const openSetup =
Config.instance.selfHosted && !appPublicConfig.setup.configured
? await instanceConfigRepository.isAccountIdentityLocked()
: undefined;
? {
account_identity_locked: await instanceConfigRepository.isAccountIdentityLocked(),
setup_token_required: await instanceConfigRepository.isSetupTokenRequired(),
}
: {};
const discovery = buildDiscoveryResponse(
buildDiscoveryStaticInput(
gifService,
@@ -142,7 +147,7 @@ export function InstanceController(app: Hono<HonoEnv>) {
setup: {
...appPublicConfig.setup,
admin_url: Config.endpoints.admin || null,
...(accountIdentityLocked === undefined ? {} : {account_identity_locked: accountIdentityLocked}),
...openSetup,
},
},
{
@@ -187,15 +192,23 @@ export function InstanceController(app: Hono<HonoEnv>) {
security: [],
tags: ['Instance'],
description:
'Sets how people sign in on a new self-hosted instance, and for email sign-in whether usernames are unique with no tag. Username sign-in always uses unique usernames. It works only before setup is finished and before the first account exists. After that it fails with ACCOUNT_IDENTITY_LOCKED.',
'Sets how people sign in on a new self-hosted instance, and for email sign-in whether usernames are unique with no tag. Username sign-in always uses unique usernames. It works only before setup is finished and before the first account exists. After that it fails with ACCOUNT_IDENTITY_LOCKED. While the instance requires a setup token, the request must send it as setup_token.',
}),
async (ctx) => {
const {mode, tag_style} = ctx.req.valid('json');
const {mode, tag_style, setup_token} = ctx.req.valid('json');
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const {config, rateLimit, cache} = ctx.get('apiContext').services;
await assertSetupToken({
rateLimit,
config,
repository: instanceConfigRepository,
presented: setup_token,
clientIp: requireConfiguredClientIp(ctx.req.raw, config.proxy),
});
if (mode === AccountIdentityModes.USERNAME && tag_style === TagStyles.RANDOM) {
throw InputValidationError.fromCode('tag_style', ValidationErrorCodes.TAG_STYLE_REQUIRES_EMAIL_SIGN_IN);
}
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const identity = await withAccountIdentitySetupLock(ctx.get('apiContext').services.cache, async () => {
const identity = await withAccountIdentitySetupLock(cache, async () => {
if (await instanceConfigRepository.isAccountIdentityLocked()) {
throw new AccountIdentityLockedError();
}
+110
View File
@@ -0,0 +1,110 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {APIConfig} from '@app/api/config/APIConfig';
import type {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
import {Logger} from '@app/api/Logger';
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
import {ms} from 'itty-time';
const SETUP_TOKEN_FAILURE_MAX_ATTEMPTS = 10;
const SETUP_TOKEN_FAILURE_WINDOW_MS = ms('30 minutes');
export function isSetupTokenEnforced(config: APIConfig): boolean {
return config.instance.selfHosted && (config.nodeEnv === 'production' || config.instance.setup.token !== null);
}
async function releaseAdminBootstrapOfMissingAccount(
repository: InstanceConfigRepository,
userExists: (userId: UserID) => Promise<boolean>,
): Promise<void> {
const claimant = await repository.getUnconfirmedAdminBootstrapClaimant();
if (claimant === null || (await userExists(claimant))) return;
await repository.releaseAdminBootstrap();
Logger.warn(
{user_id: claimant.toString()},
'The account that claimed the admin bootstrap does not exist. The claim is released and the setup token applies again',
);
}
export async function ensureSetupToken(
repository: InstanceConfigRepository,
userExists: (userId: UserID) => Promise<boolean>,
): Promise<void> {
if (!isSetupTokenEnforced(Config)) return;
await releaseAdminBootstrapOfMissingAccount(repository, userExists);
if (!(await repository.isSetupTokenGateOpen())) return;
if (Config.instance.setup.token !== null) {
Logger.info('Setup token read from FLUXER_SETUP_TOKEN');
return;
}
const token = await repository.createSetupTokenIfAbsent();
if (token === null) {
Logger.info(
'A setup token from an earlier start is still unused. Set FLUXER_SETUP_TOKEN to replace it if it is lost',
);
return;
}
Logger.warn(
{setup_token: token},
'Setup token generated. This instance has not recorded an admin account yet. The registration that presents this token becomes an admin. It is shown once',
);
}
export async function clearUsedSetupToken(repository: InstanceConfigRepository): Promise<void> {
try {
await repository.burnSetupToken();
} catch (error) {
Logger.error({error}, 'Could not clear the stored setup token after the admin bootstrap was claimed');
}
}
export async function assertSetupToken(params: {
rateLimit: IRateLimitService;
config: APIConfig;
repository: InstanceConfigRepository;
presented: string | null | undefined;
clientIp: string;
}): Promise<boolean> {
const {rateLimit, config, repository, clientIp} = params;
if (!isSetupTokenEnforced(config)) return false;
const setupConfigured = (await repository.getAppPublicConfig()).setup.configured;
const adminBootstrapped = setupConfigured
? await repository.isAdminBootstrapped()
: await repository.confirmAdminBootstrapped();
if (adminBootstrapped) return false;
const token = params.presented?.trim();
if (!token) {
if (!setupConfigured) {
throw InputValidationError.fromCode('setup_token', ValidationErrorCodes.SETUP_TOKEN_REQUIRED);
}
return false;
}
const failureLimit = config.dev.relaxRegistrationRateLimits
? null
: {
identifier: `setup-token:ip:${getSameIpDecisionKey(clientIp) ?? clientIp}`,
maxAttempts: SETUP_TOKEN_FAILURE_MAX_ATTEMPTS,
windowMs: SETUP_TOKEN_FAILURE_WINDOW_MS,
};
if (failureLimit) {
const failureState = await rateLimit.peekLimit(failureLimit);
if (failureState.remaining === 0) {
throw createRateLimitError({
...failureState,
allowed: false,
retryAfter: Math.ceil(failureLimit.windowMs / failureLimit.maxAttempts / 1000),
});
}
}
if (!(await repository.verifySetupToken(token))) {
if (failureLimit) await rateLimit.checkLimit(failureLimit);
throw InputValidationError.fromCode('setup_token', ValidationErrorCodes.SETUP_TOKEN_INVALID);
}
return true;
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {createAuthHarness, createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {getConfig} from '@app/api/Config';
import {getCachedAccountIdentity, getCachedAccountIdentityMode} from '@app/api/instance/AccountIdentityModeCache';
@@ -14,10 +15,12 @@ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const ACCOUNT_IDENTITY_CONFIG_KEY = 'account_identity_config';
const SETUP_TOKEN = 'setup-token-for-tests-0123456789-abcdefghijklmnopqrstuvwxyz';
const WRONG_SETUP_TOKEN = 'wrong-setup-token-0123456789-abcdefghijklmnopqrstuvwxyz';
interface DiscoveryResponse {
features: {account_identity: string; emails_enabled: boolean; tag_style: string};
app_public: {setup: {configured: boolean; account_identity_locked?: boolean}};
app_public: {setup: {configured: boolean; account_identity_locked?: boolean; setup_token_required?: boolean}};
}
interface AdminInstanceConfigResponse {
@@ -33,6 +36,7 @@ describe('Account identity mode', () => {
let originalSetupConfigured: boolean;
let originalAccountIdentity: typeof config.instance.accountIdentity;
let originalTagStyle: typeof config.instance.tagStyle;
let originalSetupToken: typeof config.instance.setup.token;
beforeAll(async () => {
harness = await createAuthHarness();
@@ -45,6 +49,7 @@ describe('Account identity mode', () => {
originalSetupConfigured = config.instance.setup.configured;
originalAccountIdentity = config.instance.accountIdentity;
originalTagStyle = config.instance.tagStyle;
originalSetupToken = config.instance.setup.token;
});
afterEach(() => {
vi.restoreAllMocks();
@@ -53,6 +58,7 @@ describe('Account identity mode', () => {
config.instance.setup.configured = originalSetupConfigured;
config.instance.accountIdentity = originalAccountIdentity;
config.instance.tagStyle = originalTagStyle;
config.instance.setup.token = originalSetupToken;
repository.clearCacheForTesting();
});
afterAll(async () => {
@@ -239,7 +245,7 @@ describe('Account identity mode', () => {
it('stamps email when the admin was bootstrapped', async () => {
useFreshSelfHostedInstance();
await repository.markAdminBootstrapped();
await repository.claimAdminBootstrap();
await repository.ensureAccountIdentityMode();
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'existing_instance'});
});
@@ -321,7 +327,7 @@ describe('Account identity mode', () => {
it('skips the users probe when a cheaper signal already decides', async () => {
useFreshSelfHostedInstance();
const probe = vi.spyOn(repository as unknown as {hasAnyUser: () => Promise<boolean>}, 'hasAnyUser');
await repository.markAdminBootstrapped();
await repository.claimAdminBootstrap();
await repository.ensureAccountIdentityMode();
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'existing_instance'});
expect(await repository.isAccountIdentityLocked()).toBe(true);
@@ -540,6 +546,77 @@ describe('Account identity mode', () => {
});
});
describe('setup token', () => {
async function putModeWithToken(setupToken?: string) {
return await createBuilderWithoutAuth<{
mode?: string;
tag_style?: string;
errors?: Array<{path: string; code: string}>;
}>(harness)
.put('/instance/setup/account-identity')
.body(
setupToken === undefined
? {mode: AccountIdentityModes.EMAIL}
: {mode: AccountIdentityModes.EMAIL, setup_token: setupToken},
)
.executeRaw();
}
it('reports in discovery whether registration needs the setup token', async () => {
useFreshSelfHostedInstance();
await repository.ensureAccountIdentityMode();
expect((await fetchDiscovery()).app_public.setup.setup_token_required).toBe(false);
config.instance.setup.token = SETUP_TOKEN;
const {text, json} = await createBuilderWithoutAuth<DiscoveryResponse>(harness)
.get('/.well-known/fluxer')
.executeRaw();
expect(json.app_public.setup.setup_token_required).toBe(true);
expect(text).not.toContain(SETUP_TOKEN);
expect(text).not.toContain(createHash('sha256').update(SETUP_TOKEN).digest('hex'));
await repository.claimAdminBootstrap();
expect((await fetchDiscovery()).app_public.setup.setup_token_required).toBe(false);
await repository.setAppPublicConfig({setup: {configured: true}});
expect((await fetchDiscovery()).app_public.setup).not.toHaveProperty('setup_token_required');
});
it('leaves the setup token out of discovery on the hosted service', async () => {
config.instance.selfHosted = false;
config.instance.setup.token = SETUP_TOKEN;
await repository.setAppPublicConfig({setup: {configured: false}});
expect((await fetchDiscovery()).app_public.setup).not.toHaveProperty('setup_token_required');
});
it('needs the setup token to choose the sign-in method while one is required', async () => {
useFreshSelfHostedInstance();
await repository.ensureAccountIdentityMode();
config.instance.setup.token = SETUP_TOKEN;
const missing = await putModeWithToken();
expect(missing.response.status).toBe(400);
expect(missing.json.errors?.map(({path, code}) => ({path, code}))).toEqual([
{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_REQUIRED},
]);
const wrong = await putModeWithToken(WRONG_SETUP_TOKEN);
expect(wrong.response.status).toBe(400);
expect(wrong.json.errors?.map(({path, code}) => ({path, code}))).toEqual([
{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_INVALID},
]);
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.USERNAME});
const correct = await putModeWithToken(SETUP_TOKEN);
expect(correct.response.status).toBe(200);
expect(correct.json).toEqual({mode: AccountIdentityModes.EMAIL, tag_style: 'none'});
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'setup'});
});
it('chooses the sign-in method without a setup token when none is required', async () => {
useFreshSelfHostedInstance();
await repository.ensureAccountIdentityMode();
const tokenless = await putModeWithToken();
expect(tokenless.response.status).toBe(200);
const ignored = await putModeWithToken(WRONG_SETUP_TOKEN);
expect(ignored.response.status).toBe(200);
});
});
describe('admin instance config', () => {
async function fetchAdminConfig(token: string): Promise<AdminInstanceConfigResponse> {
return await createBuilder<AdminInstanceConfigResponse>(harness, token).get('/admin/instance/config').execute();