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

+39
View File
@@ -259,3 +259,42 @@ describe('buildAPIConfigFromMaster trusted callers', () => {
);
});
});
async function setupTokenFromEnv(env: Record<string, string>): Promise<string | null> {
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value);
}
resetConfig();
return buildAPIConfigFromMaster(await loadConfig()).instance.setup.token;
}
describe('buildAPIConfigFromMaster setup token', () => {
const setupToken = 's'.repeat(32);
test('reads the token from FLUXER_SETUP_TOKEN', async () => {
expect(await setupTokenFromEnv({FLUXER_SETUP_TOKEN: ` ${setupToken}\n`})).toBe(setupToken);
});
test.each(['', ' '])('leaves the token unset for %j', async (blank) => {
expect(await setupTokenFromEnv({FLUXER_SETUP_TOKEN: blank})).toBeNull();
});
test('leaves the token unset when the variable is missing', async () => {
expect(await setupTokenFromEnv({})).toBeNull();
});
test('accepts a token of 256 characters', async () => {
expect(await setupTokenFromEnv({FLUXER_SETUP_TOKEN: 's'.repeat(256)})).toBe('s'.repeat(256));
});
test.each([
['shorter than 32 characters', 's'.repeat(31)],
['longer than 256 characters', 's'.repeat(257)],
['holding a form feed', `${'s'.repeat(16)}\f${'s'.repeat(16)}`],
['holding a text direction override', `${'s'.repeat(16)}\u202E${'s'.repeat(16)}`],
])('fails at boot on a token %s', async (_case, token) => {
await expect(setupTokenFromEnv({FLUXER_SETUP_TOKEN: token})).rejects.toThrow(
'FLUXER_SETUP_TOKEN must be 32 to 256 printable characters',
);
});
});
+20
View File
@@ -8,6 +8,7 @@ import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {smtpTlsModeFromSecure} from '@fluxer/constants/src/SmtpConstants';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {normalizeString} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
function extractHostname(url: string): string {
@@ -123,6 +124,24 @@ function buildTrustedCallers(master: MasterConfig): Array<TrustedCallerConfig> {
return trustedCallers;
}
const SETUP_TOKEN_MIN_LENGTH = 32;
const SETUP_TOKEN_MAX_LENGTH = 256;
function resolveSetupToken(master: MasterConfig): string | null {
const token = master.instance.setup.token?.trim();
if (!token) return null;
if (
token.length < SETUP_TOKEN_MIN_LENGTH ||
token.length > SETUP_TOKEN_MAX_LENGTH ||
normalizeString(token) !== token
) {
throw new Error(
`FLUXER_SETUP_TOKEN must be ${SETUP_TOKEN_MIN_LENGTH} to ${SETUP_TOKEN_MAX_LENGTH} printable characters`,
);
}
return token;
}
export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
if (!master.internal) {
throw new Error('internal configuration is required for the API');
@@ -402,6 +421,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
setup: {
configured: master.instance.setup.configured,
token: resolveSetupToken(master),
},
accountIdentity: master.instance.account_identity,
tagStyle: master.instance.tag_style,
@@ -11,6 +11,7 @@ import {
REGISTRATION_PENDING_APPROVAL_TRAIT,
REGISTRATION_REJECTED_TRAIT,
} from '@app/api/instance/InstanceConfigRepository';
import {clearUsedSetupToken} from '@app/api/instance/SetupToken';
import {deriveSsoRedirectUri, normalizeAndValidateSsoConfig} from '@app/api/instance/SsoConfigValidation';
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
@@ -44,7 +45,6 @@ import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experim
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import type {Context} from 'hono';
import {createMiddleware} from 'hono/factory';
const INSTANCE_BRANDING_ENTITY_ID = 0n;
const ATTACHMENT_EXPIRY_RULE_FIELDS = [
@@ -187,49 +187,6 @@ function buildAdminIssuedRegistrationUrl(code: string): string {
return `${baseUrl}/register?registration_url=${encodeURIComponent(code)}`;
}
function requireSetupSessionOrAdminACL(requiredACL: string) {
const requireAcl = requireAdminACL(requiredACL);
return createMiddleware<HonoEnv>(async (ctx, next) => {
const user = ctx.get('user');
const tokenType = ctx.get('authTokenType');
if (user && tokenType === 'session') {
const appPublic = await getInstanceConfigRepository().getAppPublicConfig();
if (!appPublic.setup.configured) {
ctx.set('adminUserId', user.id);
ctx.set('adminUserAcls', user.acls);
await next();
return;
}
}
return requireAcl(ctx, next);
});
}
function hasAdminAuthenticationACL(acls: ReadonlySet<string>): boolean {
return acls.has(AdminACLs.AUTHENTICATE) || acls.has(AdminACLs.WILDCARD);
}
function completesInitialSetup(data: InstanceConfigUpdateRequest, setupConfigured: boolean): boolean {
return (
!setupConfigured &&
data.app_public?.setup != null &&
readOptionalField(data.app_public.setup, 'configured') === true
);
}
async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boolean> {
const user = ctx.get('user');
if (!user || ctx.get('authTokenType') !== 'session' || hasAdminAuthenticationACL(user.acls)) {
return false;
}
const nextACLs = new Set(user.acls);
nextACLs.add(AdminACLs.WILDCARD);
const updatedUser = await ctx.get('userRepository').patchUpsert(user.id, {acls: nextACLs}, user.toRow());
ctx.set('user', updatedUser);
ctx.set('adminUserAcls', updatedUser.acls);
return true;
}
function relayConsentStamp(
current: PushRelayConfig,
patch: PushRelayConfigUpdateRequest,
@@ -346,7 +303,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
app.get(
'/admin/instance/config',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_VIEW),
requireAdminACL(AdminACLs.INSTANCE_CONFIG_VIEW),
OpenAPI({
operationId: 'get_admin_instance_config',
summary: 'Get instance configuration',
@@ -374,7 +331,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
app.patch(
'/admin/instance/config',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
Validator('json', InstanceConfigUpdateRequest),
OpenAPI({
operationId: 'update_admin_instance_config',
@@ -390,11 +347,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
const data = ctx.req.valid('json');
assertSelfHostedBillingSections(data);
await assertBillingCompatibleWithPremiumMode(data);
const appPublicBeforeUpdate = completesInitialSetup(data, false)
? await instanceConfigRepository.getAppPublicConfig()
: null;
const shouldGrantSetupCompleterAdmin =
appPublicBeforeUpdate !== null && completesInitialSetup(data, appPublicBeforeUpdate.setup.configured);
if (data.gateway_rollout) {
const patch = data.gateway_rollout;
const landed = await instanceConfigRepository.updateGatewayRolloutConfig((current) =>
@@ -628,16 +580,14 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
if (data.app_public?.setup) {
const configured = readOptionalField(data.app_public.setup, 'configured');
await instanceConfigRepository.setAppPublicConfig({
setup: omitUndefinedFields({
configured: readOptionalField(data.app_public.setup, 'configured'),
}),
setup: omitUndefinedFields({configured}),
});
}
let grantedSetupCompleterAdmin = false;
if (shouldGrantSetupCompleterAdmin) {
grantedSetupCompleterAdmin = await grantSetupCompleterAdminACL(ctx);
await instanceConfigRepository.markAdminBootstrapped();
if (configured === true) {
await instanceConfigRepository.claimAdminBootstrap();
await clearUsedSetupToken(instanceConfigRepository);
}
}
await recordAdminWrite(ctx, {
targetType: 'instance_config',
@@ -645,7 +595,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
action: 'update_instance_config',
metadata: {
sections: listSuppliedSections(data),
granted_acls: grantedSetupCompleterAdmin ? AdminACLs.WILDCARD : undefined,
},
});
return ctx.json(await buildInstanceConfigResponse());
@@ -654,7 +603,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
app.post(
'/admin/instance/config/branding-assets',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
Validator('json', BrandingAssetUploadRequest),
OpenAPI({
operationId: 'create_admin_instance_branding_asset',
@@ -690,7 +639,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
app.post(
'/admin/instance/config/smtp-tests',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
Validator('json', InstanceEmailSmtpTestRequest),
OpenAPI({
operationId: 'create_admin_instance_smtp_test',
@@ -27,7 +27,6 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
{
method: 'GET',
route: '/admin/instance/config',
name: 'admin acl',
async prepare({harness, admin}) {
await createRegistrationUrl(harness, admin);
return {
@@ -41,23 +40,6 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
};
},
},
{
method: 'GET',
route: '/admin/instance/config',
name: 'setup session',
async prepare() {
await getInstanceConfigRepository().setAppPublicConfig({setup: {configured: false}});
return {
request: {path: '/admin/instance/config'},
expected: {
action: 'get_instance_config',
targetType: 'instance_config',
targetId: '0',
metadata: {registration_url_count: '0', pending_registration_count: '0'},
},
};
},
},
{
method: 'PATCH',
route: '/admin/instance/config',
+51 -15
View File
@@ -12,8 +12,10 @@ import {createPasswordResetToken} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {Logger} from '@app/api/Logger';
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {PASSWORD_RESET_TOKEN_TTL_SECONDS} from '@app/api/user/repositories/auth/TokenRepository';
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
import * as FetchUtils from '@app/api/utils/FetchUtils';
import {hashPassword} from '@app/api/utils/PasswordUtils';
@@ -31,6 +33,7 @@ import {ms} from 'itty-time';
const PWNED_PASSWORDS_TIMEOUT_MS = ms('5 seconds');
const PWNED_PASSWORD_CACHE_MAX_PREFIXES = 128;
const PASSWORD_RESET_CLAIM_KEY_PREFIX = 'auth:password-reset:claimed:';
interface CacheEntry {
pwnedSuffixes: ReadonlySet<string>;
@@ -94,12 +97,14 @@ interface ApplyPasswordResetParams {
password: string;
request: Request;
afterPasswordSet?: () => Promise<void>;
onPasswordWritten?: () => void;
}
interface CommitPasswordResetParams {
user: User;
passwordHash: string;
webauthnIsSecondFactor: boolean;
onPasswordWritten?: () => void;
}
type ResetPasswordResult =
@@ -242,6 +247,22 @@ export async function validateResetToken(ctx: ApiContext, token: string): Promis
return true;
}
function passwordResetClaimKey(token: string): string {
return `${PASSWORD_RESET_CLAIM_KEY_PREFIX}${crypto.createHash('sha256').update(token).digest('hex')}`;
}
function claimPasswordResetToken(token: string): Promise<boolean> {
return getKVClient().setnx(passwordResetClaimKey(token), '1', PASSWORD_RESET_TOKEN_TTL_SECONDS);
}
async function releasePasswordResetTokenClaim(token: string): Promise<void> {
try {
await getKVClient().del(passwordResetClaimKey(token));
} catch (error) {
Logger.error({error}, 'Could not release the claim of a password reset token whose reset failed');
}
}
export async function resetPassword(
ctx: ApiContext,
{data, request}: ResetPasswordParams,
@@ -263,28 +284,42 @@ export async function resetPassword(
if (await isPasswordPwned(ctx, data.password)) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
}
if (tokenData.email !== null) {
if (!(await claimPasswordResetToken(data.token))) {
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN);
}
let passwordWritten = false;
const onPasswordWritten = () => {
passwordWritten = true;
};
try {
if (tokenData.email !== null) {
return await applyPasswordReset(ctx, {
user,
password: data.password,
request,
afterPasswordSet: () => users.deleteAllPasswordResetTokens(user.id),
onPasswordWritten,
});
}
return await applyPasswordReset(ctx, {
user,
user: currentUser,
password: data.password,
request,
afterPasswordSet: () => users.deleteAllPasswordResetTokens(user.id),
afterPasswordSet: async () => {
await users.deleteAllPasswordResetTokens(currentUser.id);
await new RecoveryKitRepository().delete(currentUser.id);
},
onPasswordWritten,
});
} catch (error) {
if (!passwordWritten) await releasePasswordResetTokenClaim(data.token);
throw error;
}
return await applyPasswordReset(ctx, {
user: currentUser,
password: data.password,
request,
afterPasswordSet: async () => {
await users.deleteAllPasswordResetTokens(currentUser.id);
await new RecoveryKitRepository().delete(currentUser.id);
},
});
}
export async function commitPasswordReset(
ctx: ApiContext,
{user, passwordHash, webauthnIsSecondFactor}: CommitPasswordResetParams,
{user, passwordHash, webauthnIsSecondFactor, onPasswordWritten}: CommitPasswordResetParams,
): Promise<User> {
const {users} = ctx.services;
const updates: Partial<UserRow> = {
@@ -297,6 +332,7 @@ export async function commitPasswordReset(
updates.authenticator_types = authenticatorTypes;
}
const updatedUser = await users.patchUpsert(user.id, updates, user.toRow());
onPasswordWritten?.();
if (updates.authenticator_types) {
await ctx.services.botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
}
@@ -306,12 +342,12 @@ export async function commitPasswordReset(
export async function applyPasswordReset(
ctx: ApiContext,
{user, password, request, afterPasswordSet}: ApplyPasswordResetParams,
{user, password, request, afterPasswordSet, onPasswordWritten}: ApplyPasswordResetParams,
): Promise<ResetPasswordResult> {
const webauthnIsSecondFactor = await resolveWebAuthnSecondFactor(ctx, user);
const hasMfa = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) || webauthnIsSecondFactor;
const passwordHash = await hashPassword(password);
const updatedUser = await commitPasswordReset(ctx, {user, passwordHash, webauthnIsSecondFactor});
const updatedUser = await commitPasswordReset(ctx, {user, passwordHash, webauthnIsSecondFactor, onPasswordWritten});
await afterPasswordSet?.();
if (hasMfa) {
return await createMfaTicketResponse(ctx, updatedUser, webauthnIsSecondFactor);
+52 -14
View File
@@ -25,6 +25,7 @@ import {
REGISTRATION_PENDING_APPROVAL_TRAIT,
type RegistrationUrlClaim,
} from '@app/api/instance/InstanceConfigRepository';
import {assertSetupToken, clearUsedSetupToken, isSetupTokenEnforced} from '@app/api/instance/SetupToken';
import type {SingleCommunityService} from '@app/api/instance/SingleCommunityService';
import type {InviteService} from '@app/api/invite/InviteService';
import {Logger} from '@app/api/Logger';
@@ -85,6 +86,11 @@ interface RegisterParams {
requestCache: RequestCache;
}
interface RegisterAccountParams extends RegisterParams {
clientIp: string;
setupTokenVerified: boolean;
}
export interface RegistrationDependencies {
inviteService: InviteService;
instanceConfigRepository: InstanceConfigRepository;
@@ -114,16 +120,26 @@ export async function register(
deps: RegistrationDependencies,
params: RegisterParams,
): Promise<RegisterResult> {
if (!ctx.services.config.instance.selfHosted || (await deps.instanceConfigRepository.isAccountIdentityLocked())) {
return await registerAccount(ctx, deps, params);
const {config, rateLimit, cache} = ctx.services;
const clientIp = requireConfiguredClientIp(params.request, config.proxy);
const setupTokenVerified = await assertSetupToken({
rateLimit,
config,
repository: deps.instanceConfigRepository,
presented: params.data.setup_token,
clientIp,
});
const account = {...params, clientIp, setupTokenVerified};
if (!config.instance.selfHosted || (await deps.instanceConfigRepository.isAccountIdentityLocked())) {
return await registerAccount(ctx, deps, account);
}
return await withAccountIdentitySetupLock(ctx.services.cache, () => registerAccount(ctx, deps, params));
return await withAccountIdentitySetupLock(cache, () => registerAccount(ctx, deps, account));
}
async function registerAccount(
ctx: ApiContext,
deps: RegistrationDependencies,
{data, request, requestCache}: RegisterParams,
{data, request, requestCache, clientIp, setupTokenVerified}: RegisterAccountParams,
): Promise<RegisterResult> {
const {users, snowflake, emailDnsValidation, config} = ctx.services;
const {inviteService, instanceConfigRepository, singleCommunityService, discriminatorService, kvActivityTracker} =
@@ -144,8 +160,9 @@ async function registerAccount(
throw InputValidationError.fromCode('consent', ValidationErrorCodes.MUST_AGREE_TO_TOS_AND_PRIVACY_POLICY);
}
const now = new Date();
const registrationAccess = await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
const clientIp = requireConfiguredClientIp(request, config.proxy);
const registrationAccess = setupTokenVerified
? {pendingApproval: false, registrationUrl: null}
: await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
const geoipResult = await lookupGeoip(clientIp);
const countryCode = geoipResult.countryCode;
const collectDateOfBirth = appPublicConfig.registration.collect_date_of_birth;
@@ -168,7 +185,7 @@ async function registerAccount(
}
const rawEmail = usernameMode ? null : (data.email ?? null);
const emailKey = rawEmail ? rawEmail.toLowerCase() : null;
const enforceRateLimits = !config.dev.relaxRegistrationRateLimits;
const enforceRateLimits = !config.dev.relaxRegistrationRateLimits && !setupTokenVerified;
await enforceRegistrationRateLimits(ctx, {enforceRateLimits, clientIp, emailKey});
if (rawEmail) {
if (isBlockedEmailDomain(extractEmailDomain(rawEmail))) {
@@ -217,13 +234,14 @@ async function registerAccount(
discriminator = await allocateDiscriminator(discriminatorService, usernameCandidate);
}
const username = usernameCandidate!;
const grantBootstrapAdmin =
const bootstrapAdminEligible =
shouldAttemptBootstrapAdminGrant(config, {
rawEmail,
hasPassword: Boolean(data.password),
usernameMode,
pendingApproval: registrationAccess.pendingApproval,
setupConfigured: appPublicConfig.setup.configured,
setupTokenVerified,
}) && !(await instanceConfigRepository.isAdminBootstrapped());
if (
profileSubstringBlocklistCache.containsBannedSubstring('username', username) ||
@@ -258,7 +276,7 @@ async function registerAccount(
privacy_agreed_at: requiresPrivacyConsent ? now : null,
last_active_at: now,
last_active_ip: clientIp,
acls: grantBootstrapAdmin ? new Set([AdminACLs.WILDCARD]) : null,
acls: null,
traits: registrationAccess.pendingApproval ? new Set([REGISTRATION_PENDING_APPROVAL_TRAIT]) : null,
version: 1,
};
@@ -269,6 +287,7 @@ async function registerAccount(
);
let user: User;
let createAttempted = false;
let grantBootstrapAdmin = false;
let usernameReservation: UsernameReservation | null = null;
try {
if (uniqueUsernames) {
@@ -286,8 +305,9 @@ async function registerAccount(
client_ip: clientIp,
});
}
grantBootstrapAdmin = bootstrapAdminEligible && (await instanceConfigRepository.claimAdminBootstrap(userId));
createAttempted = true;
user = await users.create(userRow);
user = await users.create(grantBootstrapAdmin ? {...userRow, acls: new Set([AdminACLs.WILDCARD])} : userRow);
} catch (error) {
if (!createAttempted) {
await withdrawSignupOfUncreatedAccount(instanceConfigRepository, {
@@ -295,11 +315,16 @@ async function registerAccount(
registrationUrlUse,
pendingApproval: registrationAccess.pendingApproval,
});
} else if (grantBootstrapAdmin) {
await releaseBootstrapAdminOfUncreatedAccount(instanceConfigRepository, userId);
}
throw error;
} finally {
await usernameReservation?.release();
}
if (grantBootstrapAdmin) {
await clearUsedSetupToken(instanceConfigRepository);
}
await users.upsertSettings(
UserSettings.getDefaultUserSettings({
userId,
@@ -348,9 +373,6 @@ async function registerAccount(
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
if (grantBootstrapAdmin) {
await instanceConfigRepository.markAdminBootstrapped();
}
return {
user_id: user.id.toString(),
token,
@@ -365,11 +387,13 @@ function shouldAttemptBootstrapAdminGrant(
usernameMode: boolean;
pendingApproval: boolean;
setupConfigured: boolean;
setupTokenVerified: boolean;
},
): boolean {
const claimedAccount = params.usernameMode ? params.hasPassword : params.rawEmail !== null;
if (isSetupTokenEnforced(config)) return params.setupTokenVerified && claimedAccount;
const localDevInstance = config.nodeEnv === 'development' && !config.dev.testModeEnabled;
const setupBootstrapOpen = !params.setupConfigured;
const claimedAccount = params.usernameMode ? params.hasPassword : params.rawEmail !== null;
return (
(config.instance.selfHosted || localDevInstance || setupBootstrapOpen) && claimedAccount && !params.pendingApproval
);
@@ -432,6 +456,20 @@ async function withdrawSignupOfUncreatedAccount(
}
}
async function releaseBootstrapAdminOfUncreatedAccount(
instanceConfigRepository: InstanceConfigRepository,
userId: UserID,
): Promise<void> {
try {
await instanceConfigRepository.releaseAdminBootstrap();
} catch (error) {
Logger.error(
{userId: userId.toString(), error},
'[AuthRegistration] Failed to release the admin bootstrap claim of an account that was never created',
);
}
}
async function resolveRegistrationAccess(
instanceConfigRepository: InstanceConfigRepository,
registrationUrlCode: string | null | undefined,
@@ -12,7 +12,8 @@ import {
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {generateUniquePassword, HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
describe('Password reset flow', () => {
let harness: ApiTestHarness;
@@ -104,6 +105,61 @@ describe('Password reset flow', () => {
const login = await loginUser(harness, {email: account.email, password: newPassword});
expect('token' in login && login.token.length > 0).toBe(true);
});
it('accepts a reset token for only one of several concurrent resets', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post('/auth/forgot')
.body({email: account.email})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const emails = await listTestEmails(harness, {recipient: account.email});
const token = findLastTestEmail(emails, 'password_reset')!.metadata!.token!;
const results = await Promise.all(
Array.from({length: 4}, () =>
createBuilderWithoutAuth(harness)
.post('/auth/reset')
.body({token, password: generateUniquePassword()})
.executeRaw(),
),
);
const statuses = results.map(({response}) => response.status).sort();
expect(statuses).toEqual(
[HTTP_STATUS.OK, HTTP_STATUS.BAD_REQUEST, HTTP_STATUS.BAD_REQUEST, HTTP_STATUS.BAD_REQUEST].sort(),
);
});
it('keeps a reset token usable when the reset fails before the password is written', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post('/auth/forgot')
.body({email: account.email})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const emails = await listTestEmails(harness, {recipient: account.email});
const token = findLastTestEmail(emails, 'password_reset')!.metadata!.token!;
const patchUpsert = vi
.spyOn(UserRepository.prototype, 'patchUpsert')
.mockRejectedValueOnce(new Error('the user row write failed'));
try {
await createBuilderWithoutAuth(harness)
.post('/auth/reset')
.body({token, password: generateUniquePassword()})
.expect(HTTP_STATUS.INTERNAL_SERVER_ERROR)
.execute();
} finally {
patchUpsert.mockRestore();
}
const newPassword = generateUniquePassword();
const resetResp = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
.post('/auth/reset')
.body({token, password: newPassword})
.execute();
expect(resetResp.token.length).toBeGreaterThan(0);
await createBuilderWithoutAuth(harness)
.post('/auth/reset')
.body({token, password: generateUniquePassword()})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
it('rejects invalid reset token', async () => {
await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
@@ -14,11 +14,19 @@ import {
import {createUserID} from '@app/api/BrandedTypes';
import {getConfig} from '@app/api/Config';
import {applySharedListUpdate, resetSharedListsForTests} from '@app/api/infrastructure/activity/SharedLists';
import {getInstanceConfigRepository, getUserRepository} from '@app/api/middleware/ServiceSingletons';
import {
getInstanceConfigRepository,
getRateLimitService,
getUserRepository,
} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import {ms} from 'itty-time';
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
function bootstrapRegistrationBody(prefix: string): Record<string, unknown> {
return {
@@ -57,6 +65,37 @@ async function withBootstrapAdminConfig(
}
}
const SETUP_TOKEN = 'setup-token-for-tests-0123456789-abcdefghijklmnopqrstuvwxyz';
const WRONG_SETUP_TOKEN = 'wrong-setup-token-0123456789-abcdefghijklmnopqrstuvwxyz';
const SETUP_TOKEN_HASH_KEY = 'setup_token_hash';
const TOO_MANY_REQUESTS = 429;
interface SetupTokenRegistrationResponse {
user_id?: string;
token?: string;
registration_pending_approval?: boolean;
code?: string;
errors?: Array<{path: string; code: string}>;
}
async function withSetupTokenInstance(callback: () => Promise<void>): Promise<void> {
const config = getConfig();
const originalSelfHosted = config.instance.selfHosted;
const originalSetupToken = config.instance.setup.token;
const originalSetupConfigured = config.instance.setup.configured;
const originalRelaxRegistrationRateLimits = config.dev.relaxRegistrationRateLimits;
try {
config.instance.selfHosted = true;
config.instance.setup.token = SETUP_TOKEN;
await callback();
} finally {
config.instance.selfHosted = originalSelfHosted;
config.instance.setup.token = originalSetupToken;
config.instance.setup.configured = originalSetupConfigured;
config.dev.relaxRegistrationRateLimits = originalRelaxRegistrationRateLimits;
}
}
async function expectUserACLs(userId: string, expectedACLs: Array<string>): Promise<void> {
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(userId)));
expect([...user.acls].sort()).toEqual([...expectedACLs].sort());
@@ -123,31 +162,45 @@ describe('Auth registration', () => {
await expect(getInstanceConfigRepository().isAdminBootstrapped()).resolves.toBe(true);
});
});
it('allows setup-open sessions to fetch instance config when bootstrap marker is stale', async () => {
it('lets exactly one concurrent caller claim the admin bootstrap', async () => {
const instanceConfigRepository = getInstanceConfigRepository();
const claims = await Promise.all(Array.from({length: 5}, () => instanceConfigRepository.claimAdminBootstrap()));
expect(claims.filter(Boolean)).toHaveLength(1);
await expect(instanceConfigRepository.isAdminBootstrapped()).resolves.toBe(true);
await expect(instanceConfigRepository.claimAdminBootstrap()).resolves.toBe(false);
});
it('keeps instance config closed to sessions without admin ACLs while setup is open', async () => {
await withBootstrapAdminConfig({selfHosted: false, testModeEnabled: true}, async () => {
const instanceConfigRepository = getInstanceConfigRepository();
await instanceConfigRepository.setAppPublicConfig({setup: {configured: false}});
await instanceConfigRepository.markAdminBootstrapped();
const account = await registerUser(harness, bootstrapRegistrationBody('stalesetupmarker'));
await instanceConfigRepository.claimAdminBootstrap();
const account = await registerUser(harness, bootstrapRegistrationBody('setupopennonadmin'));
await expectUserACLs(account.user_id, []);
await createBuilder(harness, account.token).get('/admin/instance/config').execute();
await createBuilder(harness, account.token).get('/admin/instance/config').expect(HTTP_STATUS.FORBIDDEN).execute();
await createBuilder(harness, account.token)
.patch('/admin/instance/config')
.body({app_public: {setup: {configured: true}}})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
await expectUserACLs(account.user_id, []);
await expect(instanceConfigRepository.getAppPublicConfig()).resolves.toMatchObject({
setup: {configured: false},
});
});
});
it('repairs setup completer admin ACL when bootstrap marker is stale', async () => {
it('lets the bootstrap admin complete setup', async () => {
await withBootstrapAdminConfig({selfHosted: false, testModeEnabled: true}, async () => {
const instanceConfigRepository = getInstanceConfigRepository();
await instanceConfigRepository.setAppPublicConfig({setup: {configured: false}});
await instanceConfigRepository.markAdminBootstrapped();
const account = await registerUser(harness, bootstrapRegistrationBody('stalesetupcomplete'));
await expectUserACLs(account.user_id, []);
const account = await registerUser(harness, bootstrapRegistrationBody('setupopenowner'));
await expectUserACLs(account.user_id, [AdminACLs.WILDCARD]);
await createBuilder(harness, account.token)
.patch('/admin/instance/config')
.body({app_public: {setup: {configured: true}}})
.execute();
await expectUserACLs(account.user_id, [AdminACLs.WILDCARD]);
await createBuilder(harness, account.token).get('/admin/instance/config').execute();
await expect(instanceConfigRepository.getAppPublicConfig()).resolves.toMatchObject({
setup: {configured: true},
});
});
});
it('derives username from display name when username is omitted', async () => {
@@ -237,3 +290,303 @@ describe('Auth registration', () => {
expect(me.email).toBe('[email protected]');
});
});
describe('Setup token registration', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function register(
body: Record<string, unknown>,
ipAddress?: string,
): Promise<{status: number; json: SetupTokenRegistrationResponse}> {
const builder = createBuilderWithoutAuth<SetupTokenRegistrationResponse>(harness).post('/auth/register').body(body);
if (ipAddress) builder.header('x-forwarded-for', ipAddress);
const {response, json} = await builder.executeRaw();
return {status: response.status, json};
}
function setupTokenErrors(json: SetupTokenRegistrationResponse): Array<{path: string; code: string}> {
return (json.errors ?? []).map(({path, code}) => ({path, code}));
}
async function registerAdmin(prefix: string, ipAddress?: string): Promise<string> {
const {status, json} = await register({...bootstrapRegistrationBody(prefix), setup_token: SETUP_TOKEN}, ipAddress);
expect(status).toBe(HTTP_STATUS.OK);
return json.user_id!;
}
it('refuses a registration that sends no setup token', async () => {
await withSetupTokenInstance(async () => {
const body = bootstrapRegistrationBody('tokenless');
const {status, json} = await register(body);
expect(status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(setupTokenErrors(json)).toEqual([{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_REQUIRED}]);
expect(await getUserRepository().findByEmail(body.email as string)).toBeNull();
await expect(getInstanceConfigRepository().isAdminBootstrapped()).resolves.toBe(false);
});
});
it('refuses a registration that sends a wrong setup token', async () => {
await withSetupTokenInstance(async () => {
const body = bootstrapRegistrationBody('wrongtoken');
const {status, json} = await register({...body, setup_token: WRONG_SETUP_TOKEN});
expect(status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(setupTokenErrors(json)).toEqual([{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_INVALID}]);
expect(await getUserRepository().findByEmail(body.email as string)).toBeNull();
await expect(getInstanceConfigRepository().isAdminBootstrapped()).resolves.toBe(false);
});
});
it('makes the registration that presents the setup token the admin', async () => {
await withSetupTokenInstance(async () => {
const userId = await registerAdmin('tokenadmin');
await expectUserACLs(userId, [AdminACLs.WILDCARD]);
await expect(getInstanceConfigRepository().isAdminBootstrapped()).resolves.toBe(true);
expect((await getInstanceConfigRepository().getConfig(SETUP_TOKEN_HASH_KEY)) ?? '').toBe('');
});
});
it('accepts a setup token padded with whitespace', async () => {
await withSetupTokenInstance(async () => {
const {status, json} = await register({
...bootstrapRegistrationBody('paddedtoken'),
setup_token: ` ${SETUP_TOKEN}\n`,
});
expect(status).toBe(HTTP_STATUS.OK);
await expectUserACLs(json.user_id!, [AdminACLs.WILDCARD]);
});
});
it('ignores the setup token once the admin exists', async () => {
await withSetupTokenInstance(async () => {
await registerAdmin('firstadmin');
const tokenless = await register(bootstrapRegistrationBody('aftertokenless'));
expect(tokenless.status).toBe(HTTP_STATUS.OK);
await expectUserACLs(tokenless.json.user_id!, []);
const wrong = await register({...bootstrapRegistrationBody('afterwrong'), setup_token: WRONG_SETUP_TOKEN});
expect(wrong.status).toBe(HTTP_STATUS.OK);
await expectUserACLs(wrong.json.user_id!, []);
const repeated = await register({...bootstrapRegistrationBody('afterrepeat'), setup_token: SETUP_TOKEN});
expect(repeated.status).toBe(HTTP_STATUS.OK);
await expectUserACLs(repeated.json.user_id!, []);
});
});
it('refuses an unclaimed registration that sends no setup token', async () => {
await withSetupTokenInstance(async () => {
const {status, json} = await register({global_name: 'Unclaimed Visitor'});
expect(status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(setupTokenErrors(json)).toEqual([{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_REQUIRED}]);
});
});
it('creates a plain account for an unclaimed registration that presents the setup token', async () => {
await withSetupTokenInstance(async () => {
const {status, json} = await register({global_name: 'Unclaimed Operator', setup_token: SETUP_TOKEN});
expect(status).toBe(HTTP_STATUS.OK);
await expectUserACLs(json.user_id!, []);
await expect(getInstanceConfigRepository().isAdminBootstrapped()).resolves.toBe(false);
const userId = await registerAdmin('claimedoperator');
await expectUserACLs(userId, [AdminACLs.WILDCARD]);
});
});
it.each(['approval', 'closed'] as const)(
'lets the setup token holder register as admin while registration is %s',
async (mode) => {
await withSetupTokenInstance(async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode});
const {status, json} = await register({...bootstrapRegistrationBody(`mode${mode}`), setup_token: SETUP_TOKEN});
expect(status).toBe(HTTP_STATUS.OK);
expect(json.registration_pending_approval).toBeUndefined();
expect(json.token).toBeTruthy();
await expectUserACLs(json.user_id!, [AdminACLs.WILDCARD]);
await expect(getInstanceConfigRepository().getPendingRegistrations()).resolves.toEqual([]);
});
},
);
it('reports the missing setup token before any other registration check', async () => {
await withSetupTokenInstance(async () => {
await getInstanceConfigRepository().setAppPublicConfig({legal: {terms_url: 'https://example.com/terms'}});
const body = {...bootstrapRegistrationBody('noconsent'), consent: false};
const tokenless = await register(body);
expect(tokenless.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(setupTokenErrors(tokenless.json)).toEqual([
{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_REQUIRED},
]);
const verified = await register({...body, setup_token: SETUP_TOKEN});
expect(verified.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(setupTokenErrors(verified.json)).toEqual([
{path: 'consent', code: ValidationErrorCodes.MUST_AGREE_TO_TOS_AND_PRIVACY_POLICY},
]);
await expect(getInstanceConfigRepository().isAdminBootstrapped()).resolves.toBe(false);
});
});
it('locks an address out after ten wrong setup tokens', async () => {
await withSetupTokenInstance(async () => {
getConfig().dev.relaxRegistrationRateLimits = false;
const attacker = '203.0.113.10';
const operator = '203.0.113.20';
for (let attempt = 0; attempt < 3; attempt++) {
const tokenless = await register(bootstrapRegistrationBody(`limitmissing${attempt}`), attacker);
expect(setupTokenErrors(tokenless.json)).toEqual([
{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_REQUIRED},
]);
}
for (let attempt = 0; attempt < 10; attempt++) {
const wrong = await register(
{...bootstrapRegistrationBody(`limitwrong${attempt}`), setup_token: WRONG_SETUP_TOKEN},
attacker,
);
expect(wrong.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(setupTokenErrors(wrong.json)).toEqual([
{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_INVALID},
]);
}
const eleventh = await register(
{...bootstrapRegistrationBody('limiteleventh'), setup_token: WRONG_SETUP_TOKEN},
attacker,
);
expect(eleventh.status).toBe(TOO_MANY_REQUESTS);
const lockedOut = await register(
{...bootstrapRegistrationBody('limitlocked'), setup_token: SETUP_TOKEN},
attacker,
);
expect(lockedOut.status).toBe(TOO_MANY_REQUESTS);
await expect(getInstanceConfigRepository().isAdminBootstrapped()).resolves.toBe(false);
const userId = await registerAdmin('limitoperator', operator);
await expectUserACLs(userId, [AdminACLs.WILDCARD]);
});
});
it('lets the setup token holder past the registration limiter of their address', async () => {
await withSetupTokenInstance(async () => {
getConfig().dev.relaxRegistrationRateLimits = false;
const operator = '203.0.113.30';
const registrationLimit = {
identifier: `registration:ip:${getSameIpDecisionKey(operator) ?? operator}`,
maxAttempts: 3,
windowMs: ms('1 hour'),
};
for (let attempt = 0; attempt < 3; attempt++) {
await getRateLimitService().checkLimit(registrationLimit);
}
const userId = await registerAdmin('limitedoperator', operator);
await expectUserACLs(userId, [AdminACLs.WILDCARD]);
const next = await register(bootstrapRegistrationBody('limitedmember'), operator);
expect(next.status).toBe(TOO_MANY_REQUESTS);
});
});
it('ties the admin grant to the setup token on an instance configured through the environment', async () => {
await withSetupTokenInstance(async () => {
getConfig().instance.setup.configured = true;
const tokenless = await register(bootstrapRegistrationBody('envtokenless'));
expect(tokenless.status).toBe(HTTP_STATUS.OK);
await expectUserACLs(tokenless.json.user_id!, []);
await expect(getInstanceConfigRepository().isAdminBootstrapped()).resolves.toBe(false);
const wrong = await register({...bootstrapRegistrationBody('envwrong'), setup_token: WRONG_SETUP_TOKEN});
expect(wrong.status).toBe(HTTP_STATUS.BAD_REQUEST);
expect(setupTokenErrors(wrong.json)).toEqual([
{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_INVALID},
]);
const userId = await registerAdmin('envadmin');
await expectUserACLs(userId, [AdminACLs.WILDCARD]);
await expect(getInstanceConfigRepository().isAdminBootstrapped()).resolves.toBe(true);
});
});
it('ties the admin grant to the setup token on an instance configured in storage', async () => {
await withSetupTokenInstance(async () => {
await getInstanceConfigRepository().setAppPublicConfig({setup: {configured: true}});
const tokenless = await register(bootstrapRegistrationBody('storedtokenless'));
expect(tokenless.status).toBe(HTTP_STATUS.OK);
await expectUserACLs(tokenless.json.user_id!, []);
const userId = await registerAdmin('storedadmin');
await expectUserACLs(userId, [AdminACLs.WILDCARD]);
});
});
it('leaves the hosted service alone even with a setup token configured', async () => {
await withSetupTokenInstance(async () => {
getConfig().instance.selfHosted = false;
await getInstanceConfigRepository().setAppPublicConfig({setup: {configured: false}});
const {status, json} = await register(bootstrapRegistrationBody('hostedtokenless'));
expect(status).toBe(HTTP_STATUS.OK);
await expectUserACLs(json.user_id!, [AdminACLs.WILDCARD]);
});
});
it('releases the admin claim when the account cannot be created', async () => {
await withSetupTokenInstance(async () => {
const create = vi.spyOn(getUserRepository(), 'create').mockRejectedValueOnce(new Error('insert failed'));
try {
const failed = await register({...bootstrapRegistrationBody('failedadmin'), setup_token: SETUP_TOKEN});
expect(failed.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect(create).toHaveBeenCalledTimes(1);
} finally {
create.mockRestore();
}
await expect(getInstanceConfigRepository().isAdminBootstrapped()).resolves.toBe(false);
const tokenless = await register(bootstrapRegistrationBody('failedtokenless'));
expect(setupTokenErrors(tokenless.json)).toEqual([
{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_REQUIRED},
]);
const userId = await registerAdmin('retriedadmin');
await expectUserACLs(userId, [AdminACLs.WILDCARD]);
await expect(getInstanceConfigRepository().getConfig('admin_bootstrap_user_id')).resolves.toBe(userId);
});
});
it('burns a generated setup token once the admin exists', async () => {
await withSetupTokenInstance(async () => {
const config = getConfig();
const originalNodeEnv = config.nodeEnv;
config.instance.setup.token = null;
config.nodeEnv = 'production';
try {
const repository = getInstanceConfigRepository();
const generated = await repository.createSetupTokenIfAbsent();
expect(generated).not.toBeNull();
const wrong = await register({...bootstrapRegistrationBody('genwrong'), setup_token: SETUP_TOKEN});
expect(setupTokenErrors(wrong.json)).toEqual([
{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_INVALID},
]);
const {status, json} = await register({...bootstrapRegistrationBody('genadmin'), setup_token: generated});
expect(status).toBe(HTTP_STATUS.OK);
await expectUserACLs(json.user_id!, [AdminACLs.WILDCARD]);
expect(await repository.getConfig(SETUP_TOKEN_HASH_KEY)).toBe('');
await expect(repository.verifySetupToken(generated)).resolves.toBe(false);
} finally {
config.nodeEnv = originalNodeEnv;
}
});
});
it('burns the stored setup token when an admin marks setup as configured', async () => {
await withBootstrapAdminConfig({selfHosted: false, testModeEnabled: true}, async () => {
const repository = getInstanceConfigRepository();
await repository.setAppPublicConfig({setup: {configured: false}});
const account = await registerUser(harness, bootstrapRegistrationBody('configuringadmin'));
await repository.releaseAdminBootstrap();
const generated = await repository.createSetupTokenIfAbsent();
await expect(repository.verifySetupToken(generated)).resolves.toBe(true);
await createBuilder(harness, account.token)
.patch('/admin/instance/config')
.body({app_public: {setup: {configured: true}}})
.execute();
await expect(repository.isAdminBootstrapped()).resolves.toBe(true);
await expect(repository.verifySetupToken(generated)).resolves.toBe(false);
});
});
});
+1
View File
@@ -283,6 +283,7 @@ export interface APIConfig {
};
setup: {
configured: boolean;
token: string | null;
};
accountIdentity: AccountIdentityMode | null;
tagStyle: TagStyle | null;
@@ -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();
@@ -47,6 +47,7 @@ import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigReposito
import {type LegalUrls, resolveLegalUrls} from '@app/api/instance/LegalUrls';
import {NatsConfigPublisher} from '@app/api/instance/NatsConfigPublisher';
import {getInstanceProductName} from '@app/api/instance/ProductName';
import {ensureSetupToken} from '@app/api/instance/SetupToken';
import {InviteRepository} from '@app/api/invite/InviteRepository';
import {Logger} from '@app/api/Logger';
import {LimitConfigService} from '@app/api/limits/LimitConfigService';
@@ -482,6 +483,11 @@ export async function initializeServiceSingletons(): Promise<void> {
assertServiceSingletonInitializationActive(owner);
await getInstanceConfigRepository().ensureAccountIdentityMode();
assertServiceSingletonInitializationActive(owner);
await ensureSetupToken(
getInstanceConfigRepository(),
async (userId) => (await getUserRepository().findUnique(userId)) !== null,
);
assertServiceSingletonInitializationActive(owner);
await limitConfigService.initialize();
assertServiceSingletonInitializationActive(owner);
limitConfigService.setAsGlobalInstance();
+13 -1
View File
@@ -11390,7 +11390,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"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.",
"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. While the instance requires a setup token, the request must send it as setup_token.",
"requestBody": {
"required": true,
"content": {
@@ -29472,6 +29472,10 @@
"tag_style": {
"description": "How usernames are tagged. Defaults to none. Username sign-in accepts only none",
"$ref": "#/components/schemas/TagStyleSchema"
},
"setup_token": {
"description": "Setup token of a new self-hosted instance. Required until the first admin account exists",
"type": ["string", "null"]
}
},
"required": ["mode"]
@@ -32830,6 +32834,10 @@
],
"enum": ["dark", "dark_legacy", "coal", "light", "system"],
"type": "string"
},
"setup_token": {
"description": "Setup token of a new self-hosted instance. Required until the first admin account exists",
"type": ["string", "null"]
}
}
},
@@ -33569,6 +33577,10 @@
"account_identity_locked": {
"description": "Present only while a self-hosted instance is unconfigured. True when the sign-in method can no longer change",
"type": "boolean"
},
"setup_token_required": {
"description": "Present only while a self-hosted instance is unconfigured. True when registration needs the setup token",
"type": "boolean"
}
},
"required": ["configured", "admin_url"],
@@ -6,7 +6,7 @@ import {afterAll, beforeAll, describe, expect, test} from 'vitest';
// Mirrors the live BRL/TRY configuration after the repricing: the BRL slots point at the new list
// prices, TRY is not configured at all (production has no TRY prices), and every retired price id
// lives only in the legacy map.
// is only in the legacy map.
const MOCK_PRICES = {
monthlyUsd: 'price_monthly_usd',
monthlyBrl: 'price_1TMbqsFPC94Os7FdfElrIIaZ',