mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(setup): add a setup token for first-run setup (#3437)
This commit is contained in:
1 parent
8e39380ee6
commit
78272521bb
135 files changed
+3045
-771
No files matched your search
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Generated
+13
-1
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user