refactor(api): tidy the api and shared packages (#2733)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-13 17:37:48 +02:00
1 parent 580401d2dc
commit 5afbf67a70
333 files changed
+10180 -7910

No files matched your search

+2 -1
View File
@@ -343,7 +343,8 @@ export function AuthController(app: HonoApp) {
}),
async (ctx) => {
const userId = ctx.get('user').id;
return ctx.json(await ctx.get('authRequestService').getAuthSessions(userId));
const currentSessionIdHash = ctx.get('authSession')?.sessionIdHash;
return ctx.json(await ctx.get('authRequestService').getAuthSessions(userId, currentSessionIdHash));
},
);
app.post(
+5 -9
View File
@@ -7,9 +7,7 @@ import type {VerifyEmailRequest} from '@fluxer/schema/src/domains/auth/AuthSchem
import {ms} from 'itty-time';
import type {ApiContext} from '../ApiContext';
import {createEmailVerificationToken} from '../BrandedTypes';
import {Logger} from '../Logger';
import type {User} from '../models/User';
import {getUserSearchService} from '../SearchFactory';
import {mapUserToPrivateResponse} from '../user/UserMappers';
import * as RandomUtils from '../utils/RandomUtils';
@@ -38,7 +36,11 @@ export async function verifyEmail(ctx: ApiContext, data: VerifyEmailRequest): Pr
return false;
}
assertNonBotUser(user);
if (user.flags & UserFlags.DELETED) {
if (
user.flags & UserFlags.DELETED ||
!user.email ||
user.email.trim().toLowerCase() !== tokenData.email.trim().toLowerCase()
) {
return false;
}
const updates: {
@@ -57,12 +59,6 @@ export async function verifyEmail(ctx: ApiContext, data: VerifyEmailRequest): Pr
}
const updatedUser = await users.patchUpsert(user.id, updates, user.toRow());
await users.deleteEmailVerificationToken(data.token);
const userSearchService = getUserSearchService();
if (userSearchService && 'updateUser' in userSearchService) {
await userSearchService.updateUser(updatedUser).catch((error) => {
Logger.error({userId: user.id, error}, 'Failed to update user in search');
});
}
await gateway.dispatchPresence({
userId: user.id,
event: 'USER_UPDATE',
+1 -10
View File
@@ -5,9 +5,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import type {ApiContext} from '../ApiContext';
import {createEmailRevertToken} from '../BrandedTypes';
import {Logger} from '../Logger';
import type {User} from '../models/User';
import {getUserSearchService} from '../SearchFactory';
import {mapUserToPrivateResponse} from '../user/UserMappers';
import * as AuthPassword from './AuthPassword';
import * as AuthSession from './AuthSession';
@@ -75,6 +73,7 @@ export async function revertEmailChange(
},
user.toRow(),
);
await users.deleteAllPasswordResetTokens(user.id);
await users.deleteEmailRevertToken(token);
await users.deleteAllMfaBackupCodes(user.id);
await users.deleteAllWebAuthnCredentials(user.id);
@@ -87,14 +86,6 @@ export async function revertEmailChange(
clientIpHeaderName: config.proxy.client_ip_header,
}),
);
const userSearchService = getUserSearchService();
if (userSearchService && updatedUser && 'updateUser' in userSearchService) {
await userSearchService
.updateUser(updatedUser)
.catch((error) =>
Logger.debug({error, userId: updatedUser.id}, 'Failed to update search index after email revert'),
);
}
await gateway.dispatchPresence({
userId: updatedUser.id,
event: 'USER_UPDATE',
+14 -30
View File
@@ -8,13 +8,11 @@ import {IpAuthorizationResendLimitExceededError} from '@fluxer/errors/src/domain
import {RegistrationPendingApprovalError} from '@fluxer/errors/src/domains/auth/RegistrationPendingApprovalError';
import {RegistrationRejectedError} from '@fluxer/errors/src/domains/auth/RegistrationRejectedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {LoginRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {formatGeoipLocation, UNKNOWN_LOCATION} from '@pkgs/geoip/src/GeoipLookup';
import type {RateLimitResult} from '@pkgs/rate_limit/src/IRateLimitService';
import type {AuthenticationResponseJSON} from '@simplewebauthn/server';
import {ms, seconds} from 'itty-time';
import type {ApiContext} from '../ApiContext';
@@ -33,6 +31,7 @@ import {createRequestCache} from '../middleware/RequestCacheMiddleware';
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
import type {User} from '../models/User';
import {lookupGeoip} from '../utils/IpUtils';
import {createRateLimitError} from '../utils/RateLimitUtils';
import * as AuthMfa from './AuthMfa';
import * as AuthPassword from './AuthPassword';
import * as AuthSession from './AuthSession';
@@ -79,18 +78,6 @@ interface LoginMfaResult {
type LoginResult = LoginTokenResult | LoginMfaResult;
function getRetryAfterSeconds(result: RateLimitResult): number {
return result.retryAfter ?? Math.max(0, Math.ceil((result.resetTime.getTime() - Date.now()) / 1000));
}
function throwLoginRateLimit(result: RateLimitResult): never {
throw new RateLimitError({
retryAfter: getRetryAfterSeconds(result),
limit: result.limit,
resetTime: result.resetTime,
});
}
export interface IpAuthorizationTicketCache {
userId: string;
email: string;
@@ -202,7 +189,7 @@ export async function login(
windowMs: ms('15 minutes'),
});
if (!emailRateLimit.allowed && !skipRateLimits) {
throwLoginRateLimit(emailRateLimit);
throw createRateLimitError(emailRateLimit);
}
const clientIp = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
@@ -214,7 +201,7 @@ export async function login(
windowMs: ms('30 minutes'),
});
if (!ipRateLimit.allowed && !skipRateLimits) {
throwLoginRateLimit(ipRateLimit);
throw createRateLimitError(ipRateLimit);
}
const user = await users.findByEmail(data.email);
if (!user) {
@@ -254,22 +241,19 @@ export async function login(
Logger.info({userId: currentUser.id}, 'Auto-undisabled user on login');
}
if ((currentUser.flags & UserFlags.SELF_DELETED) !== 0n) {
if (currentUser.pendingDeletionAt) {
await users.removePendingDeletion(currentUser.id, currentUser.pendingDeletionAt);
const pendingDeletionAt = currentUser.pendingDeletionAt;
const updatedFlags = currentUser.flags & ~UserFlags.SELF_DELETED;
currentUser = await users.updateDeletionSchedule(currentUser, {
flags: updatedFlags,
pending_deletion_at: null,
deletion_reason_code: null,
deletion_public_reason: null,
deletion_audit_log_reason: null,
});
if (pendingDeletionAt) {
await users.removePendingDeletion(currentUser.id, pendingDeletionAt);
}
await kvDeletionQueue.removeFromQueue(currentUser.id);
const updatedFlags = currentUser.flags & ~UserFlags.SELF_DELETED;
currentUser = await users.patchUpsert(
currentUser.id,
{
flags: updatedFlags,
pending_deletion_at: null,
deletion_reason_code: null,
deletion_public_reason: null,
deletion_audit_log_reason: null,
},
currentUser.toRow(),
);
Logger.info({userId: currentUser.id}, 'Auto-cancelled deletion on login');
}
if (currentUser.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) {
-13
View File
@@ -27,7 +27,6 @@ import {createUserID, type UserID} from '../BrandedTypes';
import {Logger} from '../Logger';
import type {User} from '../models/User';
import type {WebAuthnCredential} from '../models/WebAuthnCredential';
import {getUserSearchService} from '../SearchFactory';
import {mapUserToPrivateResponse} from '../user/UserMappers';
import {TotpGenerator} from '../utils/TotpGenerator';
import {deriveSudoMethods, userHasMfa} from './services/SudoMethods';
@@ -217,12 +216,6 @@ export async function verifyWebAuthnRegistration(
if (!authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
const userSearchService = getUserSearchService();
if (userSearchService && 'updateUser' in userSearchService) {
await userSearchService.updateUser(updatedUser).catch((error) => {
Logger.error({userId, error}, 'Failed to update user in search');
});
}
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
}
@@ -242,12 +235,6 @@ export async function deleteWebAuthnCredential(ctx: ApiContext, userId: UserID,
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
const userSearchService = getUserSearchService();
if (userSearchService && 'updateUser' in userSearchService) {
await userSearchService.updateUser(updatedUser).catch((error) => {
Logger.error({userId, error}, 'Failed to update user in search');
});
}
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
}
+3 -3
View File
@@ -28,10 +28,10 @@ async function resolveAuthSessionLocation(session: AuthSession): Promise<string
export async function mapAuthSessionsToResponse({
authSessions,
currentSessionId,
currentSessionIdHash,
}: {
authSessions: Array<AuthSession>;
currentSessionId?: Uint8Array;
currentSessionIdHash?: Uint8Array;
}): Promise<Array<AuthSessionResponse>> {
const sortedSessions = authSessions.toSorted((a, b) => {
const aTime = a.approximateLastUsedAt?.getTime() || 0;
@@ -51,7 +51,7 @@ export async function mapAuthSessionsToResponse({
productName: branding.product_name,
});
const idHash = uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true});
const isCurrent = currentSessionId ? Buffer.compare(authSession.sessionIdHash, currentSessionId) === 0 : false;
const isCurrent = currentSessionIdHash ? authSession.sessionIdHash.equals(currentSessionIdHash) : false;
return {
id_hash: idHash,
client_info: {
+21 -20
View File
@@ -5,7 +5,6 @@ import {FLUXER_USER_AGENT} from '@fluxer/constants/src/Core';
import {UserAuthenticatorTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {ForgotPasswordRequest, ResetPasswordRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
@@ -17,6 +16,7 @@ import type {User} from '../models/User';
import {EXTERNAL_RESPONSE_LIMITS} from '../utils/ExternalResponseLimits';
import * as FetchUtils from '../utils/FetchUtils';
import {hashPassword as hashPasswordUtil, verifyPassword as verifyPasswordUtil} from '../utils/PasswordUtils';
import {createRateLimitError} from '../utils/RateLimitUtils';
import * as AuthSession from './AuthSession';
import * as AuthUtility from './AuthUtility';
@@ -132,6 +132,7 @@ export async function isPasswordPwned(_ctx: ApiContext, password: string): Promi
signal: AbortSignal.timeout(PWNED_PASSWORDS_TIMEOUT_MS),
});
if (!response.ok) {
FetchUtils.discardResponseBody(response.body, response.status);
Logger.warn(
{
status: response.status,
@@ -183,29 +184,21 @@ export async function forgotPassword(ctx: ApiContext, {data, request}: ForgotPas
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const ipLimitConfig = {maxAttempts: 20, windowMs: ms('30 minutes')};
const emailLimitConfig = {maxAttempts: 5, windowMs: ms('30 minutes')};
const ipRateLimit = await rateLimit.checkLimit({
identifier: `password_reset:ip:${getSameIpDecisionKey(clientIp) ?? clientIp}`,
...ipLimitConfig,
maxAttempts: 20,
windowMs: ms('30 minutes'),
});
const emailRateLimit = await rateLimit.checkLimit({
identifier: `password_reset:email:${data.email.toLowerCase()}`,
...emailLimitConfig,
maxAttempts: 5,
windowMs: ms('30 minutes'),
});
const exceeded = !ipRateLimit.allowed
? {result: ipRateLimit, config: ipLimitConfig}
: !emailRateLimit.allowed
? {result: emailRateLimit, config: emailLimitConfig}
: null;
if (exceeded) {
const retryAfter =
exceeded.result.retryAfter ?? Math.max(0, Math.ceil((exceeded.result.resetTime.getTime() - Date.now()) / 1000));
throw new RateLimitError({
retryAfter,
limit: exceeded.result.limit,
resetTime: exceeded.result.resetTime,
});
if (!ipRateLimit.allowed) {
throw createRateLimitError(ipRateLimit);
}
if (!emailRateLimit.allowed) {
throw createRateLimitError(emailRateLimit);
}
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(data.email);
if (!hasValidDns) {
@@ -235,7 +228,11 @@ export async function validateResetToken(ctx: ApiContext, token: string): Promis
if (!user) {
return false;
}
if (user.flags & UserFlags.DELETED) {
if (
user.flags & UserFlags.DELETED ||
!user.email ||
user.email.trim().toLowerCase() !== tokenData.email.trim().toLowerCase()
) {
return false;
}
return true;
@@ -255,7 +252,11 @@ export async function resetPassword(
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN);
}
AuthUtility.assertNonBotUser(ctx, user);
if (user.flags & UserFlags.DELETED) {
if (
user.flags & UserFlags.DELETED ||
!user.email ||
user.email.trim().toLowerCase() !== tokenData.email.trim().toLowerCase()
) {
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN);
}
await AuthUtility.handleBanStatus(ctx, user);
-7
View File
@@ -33,7 +33,6 @@ import type {ApiContext} from '../ApiContext';
import type {UserID} from '../BrandedTypes';
import {Logger} from '../Logger';
import type {User} from '../models/User';
import {getUserSearchService} from '../SearchFactory';
import {mapUserToPartialResponse, mapUserToPrivateResponse} from '../user/UserMappers';
import {phonePrefixBanCache} from './PhonePrefixBanCache';
import {requiresInboundPhoneVerification} from './PhoneVerificationPrefixPolicy';
@@ -367,12 +366,6 @@ async function attachVerifiedPhoneToAccount(ctx: ApiContext, userId: UserID, pho
reason: 'user_requested',
actorUserId: userId,
});
const userSearchService = getUserSearchService();
if (userSearchService && 'updateUser' in userSearchService) {
await userSearchService.updateUser(updatedUser).catch((error) => {
Logger.error({userId, error}, 'Failed to update user in search index');
});
}
await gateway.dispatchPresence({
userId,
event: 'USER_UPDATE',
+7 -31
View File
@@ -7,12 +7,10 @@ import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/Registrat
import {RegistrationUrlInvalidError} from '@fluxer/errors/src/domains/auth/RegistrationUrlInvalidError';
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {getSameIpDecisionKey, getSubnet} from '@fluxer/ip_utils/src/IpAddress';
import type {RegisterRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {parseAcceptLanguage} from '@pkgs/locale/src/LocaleService';
import type {RateLimitResult} from '@pkgs/rate_limit/src/IRateLimitService';
import {types} from 'cassandra-driver';
import {ms} from 'itty-time';
import type {ApiContext} from '../ApiContext';
@@ -43,10 +41,10 @@ import {deferPhoneFlagsUntilCommunityJoin} from '../risk/DeferredPhoneGate';
import type {IRiskHistoryRepository} from '../risk/HistoricalOutcomeRepository';
import type {IRiskAssessmentRepository} from '../risk/RiskAssessmentRepository';
import {deriveLatestRiskContext} from '../risk/RiskHistoryContext';
import {getUserSearchService} from '../SearchFactory';
import * as AgeUtils from '../utils/AgeUtils';
import {extractEmailDomain} from '../utils/EmailDomainUtils';
import {lookupGeoip} from '../utils/IpUtils';
import {createRateLimitError} from '../utils/RateLimitUtils';
import {generateRandomUsername} from '../utils/UsernameGenerator';
import {deriveUsernameFromDisplayName} from '../utils/UsernameSuggestionUtils';
import * as AuthPassword from './AuthPassword';
@@ -56,18 +54,6 @@ import type {IRegistrationRiskEvaluator} from './services/IRegistrationRiskEvalu
const DEFAULT_MINIMUM_AGE = 13;
function getRetryAfterSeconds(result: RateLimitResult): number {
return result.retryAfter ?? Math.max(0, Math.ceil((result.resetTime.getTime() - Date.now()) / 1000));
}
function throwRegistrationRateLimit(result: RateLimitResult): never {
throw new RateLimitError({
retryAfter: getRetryAfterSeconds(result),
limit: result.limit,
resetTime: result.resetTime,
});
}
function parseDobLocalDate(dateOfBirth: string): types.LocalDate {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateOfBirth);
if (!match) {
@@ -149,6 +135,9 @@ export async function register(
}
const now = new Date();
const registrationAccess = await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
if (registrationAccess.pendingApproval) {
await instanceConfigRepository.getPendingRegistrations();
}
const clientIp = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
@@ -410,7 +399,6 @@ export async function register(
);
}
}
await maybeIndexUser(user);
if (rawEmail && emailEnabled) await maybeSendVerificationEmail(ctx, {user, email: rawEmail});
await users.createAuthorizedIp(userId, clientIp);
if (registrationAccess.registrationUrl) {
@@ -506,18 +494,6 @@ async function resolveRegistrationAccess(
};
}
async function maybeIndexUser(user: User): Promise<void> {
const userSearchService = getUserSearchService();
if (!userSearchService) return;
if ('indexUser' in userSearchService) {
try {
await userSearchService.indexUser(user);
} catch (error) {
Logger.error({userId: user.id, error}, 'Failed to index user in search');
}
}
}
async function maybeSendVerificationEmail(ctx: ApiContext, params: {user: User; email: string}): Promise<void> {
const {users, email: emailService} = ctx.services;
const {user, email} = params;
@@ -570,14 +546,14 @@ async function enforceRegistrationRateLimits(
maxAttempts: 3,
windowMs: ms('15 minutes'),
});
if (!emailRateLimit.allowed) throwRegistrationRateLimit(emailRateLimit);
if (!emailRateLimit.allowed) throw createRateLimitError(emailRateLimit);
}
const ipRateLimit = await rateLimit.checkLimit({
identifier: `registration:ip:${getSameIpDecisionKey(clientIp) ?? clientIp}`,
maxAttempts: 3,
windowMs: ms('1 hour'),
});
if (!ipRateLimit.allowed) throwRegistrationRateLimit(ipRateLimit);
if (!ipRateLimit.allowed) throw createRateLimitError(ipRateLimit);
const subnet = getSubnet(clientIp);
if (subnet) {
const subnetRateLimit = await rateLimit.checkLimit({
@@ -585,7 +561,7 @@ async function enforceRegistrationRateLimits(
maxAttempts: 15,
windowMs: ms('1 hour'),
});
if (!subnetRateLimit.allowed) throwRegistrationRateLimit(subnetRateLimit);
if (!subnetRateLimit.allowed) throw createRateLimitError(subnetRateLimit);
}
}
@@ -228,8 +228,8 @@ export class AuthRequestService {
return await this.toAuthLoginResponse(result);
}
getAuthSessions(userId: UserID): Promise<AuthSessionsResponse> {
return AuthSession.getAuthSessions(this.apiContext, userId);
getAuthSessions(userId: UserID, currentSessionIdHash?: Uint8Array): Promise<AuthSessionsResponse> {
return AuthSession.getAuthSessions(this.apiContext, userId, currentSessionIdHash);
}
async logoutAuthSessions({user, data}: AuthLogoutAuthSessionsRequest): Promise<void> {
+23 -48
View File
@@ -17,6 +17,7 @@ import type {User} from '../models/User';
import {lookupGeoip} from '../utils/IpUtils';
import {isFluxerNativeUserAgent, parseReportedClientOs} from '../utils/SessionClientIdentity';
import {mapAuthSessionsToResponse} from './AuthModel';
import {revokeAllAuthSessions, revokeAuthSessions} from './AuthSessionRevocation';
import * as AuthUtility from './AuthUtility';
export interface SessionOrigin {
@@ -134,10 +135,14 @@ export async function getAuthSessionByToken(ctx: ApiContext, token: string): Pro
return users.getAuthSessionByToken(Buffer.from(AuthUtility.getTokenIdHash(ctx, token)));
}
export async function getAuthSessions(ctx: ApiContext, userId: UserID): Promise<Array<AuthSessionResponse>> {
export async function getAuthSessions(
ctx: ApiContext,
userId: UserID,
currentSessionIdHash?: Uint8Array,
): Promise<Array<AuthSessionResponse>> {
const {users} = ctx.services;
const authSessions = await users.listAuthSessions(userId);
return await mapAuthSessionsToResponse({authSessions});
return await mapAuthSessionsToResponse({authSessions, currentSessionIdHash});
}
export async function updateAuthSessionLastUsed(ctx: ApiContext, tokenHash: Uint8Array): Promise<void> {
@@ -145,52 +150,28 @@ export async function updateAuthSessionLastUsed(ctx: ApiContext, tokenHash: Uint
}
export async function revokeToken(ctx: ApiContext, token: string): Promise<void> {
const {users, gateway} = ctx.services;
const {users} = ctx.services;
const tokenHash = Buffer.from(AuthUtility.getTokenIdHash(ctx, token));
const authSession = await users.getAuthSessionByToken(tokenHash);
if (!authSession) return;
const sessionIdHash = Buffer.from(authSession.sessionIdHash).toString('base64url');
await users.deletePushSubscriptionsForAuthSessions(authSession.userId, [sessionIdHash], {
deleteUnboundSubscriptions: true,
});
await gateway.invalidatePushSubscriptions({userId: authSession.userId});
await users.revokeAuthSession(tokenHash);
await gateway.terminateSession({
userId: authSession.userId,
sessionIdHashes: [sessionIdHash],
});
await revokeAuthSessions(ctx.services, authSession.userId, [
{sessionIdHash: tokenHash, encodedIdHash: encodeSessionIdHash(authSession.sessionIdHash)},
]);
}
export async function logoutAuthSessions(
ctx: ApiContext,
{user, sessionIdHashes}: LogoutAuthSessionsParams,
): Promise<void> {
const {users, gateway} = ctx.services;
const hashes = sessionIdHashes.map((hash) => Buffer.from(hash, 'base64url'));
await users.deletePushSubscriptionsForAuthSessions(user.id, sessionIdHashes, {
deleteUnboundSubscriptions: true,
});
await gateway.invalidatePushSubscriptions({userId: user.id});
await users.deleteAuthSessions(user.id, hashes);
await gateway.terminateSession({
userId: user.id,
sessionIdHashes,
});
await revokeAuthSessions(
ctx.services,
user.id,
sessionIdHashes.map((encodedIdHash) => ({sessionIdHash: Buffer.from(encodedIdHash, 'base64url'), encodedIdHash})),
);
}
export async function terminateAllUserSessions(ctx: ApiContext, userId: UserID): Promise<number> {
const {users, gateway} = ctx.services;
const authSessions = await users.listAuthSessions(userId);
await users.deleteAllPushSubscriptions(userId);
await gateway.invalidatePushSubscriptions({userId});
if (authSessions.length === 0) return 0;
const hashes = authSessions.map((s) => s.sessionIdHash);
await users.deleteAuthSessions(userId, hashes);
await gateway.terminateSession({
userId,
sessionIdHashes: authSessions.map((s) => Buffer.from(s.sessionIdHash).toString('base64url')),
});
return authSessions.length;
return await revokeAllAuthSessions(ctx.services, userId);
}
export async function replaceCurrentAuthSession(
@@ -229,20 +210,14 @@ async function deleteAndTerminateAuthSessions(
if (authSessions.length === 0) {
return;
}
const {users, gateway} = ctx.services;
const sessionIdHashes = authSessions.map((authSession) => encodeSessionIdHash(authSession.sessionIdHash));
await users.deletePushSubscriptionsForAuthSessions(userId, sessionIdHashes, {
deleteUnboundSubscriptions: true,
});
await gateway.invalidatePushSubscriptions({userId});
await users.deleteAuthSessions(
await revokeAuthSessions(
ctx.services,
userId,
authSessions.map((authSession) => authSession.sessionIdHash),
authSessions.map((authSession) => ({
sessionIdHash: authSession.sessionIdHash,
encodedIdHash: encodeSessionIdHash(authSession.sessionIdHash),
})),
);
await gateway.terminateSession({
userId,
sessionIdHashes,
});
}
function encodeSessionIdHash(sessionIdHash: Uint8Array): string {
@@ -0,0 +1,59 @@
import type {UserID} from '../BrandedTypes';
import type {IGatewayService} from '../infrastructure/IGatewayService';
import type {IUserRepositoryAggregate} from '../user/repositories/IUserRepositoryAggregate';
import {runAllInOrder} from '../utils/ConcurrencyUtils';
interface SessionRevocationDependencies {
users: IUserRepositoryAggregate;
gateway: IGatewayService;
}
interface SessionRevocationTarget {
sessionIdHash: Buffer;
encodedIdHash: string;
}
export async function revokeAuthSessions(
deps: SessionRevocationDependencies,
userId: UserID,
targets: ReadonlyArray<SessionRevocationTarget>,
): Promise<void> {
await revokeSessionTargets(deps, userId, targets, 'selected');
}
export async function revokeAllAuthSessions(deps: SessionRevocationDependencies, userId: UserID): Promise<number> {
const sessions = await deps.users.listAuthSessions(userId);
const targets = sessions.map((session) => ({
sessionIdHash: session.sessionIdHash,
encodedIdHash: session.sessionIdHash.toString('base64url'),
}));
await revokeSessionTargets(deps, userId, targets, 'all');
return sessions.length;
}
async function revokeSessionTargets(
{users, gateway}: SessionRevocationDependencies,
userId: UserID,
targets: ReadonlyArray<SessionRevocationTarget>,
scope: 'selected' | 'all',
): Promise<void> {
const sessionIdHashes = targets.map((target) => target.encodedIdHash);
const steps: Array<() => Promise<unknown>> = [
() =>
scope === 'all'
? users.deleteAllPushSubscriptions(userId)
: users.deletePushSubscriptionsForAuthSessions(userId, sessionIdHashes, {deleteUnboundSubscriptions: true}),
() => gateway.invalidatePushSubscriptions({userId}),
];
if (scope === 'selected' || targets.length > 0) {
steps.push(
() =>
users.deleteAuthSessions(
userId,
targets.map((target) => target.sessionIdHash),
),
() => gateway.terminateSession({userId, sessionIdHashes}),
);
}
await runAllInOrder(steps, 'Failed to revoke auth sessions');
}
+1
View File
@@ -104,6 +104,7 @@ function checkAccountBanStatus(_ctx: ApiContext, user: User): AccountBanStatus {
export async function handleBanStatus(ctx: ApiContext, user: User): Promise<User> {
const {users} = ctx.services;
if (user.deletionStartedAt) throw new AccountPermanentlySuspendedError();
const banStatus = checkAccountBanStatus(ctx, user);
if (banStatus.isPermanentlyBanned) {
throw new AccountPermanentlySuspendedError();
+23 -51
View File
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {AdminRepository} from '../admin/AdminRepository';
import {PHONE_PREFIX_BAN_REFRESH_CHANNEL} from '../constants/PhonePrefixBan';
import {Logger} from '../Logger';
import {RefreshSubscription} from '../utils/RefreshSubscription';
const BUILT_IN_BANNED_PHONE_PREFIXES: ReadonlyArray<string> = [
'+93',
@@ -29,55 +30,31 @@ const BUILT_IN_BANNED_PHONE_PREFIXES: ReadonlyArray<string> = [
export class PhonePrefixBanCache {
private prefixes: Set<string> = new Set();
private prefixLengths: ReadonlyArray<number> = [];
private isInitialized = false;
private adminRepository = new AdminRepository();
private kvClient: IKVProvider | null = null;
private kvSubscription: IKVSubscription | null = null;
private subscriberInitialized = false;
private messageHandler: ((channel: string) => void) | null = null;
private consecutiveFailures = 0;
private readonly maxConsecutiveFailures = 5;
private readonly refreshSubscription = new RefreshSubscription({
name: 'phone-prefix ban cache',
channels: [PHONE_PREFIX_BAN_REFRESH_CHANNEL],
refresh: () => this.refresh(),
onRefreshError: (err) => {
this.consecutiveFailures++;
const message = err instanceof Error ? err.message : String(err);
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
Logger.error({error: message}, 'Failed to refresh phone-prefix ban cache after notification');
} else {
Logger.warn({error: message}, 'Failed to refresh phone-prefix ban cache after notification');
}
},
});
setRefreshSubscriber(kvClient: IKVProvider | null): void {
this.kvClient = kvClient;
}
async initialize(): Promise<void> {
if (this.isInitialized) return;
await this.refresh();
this.isInitialized = true;
this.setupSubscriber();
}
private setupSubscriber(): void {
if (this.subscriberInitialized || !this.kvClient) return;
const subscription = this.kvClient.duplicate();
this.kvSubscription = subscription;
this.messageHandler = (channel: string) => {
if (channel === PHONE_PREFIX_BAN_REFRESH_CHANNEL) {
this.refresh().catch((err) => {
this.consecutiveFailures++;
const message = err instanceof Error ? err.message : String(err);
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
Logger.error({error: message}, 'Failed to refresh phone-prefix ban cache after notification');
} else {
Logger.warn({error: message}, 'Failed to refresh phone-prefix ban cache after notification');
}
});
}
};
subscription
.connect()
.then(() => subscription.subscribe(PHONE_PREFIX_BAN_REFRESH_CHANNEL))
.then(() => {
if (this.messageHandler) {
subscription.on('message', this.messageHandler);
}
})
.catch((error) => {
Logger.error({error}, 'Failed to subscribe to phone-prefix ban refresh channel');
});
this.subscriberInitialized = true;
initialize(): Promise<void> {
return this.refreshSubscription.start(this.kvClient);
}
async refresh(): Promise<void> {
@@ -132,9 +109,11 @@ export class PhonePrefixBanCache {
}
resetForTests(): void {
void this.shutdown().catch((error) => {
Logger.error({error}, 'Failed to shut down phone-prefix ban cache');
});
this.prefixes = new Set();
this.prefixLengths = [];
this.isInitialized = false;
}
private computeSortedLengths(): ReadonlyArray<number> {
@@ -143,15 +122,8 @@ export class PhonePrefixBanCache {
return [...seen].sort((a, b) => a - b);
}
shutdown(): void {
if (this.kvSubscription && this.messageHandler) {
this.kvSubscription.off('message', this.messageHandler);
}
if (this.kvSubscription) {
this.kvSubscription.disconnect();
this.kvSubscription = null;
}
this.messageHandler = null;
shutdown(): Promise<void> {
return this.refreshSubscription.stop();
}
}
+11 -1
View File
@@ -10,6 +10,7 @@ import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlo
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {EmailType} from '@fluxer/schema/src/primitives/UserValidators';
import {formatUrlForDiagnostics} from '@pkgs/http_client/src/HttpClientDiagnostics';
import {ms, seconds} from 'itty-time';
import {
type CryptoKey,
@@ -377,6 +378,9 @@ export class SsoService {
throw new RegistrationClosedError();
}
const pendingApproval = registrationConfig.mode === 'approval';
if (pendingApproval) {
await this.instanceConfigRepository.getPendingRegistrations();
}
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval});
if (pendingApproval) {
await this.instanceConfigRepository.addPendingRegistration({
@@ -675,6 +679,7 @@ export class SsoService {
{requestUrlPolicy: getSsoRequestUrlPolicy()},
);
if (response.status < 200 || response.status >= 300) {
FetchUtils.discardResponseBody(response.stream, response.status);
throw new Error(`Failed to fetch JWKS: HTTP ${response.status}`);
}
const rawBody = await FetchUtils.streamToStringWithLimit(response.stream, {
@@ -774,6 +779,7 @@ export class SsoService {
{requestUrlPolicy: getSsoRequestUrlPolicy()},
);
if (resp.status < 200 || resp.status >= 300) {
FetchUtils.discardResponseBody(resp.stream, resp.status);
throw InputValidationError.fromCode('access_token', ValidationErrorCodes.FAILED_TO_FETCH_SSO_USER_INFO);
}
try {
@@ -836,6 +842,7 @@ export class SsoService {
{requestUrlPolicy: getSsoRequestUrlPolicy()},
);
if (resp.status < 200 || resp.status >= 300) {
FetchUtils.discardResponseBody(resp.stream, resp.status);
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_SSO_AUTHORIZATION_CODE);
}
const rawBody = await FetchUtils.streamToStringWithLimit(resp.stream, {
@@ -926,7 +933,10 @@ export class SsoService {
try {
return await this.assertPublicOutboundUrl(rawUrl, fieldName);
} catch (error) {
getLogger().warn({fieldName, rawUrl, error}, 'Ignoring SSO URL that failed outbound policy validation');
getLogger().warn(
{fieldName, rawUrl: formatUrlForDiagnostics(rawUrl), error},
'Ignoring SSO URL that failed outbound policy validation',
);
return null;
}
}
+10 -3
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {formatUrlForDiagnostics} from '@pkgs/http_client/src/HttpClientDiagnostics';
import {ms} from 'itty-time';
import {Logger} from '../../Logger';
import {EXTERNAL_RESPONSE_LIMITS} from '../../utils/ExternalResponseLimits';
@@ -52,7 +53,10 @@ export function parseTokenEndpointResponse(
}
function buildDiscoveryUrl(issuer: string): URL {
const issuerUrl = new URL(issuer);
const issuerUrl = URL.parse(issuer);
if (!issuerUrl) {
throw new Error('Invalid OIDC issuer URL');
}
const normalized = issuerUrl.toString().replace(/\/$/, '');
return new URL(`${normalized}/.well-known/openid-configuration`);
}
@@ -71,7 +75,10 @@ export async function tryDiscoverOidcProviderMetadata(issuer: string): Promise<D
timeout: ms('10 seconds'),
serviceName: 'sso_oidc_discovery',
});
if (resp.status < 200 || resp.status >= 300) return null;
if (resp.status < 200 || resp.status >= 300) {
FetchUtils.discardResponseBody(resp.stream, resp.status);
return null;
}
const rawBody = await FetchUtils.streamToStringWithLimit(resp.stream, {
maxBytes: EXTERNAL_RESPONSE_LIMITS.ssoDiscoveryBytes,
headers: resp.headers,
@@ -94,7 +101,7 @@ export async function tryDiscoverOidcProviderMetadata(issuer: string): Promise<D
jwks_uri: typeof json['jwks_uri'] === 'string' ? json['jwks_uri'] : undefined,
};
} catch (error) {
Logger.debug({issuer, error}, 'Failed to discover OIDC provider metadata');
Logger.debug({issuer: formatUrlForDiagnostics(issuer), error}, 'Failed to discover OIDC provider metadata');
return null;
}
}