refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,670 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {requireSudoMode} from '@fluxer/api/src/auth/services/SudoVerificationService';
import {DefaultUserOnly, LoginRequiredAllowSuspicious} from '@fluxer/api/src/middleware/AuthMiddleware';
import {CaptchaMiddleware} from '@fluxer/api/src/middleware/CaptchaMiddleware';
import {LocalAuthMiddleware} from '@fluxer/api/src/middleware/LocalAuthMiddleware';
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
import {SudoModeMiddleware} from '@fluxer/api/src/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
import {Validator} from '@fluxer/api/src/Validator';
import {
AuthLoginResponse,
AuthorizeIpRequest,
AuthRegisterResponse,
AuthSessionsResponse,
AuthTokenWithUserIdResponse,
EmailRevertRequest,
ForgotPasswordRequest,
HandoffCodeParam,
HandoffCompleteRequest,
HandoffInitiateResponse,
HandoffStatusResponse,
IpAuthorizationPollQuery,
IpAuthorizationPollResponse,
LoginRequest,
LogoutAuthSessionsRequest,
MfaSmsRequest,
MfaTicketRequest,
MfaTotpRequest,
RegisterRequest,
ResetPasswordRequest,
SsoCompleteRequest,
SsoCompleteResponse,
SsoStartRequest,
SsoStartResponse,
SsoStatusResponse,
SudoVerificationSchema,
UsernameSuggestionsRequest,
UsernameSuggestionsResponse,
VerifyEmailRequest,
WebAuthnAuthenticateRequest,
WebAuthnAuthenticationOptionsResponse,
WebAuthnMfaRequest,
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
export function AuthController(app: HonoApp) {
app.get(
'/auth/sso/status',
RateLimitMiddleware(RateLimitConfigs.AUTH_SSO_START),
OpenAPI({
operationId: 'get_sso_status',
summary: 'Get SSO status',
responseSchema: SsoStatusResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description: 'Retrieve the current status of the SSO authentication session without authentication required.',
}),
async (ctx) => {
const status = await ctx.get('authRequestService').getSsoStatus();
return ctx.json(status);
},
);
app.post(
'/auth/sso/start',
RateLimitMiddleware(RateLimitConfigs.AUTH_SSO_START),
Validator('json', SsoStartRequest),
OpenAPI({
operationId: 'start_sso',
summary: 'Start SSO',
responseSchema: SsoStartResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Initiate a new Single Sign-On (SSO) session. Returns a session URL to be completed with SSO provider credentials.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').startSso(ctx.req.valid('json'));
return ctx.json(result);
},
);
app.post(
'/auth/sso/complete',
RateLimitMiddleware(RateLimitConfigs.AUTH_SSO_COMPLETE),
Validator('json', SsoCompleteRequest),
OpenAPI({
operationId: 'complete_sso',
summary: 'Complete SSO',
responseSchema: SsoCompleteResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Complete the SSO authentication flow with the authorization code from the SSO provider. Returns authentication token and user information.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').completeSso(ctx.req.valid('json'), ctx.req.raw);
return ctx.json(result);
},
);
app.post(
'/auth/register',
LocalAuthMiddleware,
CaptchaMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_REGISTER),
Validator('json', RegisterRequest),
OpenAPI({
operationId: 'register_account',
summary: 'Register account',
responseSchema: AuthRegisterResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Create a new user account with email and password. Requires CAPTCHA verification. User account is created but must verify email before logging in.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').register({
data: ctx.req.valid('json'),
request: ctx.req.raw,
requestCache: ctx.get('requestCache'),
});
return ctx.json(result);
},
);
app.post(
'/auth/login',
LocalAuthMiddleware,
CaptchaMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN),
Validator('json', LoginRequest),
OpenAPI({
operationId: 'login_user',
summary: 'Login account',
responseSchema: AuthLoginResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').login({
data: ctx.req.valid('json'),
request: ctx.req.raw,
requestCache: ctx.get('requestCache'),
});
return ctx.json(result);
},
);
app.post(
'/auth/login/mfa/totp',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
Validator('json', MfaTotpRequest),
OpenAPI({
operationId: 'login_with_totp',
summary: 'Login with TOTP',
responseSchema: AuthTokenWithUserIdResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Complete login by verifying TOTP code during multi-factor authentication. Requires the MFA ticket from initial login attempt.',
}),
async (ctx) => {
const {code, ticket} = ctx.req.valid('json');
const result = await ctx.get('authRequestService').loginMfaTotp({code, ticket, request: ctx.req.raw});
return ctx.json(result);
},
);
app.post(
'/auth/login/mfa/sms/send',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
Validator('json', MfaTicketRequest),
OpenAPI({
operationId: 'send_sms_mfa_code',
summary: 'Send SMS MFA code',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description:
"Request an SMS code to be sent to the user's registered phone number during MFA login. Requires the MFA ticket from initial login attempt.",
}),
async (ctx) => {
await ctx.get('authRequestService').sendSmsMfaCodeForTicket(ctx.req.valid('json'));
return ctx.body(null, 204);
},
);
app.post(
'/auth/login/mfa/sms',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
Validator('json', MfaSmsRequest),
OpenAPI({
operationId: 'login_with_sms_mfa',
summary: 'Login with SMS MFA',
responseSchema: AuthTokenWithUserIdResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Complete login by verifying the SMS code sent during MFA authentication. Requires the MFA ticket from initial login attempt.',
}),
async (ctx) => {
const {code, ticket} = ctx.req.valid('json');
const result = await ctx.get('authRequestService').loginMfaSms({code, ticket, request: ctx.req.raw});
return ctx.json(result);
},
);
app.post(
'/auth/logout',
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGOUT),
OpenAPI({
operationId: 'logout_user',
summary: 'Logout account',
responseSchema: null,
statusCode: 204,
security: ['bearerToken', 'sessionToken'],
tags: ['Auth'],
description:
'Invalidate the current authentication token and end the session. The auth token in the Authorization header will no longer be valid.',
}),
async (ctx) => {
await ctx.get('authRequestService').logout({
authorizationHeader: ctx.req.header('Authorization') ?? undefined,
authToken: ctx.get('authToken') ?? undefined,
});
return ctx.body(null, 204);
},
);
app.post(
'/auth/verify',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_VERIFY_EMAIL),
Validator('json', VerifyEmailRequest),
OpenAPI({
operationId: 'verify_email',
summary: 'Verify email',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description:
'Verify user email address using the code sent during registration. Email verification is required before the account becomes fully usable.',
}),
async (ctx) => {
await ctx.get('authRequestService').verifyEmail(ctx.req.valid('json'));
return ctx.body(null, 204);
},
);
app.post(
'/auth/verify/resend',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_RESEND_VERIFICATION),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
OpenAPI({
operationId: 'resend_verification_email',
summary: 'Resend verification email',
responseSchema: null,
statusCode: 204,
security: ['bearerToken', 'sessionToken'],
tags: ['Auth'],
description:
'Request a new email verification code to be sent. Requires authentication. Use this if the original verification email was lost or expired.',
}),
async (ctx) => {
await ctx.get('authRequestService').resendVerificationEmail(ctx.get('user'));
return ctx.body(null, 204);
},
);
app.post(
'/auth/forgot',
LocalAuthMiddleware,
CaptchaMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_FORGOT_PASSWORD),
Validator('json', ForgotPasswordRequest),
OpenAPI({
operationId: 'forgot_password',
summary: 'Forgot password',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description:
"Initiate password reset process by email. A password reset link will be sent to the user's email address. Requires CAPTCHA verification.",
}),
async (ctx) => {
await ctx.get('authRequestService').forgotPassword({
data: ctx.req.valid('json'),
request: ctx.req.raw,
});
return ctx.body(null, 204);
},
);
app.post(
'/auth/reset',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_RESET_PASSWORD),
Validator('json', ResetPasswordRequest),
OpenAPI({
operationId: 'reset_password',
summary: 'Reset password',
responseSchema: AuthLoginResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Complete the password reset flow using the token from the reset email. Returns authentication token after successful password reset.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').resetPassword({
data: ctx.req.valid('json'),
request: ctx.req.raw,
});
return ctx.json(result);
},
);
app.post(
'/auth/email-revert',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_EMAIL_REVERT),
Validator('json', EmailRevertRequest),
OpenAPI({
operationId: 'revert_email_change',
summary: 'Revert email change',
responseSchema: AuthLoginResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Revert a pending email change using the verification token sent to the old email. Returns authentication token after successful revert.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').revertEmailChange({
data: ctx.req.valid('json'),
request: ctx.req.raw,
});
return ctx.json(result);
},
);
app.get(
'/auth/sessions',
RateLimitMiddleware(RateLimitConfigs.AUTH_SESSIONS_GET),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
OpenAPI({
operationId: 'list_auth_sessions',
summary: 'List auth sessions',
responseSchema: AuthSessionsResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Auth'],
description: 'Retrieve all active authentication sessions for the current user. Requires authentication.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
return ctx.json(await ctx.get('authRequestService').getAuthSessions(userId));
},
);
app.post(
'/auth/sessions/logout',
RateLimitMiddleware(RateLimitConfigs.AUTH_SESSIONS_LOGOUT),
LoginRequiredAllowSuspicious,
DefaultUserOnly,
SudoModeMiddleware,
Validator('json', LogoutAuthSessionsRequest.merge(SudoVerificationSchema)),
OpenAPI({
operationId: 'logout_all_sessions',
summary: 'Logout all sessions',
responseSchema: null,
statusCode: 204,
security: ['bearerToken', 'sessionToken'],
tags: ['Auth'],
description:
'Invalidate all active authentication sessions for the current user. Requires sudo mode verification for security.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
await ctx.get('authRequestService').logoutAuthSessions({user, data: body});
return ctx.body(null, 204);
},
);
app.post(
'/auth/authorize-ip',
RateLimitMiddleware(RateLimitConfigs.AUTH_AUTHORIZE_IP),
Validator('json', AuthorizeIpRequest),
OpenAPI({
operationId: 'authorize_ip_address',
summary: 'Authorize IP address',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description:
'Verify and authorize a new IP address using the confirmation code sent via email. Completes IP authorization flow.',
}),
async (ctx) => {
await ctx.get('authRequestService').completeIpAuthorization({data: ctx.req.valid('json')});
return ctx.body(null, 204);
},
);
app.post(
'/auth/ip-authorization/resend',
RateLimitMiddleware(RateLimitConfigs.AUTH_IP_AUTHORIZATION_RESEND),
Validator('json', MfaTicketRequest),
OpenAPI({
operationId: 'resend_ip_authorization',
summary: 'Resend IP authorization',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description:
'Request a new IP authorization verification code to be sent via email. Use if the original code was lost or expired.',
}),
async (ctx) => {
await ctx.get('authRequestService').resendIpAuthorization(ctx.req.valid('json'));
return ctx.body(null, 204);
},
);
app.get(
'/auth/ip-authorization/poll',
RateLimitMiddleware(RateLimitConfigs.AUTH_IP_AUTHORIZATION_POLL),
Validator('query', IpAuthorizationPollQuery),
OpenAPI({
operationId: 'poll_ip_authorization',
summary: 'Poll IP authorization',
responseSchema: IpAuthorizationPollResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Poll the status of an IP authorization request. Use the ticket parameter to check if verification has been completed.',
}),
async (ctx) => {
const {ticket} = ctx.req.valid('query');
return ctx.json(await ctx.get('authRequestService').pollIpAuthorization({ticket}));
},
);
app.post(
'/auth/webauthn/authentication-options',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_WEBAUTHN_OPTIONS),
OpenAPI({
operationId: 'get_webauthn_authentication_options',
summary: 'Get WebAuthn authentication options',
responseSchema: WebAuthnAuthenticationOptionsResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Retrieve WebAuthn authentication challenge and options for passwordless login with biometrics or security keys.',
}),
async (ctx) => {
return ctx.json(await ctx.get('authRequestService').getWebAuthnAuthenticationOptions());
},
);
app.post(
'/auth/webauthn/authenticate',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_WEBAUTHN_AUTHENTICATE),
Validator('json', WebAuthnAuthenticateRequest),
OpenAPI({
operationId: 'authenticate_with_webauthn',
summary: 'Authenticate with WebAuthn',
responseSchema: AuthTokenWithUserIdResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Complete passwordless login using WebAuthn (biometrics or security key). Returns authentication token on success.',
}),
async (ctx) => {
return ctx.json(
await ctx.get('authRequestService').authenticateWebAuthnDiscoverable({
data: ctx.req.valid('json'),
request: ctx.req.raw,
}),
);
},
);
app.post(
'/auth/login/mfa/webauthn/authentication-options',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
Validator('json', MfaTicketRequest),
OpenAPI({
operationId: 'get_webauthn_mfa_options',
summary: 'Get WebAuthn MFA options',
responseSchema: WebAuthnAuthenticationOptionsResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Retrieve WebAuthn challenge and options for multi-factor authentication. Requires the MFA ticket from initial login.',
}),
async (ctx) => {
return ctx.json(await ctx.get('authRequestService').getWebAuthnMfaOptions(ctx.req.valid('json')));
},
);
app.post(
'/auth/login/mfa/webauthn',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
Validator('json', WebAuthnMfaRequest),
OpenAPI({
operationId: 'login_with_webauthn_mfa',
summary: 'Login with WebAuthn MFA',
responseSchema: AuthTokenWithUserIdResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Complete login by verifying WebAuthn response during MFA. Requires the MFA ticket from initial login attempt.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').loginMfaWebAuthn({
data: ctx.req.valid('json'),
request: ctx.req.raw,
});
return ctx.json(result);
},
);
app.post(
'/auth/username-suggestions',
LocalAuthMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_REGISTER),
Validator('json', UsernameSuggestionsRequest),
OpenAPI({
operationId: 'get_username_suggestions',
summary: 'Get username suggestions',
responseSchema: UsernameSuggestionsResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description: 'Generate username suggestions based on a provided global name for new account registration.',
}),
async (ctx) => {
const response = ctx.get('authRequestService').getUsernameSuggestions({
globalName: ctx.req.valid('json').global_name,
});
return ctx.json(response);
},
);
app.post(
'/auth/handoff/initiate',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_INITIATE),
OpenAPI({
operationId: 'initiate_handoff',
summary: 'Initiate handoff',
responseSchema: HandoffInitiateResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
}),
async (ctx) => {
return ctx.json(await ctx.get('authRequestService').initiateHandoff({userAgent: ctx.req.header('User-Agent')}));
},
);
app.post(
'/auth/handoff/complete',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_COMPLETE),
Validator('json', HandoffCompleteRequest),
OpenAPI({
operationId: 'complete_handoff',
summary: 'Complete handoff',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description: 'Complete the handoff process and authenticate on the target device using the handoff code.',
}),
async (ctx) => {
await ctx.get('authRequestService').completeHandoff({data: ctx.req.valid('json'), request: ctx.req.raw});
return ctx.body(null, 204);
},
);
app.get(
'/auth/handoff/:code/status',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_STATUS),
Validator('param', HandoffCodeParam),
OpenAPI({
operationId: 'get_handoff_status',
summary: 'Get handoff status',
responseSchema: HandoffStatusResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Check the status of a handoff session. Returns whether the handoff has been completed or is still pending.',
}),
async (ctx) => {
const response = await ctx.get('authRequestService').getHandoffStatus({code: ctx.req.valid('param').code});
return ctx.json(response);
},
);
app.delete(
'/auth/handoff/:code',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_CANCEL),
Validator('param', HandoffCodeParam),
OpenAPI({
operationId: 'cancel_handoff',
summary: 'Cancel handoff',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description: 'Cancel an ongoing handoff session. The handoff code will no longer be valid for authentication.',
}),
async (ctx) => {
await ctx.get('authRequestService').cancelHandoff({code: ctx.req.valid('param').code});
return ctx.body(null, 204);
},
);
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@fluxer/api/src/Logger';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import {getLocationLabelFromIp} from '@fluxer/api/src/utils/IpUtils';
import {resolveSessionClientInfo} from '@fluxer/api/src/utils/UserAgentUtils';
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {uint8ArrayToBase64} from 'uint8array-extras';
async function resolveAuthSessionLocation(session: AuthSession): Promise<string | null> {
try {
return await getLocationLabelFromIp(session.clientIp);
} catch (error) {
Logger.warn({error, clientIp: session.clientIp}, 'Failed to resolve location from IP');
return null;
}
}
export async function mapAuthSessionsToResponse({
authSessions,
currentSessionId,
}: {
authSessions: Array<AuthSession>;
currentSessionId?: Uint8Array;
}): Promise<Array<AuthSessionResponse>> {
const sortedSessions = authSessions.toSorted((a, b) => {
const aTime = a.approximateLastUsedAt?.getTime() || 0;
const bTime = b.approximateLastUsedAt?.getTime() || 0;
return bTime - aTime;
});
const locationResults = await Promise.allSettled(
sortedSessions.map((session) => resolveAuthSessionLocation(session)),
);
return sortedSessions.map((authSession, index): AuthSessionResponse => {
const locationResult = locationResults[index];
const clientLocation = locationResult?.status === 'fulfilled' ? locationResult.value : null;
let clientOs: string;
let clientPlatform: string;
if (authSession.clientUserAgent) {
const parsed = resolveSessionClientInfo({
userAgent: authSession.clientUserAgent,
isDesktopClient: authSession.clientIsDesktop,
});
clientOs = parsed.clientOs;
clientPlatform = parsed.clientPlatform;
} else {
clientOs = authSession.clientOs || 'Unknown';
clientPlatform = authSession.clientPlatform || 'Unknown';
}
const idHash = uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true});
const isCurrent = currentSessionId ? Buffer.compare(authSession.sessionIdHash, currentSessionId) === 0 : false;
return {
id_hash: idHash,
client_info: {
platform: clientPlatform,
os: clientOs,
browser: undefined,
location: clientLocation
? {
city: clientLocation.split(',').at(0)?.trim() || null,
region: clientLocation.split(',').at(1)?.trim() || null,
country: clientLocation.split(',').at(2)?.trim() || null,
}
: null,
},
approx_last_used_at: authSession.approximateLastUsedAt?.toISOString() || null,
current: isCurrent,
};
});
}

View File

@@ -0,0 +1,321 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {DesktopHandoffService} from '@fluxer/api/src/auth/services/DesktopHandoffService';
import type {SsoService} from '@fluxer/api/src/auth/services/SsoService';
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {User} from '@fluxer/api/src/models/User';
import {generateUsernameSuggestions} from '@fluxer/api/src/utils/UsernameSuggestionUtils';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {
AuthLoginResponse,
AuthorizeIpRequest,
AuthRegisterResponse,
AuthSessionsResponse,
AuthTokenWithUserIdResponse,
EmailRevertRequest,
ForgotPasswordRequest,
HandoffCompleteRequest,
HandoffInitiateResponse,
HandoffStatusResponse,
IpAuthorizationPollResponse,
LoginRequest,
LogoutAuthSessionsRequest,
MfaTicketRequest,
RegisterRequest,
ResetPasswordRequest,
SsoCompleteRequest,
SsoStartRequest,
UsernameSuggestionsResponse,
VerifyEmailRequest,
WebAuthnAuthenticateRequest,
WebAuthnMfaRequest,
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
interface AuthRegisterRequest {
data: RegisterRequest;
request: Request;
requestCache: RequestCache;
}
interface AuthLoginRequest {
data: LoginRequest;
request: Request;
requestCache: RequestCache;
}
interface AuthForgotPasswordRequest {
data: ForgotPasswordRequest;
request: Request;
}
interface AuthResetPasswordRequest {
data: ResetPasswordRequest;
request: Request;
}
interface AuthRevertEmailChangeRequest {
data: EmailRevertRequest;
request: Request;
}
interface AuthLoginMfaRequest {
code: string;
ticket: string;
request: Request;
}
interface AuthLogoutRequest {
authorizationHeader?: string;
authToken?: string;
}
interface AuthHandoffCompleteRequest {
data: HandoffCompleteRequest;
request: Request;
}
interface AuthAuthorizeIpRequest {
data: AuthorizeIpRequest;
}
interface AuthUsernameSuggestionsRequest {
globalName: string;
}
interface AuthPollIpRequest {
ticket: string;
}
interface AuthWebAuthnAuthenticateRequest {
data: WebAuthnAuthenticateRequest;
request: Request;
}
interface AuthWebAuthnMfaRequest {
data: WebAuthnMfaRequest;
request: Request;
}
interface AuthLogoutAuthSessionsRequest {
user: User;
data: LogoutAuthSessionsRequest;
}
interface AuthHandoffInitiateRequest {
userAgent?: string;
}
interface AuthHandoffStatusRequest {
code: string;
}
export class AuthRequestService {
constructor(
private authService: AuthService,
private ssoService: SsoService,
private cacheService: ICacheService,
private desktopHandoffService: DesktopHandoffService,
) {}
getSsoStatus() {
return this.ssoService.getPublicStatus();
}
startSso(data: SsoStartRequest) {
return this.ssoService.startLogin(data.redirect_to ?? undefined);
}
completeSso(data: SsoCompleteRequest, request: Request) {
return this.ssoService.completeLogin({code: data.code, state: data.state, request});
}
async register({data, request, requestCache}: AuthRegisterRequest): Promise<AuthRegisterResponse> {
const result = await this.authService.register({data, request, requestCache});
return this.toAuthLoginResponse(result);
}
async login({data, request, requestCache}: AuthLoginRequest): Promise<AuthLoginResponse> {
const result = await this.authService.login({data, request, requestCache});
return this.toAuthLoginResponse(result);
}
loginMfaTotp({code, ticket, request}: AuthLoginMfaRequest): Promise<AuthTokenWithUserIdResponse> {
return this.authService.loginMfaTotp({code, ticket, request});
}
async sendSmsMfaCodeForTicket({ticket}: MfaTicketRequest): Promise<void> {
await this.authService.sendSmsMfaCodeForTicket(ticket);
}
loginMfaSms({code, ticket, request}: AuthLoginMfaRequest): Promise<AuthTokenWithUserIdResponse> {
return this.authService.loginMfaSms({code, ticket, request});
}
async logout({authorizationHeader, authToken}: AuthLogoutRequest): Promise<void> {
const token = authorizationHeader ?? authToken;
if (token) {
await this.authService.revokeToken(token);
}
}
async verifyEmail(data: VerifyEmailRequest): Promise<void> {
const success = await this.authService.verifyEmail(data);
if (!success) {
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_VERIFICATION_TOKEN);
}
}
async resendVerificationEmail(user: User): Promise<void> {
await this.authService.resendVerificationEmail(user);
}
async forgotPassword({data, request}: AuthForgotPasswordRequest): Promise<void> {
await this.authService.forgotPassword({data, request});
}
async resetPassword({data, request}: AuthResetPasswordRequest): Promise<AuthLoginResponse> {
const result = await this.authService.resetPassword({data, request});
return this.toAuthLoginResponse(result);
}
revertEmailChange({data, request}: AuthRevertEmailChangeRequest): Promise<AuthLoginResponse> {
return this.authService.revertEmailChange({data, request});
}
getAuthSessions(userId: UserID): Promise<AuthSessionsResponse> {
return this.authService.getAuthSessions(userId);
}
async logoutAuthSessions({user, data}: AuthLogoutAuthSessionsRequest): Promise<void> {
await this.authService.logoutAuthSessions({
user,
sessionIdHashes: data.session_id_hashes,
});
}
async completeIpAuthorization({data}: AuthAuthorizeIpRequest): Promise<void> {
const result = await this.authService.completeIpAuthorization(data.token);
const payload = JSON.stringify({token: result.token, user_id: result.user_id});
await this.cacheService.set(`ip-auth-result:${result.ticket}`, payload, 60);
}
async resendIpAuthorization({ticket}: MfaTicketRequest): Promise<void> {
await this.authService.resendIpAuthorization(ticket);
}
async pollIpAuthorization({ticket}: AuthPollIpRequest): Promise<IpAuthorizationPollResponse> {
const result = await this.cacheService.get<string>(`ip-auth-result:${ticket}`);
if (result) {
const parsed = JSON.parse(result) as {token: string; user_id: string};
return {
completed: true,
token: parsed.token,
user_id: parsed.user_id,
};
}
const ticketPayload = await this.cacheService.get(`ip-auth-ticket:${ticket}`);
if (!ticketPayload) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_AUTHORIZATION_TICKET);
}
return {completed: false};
}
async getWebAuthnAuthenticationOptions() {
return this.authService.generateWebAuthnAuthenticationOptionsDiscoverable();
}
async authenticateWebAuthnDiscoverable({data, request}: AuthWebAuthnAuthenticateRequest) {
const user = await this.authService.verifyWebAuthnAuthenticationDiscoverable(data.response, data.challenge);
const [token] = await this.authService.createAuthSession({user, request});
return {token, user_id: user.id.toString()};
}
async getWebAuthnMfaOptions({ticket}: MfaTicketRequest) {
return this.authService.generateWebAuthnAuthenticationOptionsForMfa(ticket);
}
loginMfaWebAuthn({data, request}: AuthWebAuthnMfaRequest): Promise<AuthTokenWithUserIdResponse> {
return this.authService.loginMfaWebAuthn({
response: data.response,
challenge: data.challenge,
ticket: data.ticket,
request,
});
}
getUsernameSuggestions({globalName}: AuthUsernameSuggestionsRequest): UsernameSuggestionsResponse {
return {suggestions: generateUsernameSuggestions(globalName)};
}
async initiateHandoff({userAgent}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
const result = await this.desktopHandoffService.initiateHandoff(userAgent);
return {
code: result.code,
expires_at: result.expiresAt.toISOString(),
};
}
async completeHandoff({data, request}: AuthHandoffCompleteRequest): Promise<void> {
const {token: handoffToken, userId} = await this.authService.createAdditionalAuthSessionFromToken({
token: data.token,
expectedUserId: data.user_id,
request,
});
await this.desktopHandoffService.completeHandoff(data.code, handoffToken, userId);
}
async getHandoffStatus({code}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
const result = await this.desktopHandoffService.getHandoffStatus(code);
return {
status: result.status,
token: result.token,
user_id: result.userId,
};
}
async cancelHandoff({code}: AuthHandoffStatusRequest): Promise<void> {
await this.desktopHandoffService.cancelHandoff(code);
}
private toAuthLoginResponse(
result:
| {user_id: string; token: string}
| {mfa: true; ticket: string; allowed_methods: Array<string>; sms_phone_hint: string | null},
): AuthLoginResponse {
if (!('mfa' in result)) {
return result;
}
const allowedMethods = new Set(result.allowed_methods);
return {
...result,
sms: allowedMethods.has('sms'),
totp: allowedMethods.has('totp'),
webauthn: allowedMethods.has('webauthn'),
};
}
}

View File

@@ -0,0 +1,602 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AuthEmailRevertService} from '@fluxer/api/src/auth/services/AuthEmailRevertService';
import {AuthEmailService} from '@fluxer/api/src/auth/services/AuthEmailService';
import {AuthLoginService} from '@fluxer/api/src/auth/services/AuthLoginService';
import {AuthMfaService} from '@fluxer/api/src/auth/services/AuthMfaService';
import {AuthPasswordService} from '@fluxer/api/src/auth/services/AuthPasswordService';
import {AuthPhoneService} from '@fluxer/api/src/auth/services/AuthPhoneService';
import {AuthRegistrationService} from '@fluxer/api/src/auth/services/AuthRegistrationService';
import {AuthSessionService} from '@fluxer/api/src/auth/services/AuthSessionService';
import {AuthUtilityService} from '@fluxer/api/src/auth/services/AuthUtilityService';
import {createMfaTicket, type UserID} from '@fluxer/api/src/BrandedTypes';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
import type {KVActivityTracker} from '@fluxer/api/src/infrastructure/KVActivityTracker';
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
import type {SnowflakeReservationService} from '@fluxer/api/src/instance/SnowflakeReservationService';
import type {InviteService} from '@fluxer/api/src/invite/InviteService';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {User} from '@fluxer/api/src/models/User';
import type {BotMfaMirrorService} from '@fluxer/api/src/oauth/BotMfaMirrorService';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
import {randomString} from '@fluxer/api/src/utils/RandomUtils';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import {SessionTokenMismatchError} from '@fluxer/errors/src/domains/auth/SessionTokenMismatchError';
import {InvalidTokenError} from '@fluxer/errors/src/domains/core/InvalidTokenError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {
AuthSessionResponse,
EmailRevertRequest,
ForgotPasswordRequest,
LoginRequest,
RegisterRequest,
ResetPasswordRequest,
VerifyEmailRequest,
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import type {ISmsService} from '@fluxer/sms/src/ISmsService';
import type {AuthenticationResponseJSON, RegistrationResponseJSON} from '@simplewebauthn/server';
import {seconds} from 'itty-time';
interface RegisterParams {
data: RegisterRequest;
request: Request;
requestCache: RequestCache;
}
interface LoginParams {
data: LoginRequest;
request: Request;
requestCache: RequestCache;
}
interface LoginMfaTotpParams {
code: string;
ticket: string;
request: Request;
}
interface ForgotPasswordParams {
data: ForgotPasswordRequest;
request: Request;
}
interface ResetPasswordParams {
data: ResetPasswordRequest;
request: Request;
}
interface RevertEmailChangeParams {
data: EmailRevertRequest;
request: Request;
}
interface LogoutAuthSessionsParams {
user: User;
sessionIdHashes: Array<string>;
}
interface CreateAuthSessionParams {
user: User;
request: Request;
}
interface DispatchAuthSessionChangeParams {
userId: UserID;
oldAuthSessionIdHash: string;
newAuthSessionIdHash: string;
newToken: string;
}
interface VerifyPasswordParams {
password: string;
passwordHash: string;
}
interface VerifyMfaCodeParams {
userId: UserID;
mfaSecret: string;
code: string;
allowBackup?: boolean;
}
interface UpdateUserActivityParams {
userId: UserID;
clientIp: string;
}
interface ValidateAgeParams {
dateOfBirth: string;
minAge: number;
}
interface CheckEmailChangeRateLimitParams {
userId: UserID;
}
interface IAuthService {
verifyPassword(params: {password: string; passwordHash: string}): Promise<boolean>;
getUserSession(requestCache: RequestCache, token: string): Promise<AuthSession>;
}
export class AuthService implements IAuthService {
private sessionService: AuthSessionService;
private passwordService: AuthPasswordService;
private registrationService: AuthRegistrationService;
private loginService: AuthLoginService;
private emailService: AuthEmailService;
private emailRevertService: AuthEmailRevertService;
private phoneService: AuthPhoneService;
private mfaService: AuthMfaService;
private utilityService: AuthUtilityService;
constructor(
private repository: IUserRepository,
inviteService: InviteService,
private cacheService: ICacheService,
gatewayService: IGatewayService,
rateLimitService: IRateLimitService,
emailServiceDep: IEmailService,
smsService: ISmsService,
snowflakeService: SnowflakeService,
snowflakeReservationService: SnowflakeReservationService,
discriminatorService: IDiscriminatorService,
kvAccountDeletionQueue: KVAccountDeletionQueueService,
kvActivityTracker: KVActivityTracker,
private readonly contactChangeLogService: UserContactChangeLogService,
botMfaMirrorService?: BotMfaMirrorService,
authMfaService?: AuthMfaService,
) {
this.utilityService = new AuthUtilityService(repository, rateLimitService);
this.sessionService = new AuthSessionService(
repository,
gatewayService,
this.utilityService.generateAuthToken.bind(this.utilityService),
this.utilityService.getTokenIdHash.bind(this.utilityService),
);
this.passwordService = new AuthPasswordService(
repository,
emailServiceDep,
rateLimitService,
this.utilityService.generateSecureToken.bind(this.utilityService),
this.utilityService.handleBanStatus.bind(this.utilityService),
this.utilityService.assertNonBotUser.bind(this.utilityService),
this.createMfaTicketResponse.bind(this),
this.sessionService.createAuthSession.bind(this.sessionService),
);
this.mfaService =
authMfaService ?? new AuthMfaService(repository, cacheService, smsService, gatewayService, botMfaMirrorService);
this.registrationService = new AuthRegistrationService(
repository,
inviteService,
rateLimitService,
emailServiceDep,
snowflakeService,
snowflakeReservationService,
discriminatorService,
kvActivityTracker,
cacheService,
this.passwordService.hashPassword.bind(this.passwordService),
this.passwordService.isPasswordPwned.bind(this.passwordService),
this.utilityService.validateAge.bind(this.utilityService),
this.utilityService.generateSecureToken.bind(this.utilityService),
this.sessionService.createAuthSession.bind(this.sessionService),
);
this.loginService = new AuthLoginService(
repository,
inviteService,
cacheService,
rateLimitService,
emailServiceDep,
kvAccountDeletionQueue,
this.passwordService.verifyPassword.bind(this.passwordService),
this.utilityService.handleBanStatus.bind(this.utilityService),
this.utilityService.assertNonBotUser.bind(this.utilityService),
this.sessionService.createAuthSession.bind(this.sessionService),
this.utilityService.generateSecureToken.bind(this.utilityService),
this.mfaService.verifyMfaCode.bind(this.mfaService),
this.mfaService.verifySmsMfaCode.bind(this.mfaService),
this.mfaService.verifyWebAuthnAuthentication.bind(this.mfaService),
);
this.emailService = new AuthEmailService(
repository,
emailServiceDep,
gatewayService,
rateLimitService,
this.utilityService.assertNonBotUser.bind(this.utilityService),
this.utilityService.generateSecureToken.bind(this.utilityService),
);
this.emailRevertService = new AuthEmailRevertService(
repository,
emailServiceDep,
gatewayService,
this.passwordService.hashPassword.bind(this.passwordService),
this.passwordService.isPasswordPwned.bind(this.passwordService),
this.utilityService.handleBanStatus.bind(this.utilityService),
this.utilityService.assertNonBotUser.bind(this.utilityService),
this.utilityService.generateSecureToken.bind(this.utilityService),
this.sessionService.createAuthSession.bind(this.sessionService),
this.sessionService.terminateAllUserSessions.bind(this.sessionService),
this.contactChangeLogService!,
);
this.phoneService = new AuthPhoneService(
repository,
smsService,
gatewayService,
this.utilityService.assertNonBotUser.bind(this.utilityService),
this.utilityService.generateSecureToken.bind(this.utilityService),
this.contactChangeLogService!,
);
}
async register({data, request, requestCache}: RegisterParams): Promise<{user_id: string; token: string}> {
return this.registrationService.register({data, request, requestCache});
}
async login({
data,
request,
requestCache: _requestCache,
}: LoginParams): Promise<
| {user_id: string; token: string}
| {mfa: true; ticket: string; allowed_methods: Array<string>; sms_phone_hint: string | null}
> {
return this.loginService.login({data, request});
}
async loginMfaTotp({code, ticket, request}: LoginMfaTotpParams): Promise<{user_id: string; token: string}> {
return this.loginService.loginMfaTotp({code, ticket, request});
}
async loginMfaSms({
code,
ticket,
request,
}: {
code: string;
ticket: string;
request: Request;
}): Promise<{user_id: string; token: string}> {
return this.loginService.loginMfaSms({code, ticket, request});
}
async loginMfaWebAuthn({
response,
challenge,
ticket,
request,
}: {
response: AuthenticationResponseJSON;
challenge: string;
ticket: string;
request: Request;
}): Promise<{user_id: string; token: string}> {
return this.loginService.loginMfaWebAuthn({response, challenge, ticket, request});
}
async forgotPassword({data, request}: ForgotPasswordParams): Promise<void> {
return this.passwordService.forgotPassword({data, request});
}
async resetPassword({data, request}: ResetPasswordParams): Promise<
| {user_id: string; token: string}
| {
mfa: true;
ticket: string;
allowed_methods: Array<string>;
sms_phone_hint: string | null;
sms: boolean;
totp: boolean;
webauthn: boolean;
}
> {
return this.passwordService.resetPassword({data, request});
}
async revertEmailChange({data, request}: RevertEmailChangeParams): Promise<{user_id: string; token: string}> {
return this.emailRevertService.revertEmailChange({
token: data.token,
password: data.password,
request,
});
}
async issueEmailRevertToken(user: User, previousEmail: string, newEmail: string): Promise<void> {
return this.emailRevertService.issueRevertToken({user, previousEmail, newEmail});
}
async hashPassword(password: string): Promise<string> {
return this.passwordService.hashPassword(password);
}
async verifyPassword({password, passwordHash}: VerifyPasswordParams): Promise<boolean> {
return this.passwordService.verifyPassword({password, passwordHash});
}
async isPasswordPwned(password: string): Promise<boolean> {
return this.passwordService.isPasswordPwned(password);
}
async verifyEmail(data: VerifyEmailRequest): Promise<boolean> {
return this.emailService.verifyEmail(data);
}
async resendVerificationEmail(user: User): Promise<void> {
return this.emailService.resendVerificationEmail(user);
}
async getAuthSessionByToken(token: string): Promise<AuthSession | null> {
return this.sessionService.getAuthSessionByToken(token);
}
async getAuthSessions(userId: UserID): Promise<Array<AuthSessionResponse>> {
return this.sessionService.getAuthSessions(userId);
}
async createAdditionalAuthSessionFromToken({
token,
expectedUserId,
request,
}: {
token: string;
expectedUserId?: string;
request: Request;
}): Promise<{token: string; userId: string}> {
const existingSession = await this.sessionService.getAuthSessionByToken(token);
if (!existingSession) {
throw new InvalidTokenError();
}
const user = await this.repository.findUnique(existingSession.userId);
if (!user) {
throw new UnknownUserError();
}
if (expectedUserId && user.id.toString() !== expectedUserId) {
throw new SessionTokenMismatchError();
}
const [newToken] = await this.sessionService.createAuthSession({user, request});
return {token: newToken, userId: user.id.toString()};
}
async createAuthSession({user, request}: CreateAuthSessionParams): Promise<[token: string, AuthSession]> {
return this.sessionService.createAuthSession({user, request});
}
async updateAuthSessionLastUsed(tokenHash: Uint8Array): Promise<void> {
return this.sessionService.updateAuthSessionLastUsed(tokenHash);
}
async updateUserActivity({userId, clientIp}: UpdateUserActivityParams): Promise<void> {
return this.sessionService.updateUserActivity({userId, clientIp});
}
async revokeToken(token: string): Promise<void> {
return this.sessionService.revokeToken(token);
}
async logoutAuthSessions({user, sessionIdHashes}: LogoutAuthSessionsParams): Promise<void> {
return this.sessionService.logoutAuthSessions({user, sessionIdHashes});
}
async terminateAllUserSessions(userId: UserID): Promise<void> {
return this.sessionService.terminateAllUserSessions(userId);
}
async dispatchAuthSessionChange({
userId,
oldAuthSessionIdHash,
newAuthSessionIdHash,
newToken,
}: DispatchAuthSessionChangeParams): Promise<void> {
return this.sessionService.dispatchAuthSessionChange({
userId,
oldAuthSessionIdHash,
newAuthSessionIdHash,
newToken,
});
}
async getUserSession(_requestCache: RequestCache, token: string): Promise<AuthSession> {
const session = await this.getAuthSessionByToken(token);
if (!session) {
throw new InvalidTokenError();
}
return session;
}
async sendPhoneVerificationCode(phone: string, userId: UserID | null): Promise<void> {
return this.phoneService.sendPhoneVerificationCode(phone, userId);
}
async verifyPhoneCode(phone: string, code: string, userId: UserID | null): Promise<string> {
return this.phoneService.verifyPhoneCode(phone, code, userId);
}
async addPhoneToAccount(userId: UserID, phoneToken: string): Promise<void> {
return this.phoneService.addPhoneToAccount(userId, phoneToken);
}
async removePhoneFromAccount(userId: UserID): Promise<void> {
return this.phoneService.removePhoneFromAccount(userId);
}
async verifyMfaCode({userId, mfaSecret, code, allowBackup = false}: VerifyMfaCodeParams): Promise<boolean> {
return this.mfaService.verifyMfaCode({userId, mfaSecret, code, allowBackup});
}
async enableSmsMfa(userId: UserID): Promise<void> {
return this.mfaService.enableSmsMfa(userId);
}
async disableSmsMfa(userId: UserID): Promise<void> {
return this.mfaService.disableSmsMfa(userId);
}
async sendSmsMfaCode(userId: UserID): Promise<void> {
return this.mfaService.sendSmsMfaCode(userId);
}
async sendSmsMfaCodeForTicket(ticket: string): Promise<void> {
return this.mfaService.sendSmsMfaCodeForTicket(ticket);
}
async verifySmsMfaCode(userId: UserID, code: string): Promise<boolean> {
return this.mfaService.verifySmsMfaCode(userId, code);
}
async generateWebAuthnRegistrationOptions(userId: UserID) {
return this.mfaService.generateWebAuthnRegistrationOptions(userId);
}
async verifyWebAuthnRegistration(
userId: UserID,
response: RegistrationResponseJSON,
expectedChallenge: string,
name: string,
): Promise<void> {
return this.mfaService.verifyWebAuthnRegistration(userId, response, expectedChallenge, name);
}
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
return this.mfaService.deleteWebAuthnCredential(userId, credentialId);
}
async renameWebAuthnCredential(userId: UserID, credentialId: string, name: string): Promise<void> {
return this.mfaService.renameWebAuthnCredential(userId, credentialId, name);
}
async generateWebAuthnAuthenticationOptionsDiscoverable() {
return this.mfaService.generateWebAuthnAuthenticationOptionsDiscoverable();
}
async verifyWebAuthnAuthenticationDiscoverable(
response: AuthenticationResponseJSON,
expectedChallenge: string,
): Promise<User> {
return this.mfaService.verifyWebAuthnAuthenticationDiscoverable(response, expectedChallenge);
}
async generateWebAuthnAuthenticationOptionsForMfa(ticket: string) {
return this.mfaService.generateWebAuthnAuthenticationOptionsForMfa(ticket);
}
async verifyWebAuthnAuthentication(
userId: UserID,
response: AuthenticationResponseJSON,
expectedChallenge: string,
): Promise<void> {
return this.mfaService.verifyWebAuthnAuthentication(userId, response, expectedChallenge);
}
async generateSecureToken(length = 64): Promise<string> {
return this.utilityService.generateSecureToken(length);
}
async generateAuthToken(): Promise<string> {
return this.utilityService.generateAuthToken();
}
generateBackupCodes(): Array<string> {
return this.utilityService.generateBackupCodes();
}
async checkEmailChangeRateLimit({
userId,
}: CheckEmailChangeRateLimitParams): Promise<{allowed: boolean; retryAfter?: number}> {
return this.utilityService.checkEmailChangeRateLimit({userId});
}
validateAge({dateOfBirth, minAge}: ValidateAgeParams): boolean {
return this.utilityService.validateAge({dateOfBirth, minAge});
}
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
return this.utilityService.authorizeIpByToken(token);
}
async resendIpAuthorization(ticket: string): Promise<{retryAfter?: number}> {
return this.loginService.resendIpAuthorization(ticket);
}
async completeIpAuthorization(token: string): Promise<{token: string; user_id: string; ticket: string}> {
return this.loginService.completeIpAuthorization(token);
}
async createAuthSessionForUser(user: User, request: Request): Promise<{token: string; user_id: string}> {
const [token] = await this.sessionService.createAuthSession({user, request});
return {token, user_id: user.id.toString()};
}
private async createMfaTicketResponse(user: User): Promise<{
mfa: true;
ticket: string;
allowed_methods: Array<string>;
sms_phone_hint: string | null;
sms: boolean;
totp: boolean;
webauthn: boolean;
}> {
const ticket = createMfaTicket(randomString(64));
await this.cacheService.set(`mfa-ticket:${ticket}`, user.id.toString(), seconds('5 minutes'));
const credentials = await this.repository.listWebAuthnCredentials(user.id);
const hasSms = user.authenticatorTypes.has(UserAuthenticatorTypes.SMS);
const hasWebauthn = credentials.length > 0;
const hasTotp = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
const allowedMethods: Array<string> = [];
if (hasTotp) allowedMethods.push('totp');
if (hasSms) allowedMethods.push('sms');
if (hasWebauthn) allowedMethods.push('webauthn');
return {
mfa: true,
ticket: ticket,
allowed_methods: allowedMethods,
sms_phone_hint: user.phone ? this.maskPhone(user.phone) : null,
sms: hasSms,
totp: hasTotp,
webauthn: hasWebauthn,
};
}
private maskPhone(phone: string): string {
if (phone.length < 4) return '****';
return `****${phone.slice(-4)}`;
}
}

View File

@@ -0,0 +1,152 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import {createEmailRevertToken} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import {Logger} from '@fluxer/api/src/Logger';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {User} from '@fluxer/api/src/models/User';
import {getUserSearchService} from '@fluxer/api/src/SearchFactory';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
import {mapUserToPrivateResponse} from '@fluxer/api/src/user/UserMappers';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
interface IssueTokenParams {
user: User;
previousEmail: string;
newEmail: string;
}
interface RevertParams {
token: string;
password: string;
request: Request;
}
export class AuthEmailRevertService {
constructor(
private readonly repository: IUserRepository,
private readonly emailService: IEmailService,
private readonly gatewayService: IGatewayService,
private readonly hashPassword: (password: string) => Promise<string>,
private readonly isPasswordPwned: (password: string) => Promise<boolean>,
private readonly handleBanStatus: (user: User) => Promise<User>,
private readonly assertNonBotUser: (user: User) => void,
private readonly generateSecureToken: () => Promise<string>,
private readonly createAuthSession: (params: {user: User; request: Request}) => Promise<[string, AuthSession]>,
private readonly terminateAllUserSessions: (userId: UserID) => Promise<void>,
private readonly contactChangeLogService: UserContactChangeLogService,
) {}
async issueRevertToken(params: IssueTokenParams): Promise<void> {
const {user, previousEmail, newEmail} = params;
const trimmed = previousEmail.trim();
if (!trimmed) return;
const token = createEmailRevertToken(await this.generateSecureToken());
await this.repository.createEmailRevertToken({
token_: token,
user_id: user.id,
email: trimmed,
});
await this.emailService.sendEmailChangeRevert(trimmed, user.username, newEmail, token, user.locale);
}
async revertEmailChange(params: RevertParams): Promise<{user_id: string; token: string}> {
const {token, password, request} = params;
const tokenData = await this.repository.getEmailRevertToken(token);
if (!tokenData) {
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_REVERT_TOKEN);
}
const user = await this.repository.findUnique(tokenData.userId);
if (!user) {
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_REVERT_TOKEN);
}
this.assertNonBotUser(user);
await this.handleBanStatus(user);
if (await this.isPasswordPwned(password)) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
}
const passwordHash = await this.hashPassword(password);
const now = new Date();
const updatedUser = await this.repository.patchUpsert(
user.id,
{
email: tokenData.email,
email_verified: true,
phone: null,
totp_secret: null,
authenticator_types: null,
password_hash: passwordHash,
password_last_changed_at: now,
},
user.toRow(),
);
await this.repository.deleteEmailRevertToken(token);
await this.repository.deleteAllMfaBackupCodes(user.id);
await this.repository.deleteAllWebAuthnCredentials(user.id);
await this.repository.deleteAllAuthorizedIps(user.id);
await this.terminateAllUserSessions(user.id);
await this.repository.createAuthorizedIp(
user.id,
requireClientIp(request, {
trustCfConnectingIp: Config.proxy.trust_cf_connecting_ip,
}),
);
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 this.gatewayService.dispatchPresence({
userId: updatedUser.id,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
const [authToken] = await this.createAuthSession({user: updatedUser, request});
await this.contactChangeLogService.recordDiff({
oldUser: user,
newUser: updatedUser,
reason: 'user_requested',
actorUserId: user.id,
});
return {user_id: updatedUser.id.toString(), token: authToken};
}
}

View File

@@ -0,0 +1,136 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createEmailVerificationToken} from '@fluxer/api/src/BrandedTypes';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import {Logger} from '@fluxer/api/src/Logger';
import type {User} from '@fluxer/api/src/models/User';
import {getUserSearchService} from '@fluxer/api/src/SearchFactory';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {mapUserToPrivateResponse} from '@fluxer/api/src/user/UserMappers';
import {SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {VerifyEmailRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {ms} from 'itty-time';
export const EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS =
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL |
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE;
export class AuthEmailService {
constructor(
private repository: IUserRepository,
private emailService: IEmailService,
private gatewayService: IGatewayService,
private rateLimitService: IRateLimitService,
private assertNonBotUser: (user: User) => void,
private generateSecureToken: () => Promise<string>,
) {}
async verifyEmail(data: VerifyEmailRequest): Promise<boolean> {
const tokenData = await this.repository.getEmailVerificationToken(data.token);
if (!tokenData) {
return false;
}
const user = await this.repository.findUnique(tokenData.userId);
if (!user) {
return false;
}
this.assertNonBotUser(user);
if (user.flags & UserFlags.DELETED) {
return false;
}
const updates: {email_verified: boolean; suspicious_activity_flags?: number} = {
email_verified: true,
};
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
const newFlags = user.suspiciousActivityFlags & ~EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS;
if (newFlags !== user.suspiciousActivityFlags) {
updates.suspicious_activity_flags = newFlags;
}
}
const updatedUser = await this.repository.patchUpsert(user.id, updates, user.toRow());
await this.repository.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 this.gatewayService.dispatchPresence({
userId: user.id,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
return true;
}
async resendVerificationEmail(user: User): Promise<void> {
this.assertNonBotUser(user);
const allowReverification =
user.suspiciousActivityFlags !== null &&
((user.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL) !== 0 ||
(user.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE) !== 0 ||
(user.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE) !== 0 ||
(user.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE) !== 0);
if (user.emailVerified && !allowReverification) {
return;
}
const rateLimit = await this.rateLimitService.checkLimit({
identifier: `email_verification:${user.email!}`,
maxAttempts: 3,
windowMs: ms('15 minutes'),
});
if (!rateLimit.allowed) {
throw new RateLimitError({
retryAfter: rateLimit.retryAfter || 0,
limit: rateLimit.limit,
resetTime: rateLimit.resetTime,
});
}
const emailVerifyToken = createEmailVerificationToken(await this.generateSecureToken());
await this.repository.createEmailVerificationToken({
token_: emailVerifyToken,
user_id: user.id,
email: user.email!,
});
await this.emailService.sendEmailVerification(user.email!, user.username, emailVerifyToken, user.locale);
}
}

View File

@@ -0,0 +1,600 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createInviteCode,
createIpAuthorizationTicket,
createIpAuthorizationToken,
createMfaTicket,
createUserID,
type UserID,
} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
import type {InviteService} from '@fluxer/api/src/invite/InviteService';
import {Logger} from '@fluxer/api/src/Logger';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {User} from '@fluxer/api/src/models/User';
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {formatGeoipLocation, lookupGeoip, UNKNOWN_LOCATION} from '@fluxer/api/src/utils/IpUtils';
import * as RandomUtils from '@fluxer/api/src/utils/RandomUtils';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {UserAuthenticatorTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import {IpAuthorizationRequiredError} from '@fluxer/errors/src/domains/auth/IpAuthorizationRequiredError';
import {IpAuthorizationResendCooldownError} from '@fluxer/errors/src/domains/auth/IpAuthorizationResendCooldownError';
import {IpAuthorizationResendLimitExceededError} from '@fluxer/errors/src/domains/auth/IpAuthorizationResendLimitExceededError';
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 type {IRateLimitService, RateLimitResult} from '@fluxer/rate_limit/src/IRateLimitService';
import type {LoginRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import type {AuthenticationResponseJSON} from '@simplewebauthn/server';
import {ms, seconds} from 'itty-time';
function createRequestCache(): RequestCache {
return {
userPartials: new Map(),
clear: () => {},
};
}
interface LoginParams {
data: LoginRequest;
request: Request;
}
interface LoginMfaTotpParams {
code: string;
ticket: string;
request: Request;
}
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;
username: string;
clientIp: string;
userAgent: string;
platform: string | null;
authToken: string;
clientLocation: string;
inviteCode?: string | null;
resendUsed?: boolean;
createdAt: number;
}
export class AuthLoginService {
constructor(
private repository: IUserRepository,
private inviteService: InviteService,
private cacheService: ICacheService,
private rateLimitService: IRateLimitService,
private emailService: IEmailService,
private kvDeletionQueue: KVAccountDeletionQueueService,
private verifyPassword: (params: {password: string; passwordHash: string}) => Promise<boolean>,
private handleBanStatus: (user: User) => Promise<User>,
private assertNonBotUser: (user: User) => void,
private createAuthSession: (params: {user: User; request: Request}) => Promise<[string, AuthSession]>,
private generateSecureToken: () => Promise<string>,
private verifyMfaCode: (params: {
userId: UserID;
mfaSecret: string;
code: string;
allowBackup?: boolean;
}) => Promise<boolean>,
private verifySmsMfaCode: (userId: UserID, code: string) => Promise<boolean>,
private verifyWebAuthnAuthentication: (
userId: UserID,
response: AuthenticationResponseJSON,
expectedChallenge: string,
context?: 'registration' | 'discoverable' | 'mfa' | 'sudo',
ticket?: string,
) => Promise<void>,
) {}
private getTicketCacheKey(ticket: string): string {
return `ip-auth-ticket:${ticket}`;
}
private getTokenCacheKey(token: string): string {
return `ip-auth-token:${token}`;
}
async resendIpAuthorization(ticket: string): Promise<{retryAfter?: number}> {
const cacheKey = this.getTicketCacheKey(ticket);
const payload = await this.cacheService.get<IpAuthorizationTicketCache>(cacheKey);
if (!payload) {
throw InputValidationError.create('ticket', 'Invalid or expired authorization ticket');
}
const now = Date.now();
const secondsSinceCreation = Math.floor((now - payload.createdAt) / 1000);
if (payload.resendUsed) {
throw new IpAuthorizationResendLimitExceededError();
}
const minDelay = 30;
if (secondsSinceCreation < minDelay) {
throw new IpAuthorizationResendCooldownError(minDelay - secondsSinceCreation);
}
await this.emailService.sendIpAuthorizationEmail(
payload.email,
payload.username,
payload.authToken,
payload.clientIp,
payload.clientLocation,
null,
);
const ttl = await this.cacheService.ttl(cacheKey);
await this.cacheService.set(
cacheKey,
{
...payload,
resendUsed: true,
},
ttl > 0 ? ttl : undefined,
);
return {};
}
async completeIpAuthorization(token: string): Promise<{token: string; user_id: string; ticket: string}> {
const tokenMapping = await this.cacheService.get<{ticket: string}>(this.getTokenCacheKey(token));
if (!tokenMapping?.ticket) {
throw InputValidationError.create('token', 'Invalid or expired authorization token');
}
const cacheKey = this.getTicketCacheKey(tokenMapping.ticket);
const payload = await this.cacheService.get<IpAuthorizationTicketCache>(cacheKey);
if (!payload) {
throw InputValidationError.create('token', 'Invalid or expired authorization token');
}
const repoResult = await this.repository.authorizeIpByToken(token);
if (!repoResult || repoResult.userId.toString() !== payload.userId) {
throw InputValidationError.create('token', 'Invalid or expired authorization token');
}
const user = await this.repository.findUnique(createUserID(BigInt(payload.userId)));
if (!user) {
throw new UnknownUserError();
}
this.assertNonBotUser(user);
await this.repository.createAuthorizedIp(user.id, payload.clientIp);
const headers: Record<string, string> = {
'X-Forwarded-For': payload.clientIp,
'user-agent': payload.userAgent,
};
if (payload.platform) {
headers['x-fluxer-platform'] = payload.platform;
}
const syntheticRequest = new Request('https://api.fluxer.app/auth/ip-authorization', {
headers,
method: 'POST',
});
const [sessionToken] = await this.createAuthSession({user, request: syntheticRequest});
await this.cacheService.delete(cacheKey);
await this.cacheService.delete(this.getTokenCacheKey(token));
getMetricsService().counter({
name: 'user.login',
dimensions: {mfa_type: 'ip_authorization'},
});
getMetricsService().counter({
name: 'auth.login.success',
});
return {token: sessionToken, user_id: user.id.toString(), ticket: tokenMapping.ticket};
}
async login({
data,
request,
}: LoginParams): Promise<
| {user_id: string; token: string}
| {mfa: true; ticket: string; allowed_methods: Array<string>; sms_phone_hint: string | null}
> {
return await withBusinessSpan('fluxer.auth.login', 'fluxer.auth.logins', {}, () =>
this.performLogin({data, request}),
);
}
private async performLogin({
data,
request,
}: LoginParams): Promise<
| {user_id: string; token: string}
| {mfa: true; ticket: string; allowed_methods: Array<string>; sms_phone_hint: string | null}
> {
const skipRateLimits = Config.dev.testModeEnabled || Config.dev.disableRateLimits;
const emailRateLimit = await this.rateLimitService.checkLimit({
identifier: `login:email:${data.email}`,
maxAttempts: 5,
windowMs: ms('15 minutes'),
});
if (!emailRateLimit.allowed && !skipRateLimits) {
throwLoginRateLimit(emailRateLimit);
}
const clientIp = requireClientIp(request, {
trustCfConnectingIp: Config.proxy.trust_cf_connecting_ip,
});
const ipRateLimit = await this.rateLimitService.checkLimit({
identifier: `login:ip:${clientIp}`,
maxAttempts: 10,
windowMs: ms('30 minutes'),
});
if (!ipRateLimit.allowed && !skipRateLimits) {
throwLoginRateLimit(ipRateLimit);
}
const user = await this.repository.findByEmail(data.email);
if (!user) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'invalid_credentials'},
});
throw InputValidationError.fromCodes([
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
]);
}
this.assertNonBotUser(user);
const isMatch = await this.verifyPassword({
password: data.password,
passwordHash: user.passwordHash!,
});
if (!isMatch) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'invalid_credentials'},
});
throw InputValidationError.fromCodes([
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
]);
}
let currentUser = await this.handleBanStatus(user);
if ((currentUser.flags & UserFlags.DISABLED) !== 0n && !currentUser.tempBannedUntil) {
const updatedFlags = currentUser.flags & ~UserFlags.DISABLED;
currentUser = await this.repository.patchUpsert(
currentUser.id,
{
flags: updatedFlags,
},
currentUser.toRow(),
);
Logger.info({userId: currentUser.id}, 'Auto-undisabled user on login');
}
if ((currentUser.flags & UserFlags.SELF_DELETED) !== 0n) {
if (currentUser.pendingDeletionAt) {
await this.repository.removePendingDeletion(currentUser.id, currentUser.pendingDeletionAt);
}
await this.kvDeletionQueue.removeFromQueue(currentUser.id);
const updatedFlags = currentUser.flags & ~UserFlags.SELF_DELETED;
currentUser = await this.repository.patchUpsert(
currentUser.id,
{
flags: updatedFlags,
pending_deletion_at: null,
},
currentUser.toRow(),
);
Logger.info({userId: currentUser.id}, 'Auto-cancelled deletion on login');
}
const hasMfa = (currentUser.authenticatorTypes?.size ?? 0) > 0;
const isAppStoreReviewer = (currentUser.flags & UserFlags.APP_STORE_REVIEWER) !== 0n;
if (!hasMfa && !isAppStoreReviewer) {
const isIpAuthorized = await this.repository.checkIpAuthorized(currentUser.id, clientIp);
if (!isIpAuthorized) {
const ticket = createIpAuthorizationTicket(await this.generateSecureToken());
const authToken = createIpAuthorizationToken(await this.generateSecureToken());
const geoipResult = await lookupGeoip(clientIp);
const clientLocation = formatGeoipLocation(geoipResult) ?? UNKNOWN_LOCATION;
const userAgent = request.headers.get('user-agent') || '';
const platform = request.headers.get('x-fluxer-platform');
const cachePayload: IpAuthorizationTicketCache = {
userId: currentUser.id.toString(),
email: currentUser.email!,
username: currentUser.username,
clientIp,
userAgent,
platform: platform ?? null,
authToken,
clientLocation,
inviteCode: data.invite_code ?? null,
resendUsed: false,
createdAt: Date.now(),
};
const ttlSeconds = seconds('15 minutes');
await this.cacheService.set<IpAuthorizationTicketCache>(`ip-auth-ticket:${ticket}`, cachePayload, ttlSeconds);
await this.cacheService.set<{ticket: string}>(`ip-auth-token:${authToken}`, {ticket}, ttlSeconds);
await this.repository.createIpAuthorizationToken(currentUser.id, authToken, currentUser.email!);
await this.emailService.sendIpAuthorizationEmail(
currentUser.email!,
currentUser.username,
authToken,
clientIp,
clientLocation,
currentUser.locale,
);
throw new IpAuthorizationRequiredError({
ticket,
email: currentUser.email!,
resendAvailableIn: 30,
});
}
}
if (hasMfa) {
return await this.createMfaTicketResponse(currentUser);
}
if (data.invite_code && this.inviteService) {
try {
await this.inviteService.acceptInvite({
userId: currentUser.id,
inviteCode: createInviteCode(data.invite_code),
requestCache: createRequestCache(),
});
} catch (error) {
Logger.warn({inviteCode: data.invite_code, error}, 'Failed to auto-join invite on login');
}
}
const [token] = await this.createAuthSession({user: currentUser, request});
getMetricsService().counter({
name: 'user.login',
dimensions: {mfa_type: 'none'},
});
getMetricsService().counter({
name: 'auth.login.success',
});
return {
user_id: currentUser.id.toString(),
token,
};
}
async loginMfaTotp({code, ticket, request}: LoginMfaTotpParams): Promise<{user_id: string; token: string}> {
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_ticket_expired'},
});
throw InputValidationError.create('code', 'Session timeout. Please refresh the page and log in again.');
}
const user = await this.repository.findUnique(createUserID(BigInt(userId)));
if (!user) {
throw new UnknownUserError();
}
this.assertNonBotUser(user);
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_not_enabled'},
});
throw InputValidationError.create('code', 'TOTP is not enabled for this account');
}
const isValid = await this.verifyMfaCode({
userId: user.id,
mfaSecret: user.totpSecret,
code,
allowBackup: true,
});
if (!isValid) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_invalid'},
});
throw InputValidationError.create('code', 'Invalid code');
}
await this.cacheService.delete(`mfa-ticket:${ticket}`);
const [token] = await this.createAuthSession({user, request});
getMetricsService().counter({
name: 'user.login',
dimensions: {mfa_type: 'totp'},
});
getMetricsService().counter({
name: 'auth.login.success',
});
return {user_id: user.id.toString(), token};
}
async loginMfaSms({
code,
ticket,
request,
}: {
code: string;
ticket: string;
request: Request;
}): Promise<{user_id: string; token: string}> {
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_ticket_expired'},
});
throw InputValidationError.create('code', 'Session timeout. Please refresh the page and log in again.');
}
const user = await this.repository.findUnique(createUserID(BigInt(userId)));
if (!user) {
throw new UnknownUserError();
}
this.assertNonBotUser(user);
const isValid = await this.verifySmsMfaCode(user.id, code);
if (!isValid) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_invalid'},
});
throw InputValidationError.create('code', 'Invalid code');
}
await this.cacheService.delete(`mfa-ticket:${ticket}`);
const [token] = await this.createAuthSession({user, request});
getMetricsService().counter({
name: 'user.login',
dimensions: {mfa_type: 'sms'},
});
getMetricsService().counter({
name: 'auth.login.success',
});
return {user_id: user.id.toString(), token};
}
async loginMfaWebAuthn({
response,
challenge,
ticket,
request,
}: {
response: AuthenticationResponseJSON;
challenge: string;
ticket: string;
request: Request;
}): Promise<{user_id: string; token: string}> {
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_ticket_expired'},
});
throw InputValidationError.create('ticket', 'Session timeout. Please refresh the page and log in again.');
}
const user = await this.repository.findUnique(createUserID(BigInt(userId)));
if (!user) {
throw new UnknownUserError();
}
this.assertNonBotUser(user);
await this.verifyWebAuthnAuthentication(user.id, response, challenge, 'mfa', ticket);
await this.cacheService.delete(`mfa-ticket:${ticket}`);
const [token] = await this.createAuthSession({user, request});
getMetricsService().counter({
name: 'user.login',
dimensions: {mfa_type: 'webauthn'},
});
getMetricsService().counter({
name: 'auth.login.success',
});
return {user_id: user.id.toString(), token};
}
private async createMfaTicketResponse(user: User): Promise<{
mfa: true;
ticket: string;
allowed_methods: Array<string>;
sms_phone_hint: string | null;
sms: boolean;
totp: boolean;
webauthn: boolean;
}> {
const ticket = createMfaTicket(RandomUtils.randomString(64));
await this.cacheService.set(`mfa-ticket:${ticket}`, user.id.toString(), seconds('5 minutes'));
const credentials = await this.repository.listWebAuthnCredentials(user.id);
const hasSms = user.authenticatorTypes.has(UserAuthenticatorTypes.SMS);
const hasWebauthn = credentials.length > 0;
const hasTotp = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
const allowedMethods: Array<string> = [];
if (hasTotp) allowedMethods.push('totp');
if (hasSms) allowedMethods.push('sms');
if (hasWebauthn) allowedMethods.push('webauthn');
return {
mfa: true,
ticket: ticket,
allowed_methods: allowedMethods,
sms_phone_hint: user.phone ? this.maskPhone(user.phone) : null,
sms: hasSms,
totp: hasTotp,
webauthn: hasWebauthn,
};
}
private maskPhone(phone: string): string {
if (phone.length < 4) return '****';
return `****${phone.slice(-4)}`;
}
}

View File

@@ -0,0 +1,715 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import {createUserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
import {Logger} from '@fluxer/api/src/Logger';
import type {User} from '@fluxer/api/src/models/User';
import type {BotMfaMirrorService} from '@fluxer/api/src/oauth/BotMfaMirrorService';
import {getUserSearchService} from '@fluxer/api/src/SearchFactory';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {mapUserToPrivateResponse} from '@fluxer/api/src/user/UserMappers';
import {TotpGenerator} from '@fluxer/api/src/utils/TotpGenerator';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {InvalidWebAuthnAuthenticationCounterError} from '@fluxer/errors/src/domains/auth/InvalidWebAuthnAuthenticationCounterError';
import {InvalidWebAuthnCredentialCounterError} from '@fluxer/errors/src/domains/auth/InvalidWebAuthnCredentialCounterError';
import {InvalidWebAuthnCredentialError} from '@fluxer/errors/src/domains/auth/InvalidWebAuthnCredentialError';
import {InvalidWebAuthnPublicKeyFormatError} from '@fluxer/errors/src/domains/auth/InvalidWebAuthnPublicKeyFormatError';
import {NoPasskeysRegisteredError} from '@fluxer/errors/src/domains/auth/NoPasskeysRegisteredError';
import {PasskeyAuthenticationFailedError} from '@fluxer/errors/src/domains/auth/PasskeyAuthenticationFailedError';
import {PhoneRequiredForSmsMfaError} from '@fluxer/errors/src/domains/auth/PhoneRequiredForSmsMfaError';
import {SmsMfaNotEnabledError} from '@fluxer/errors/src/domains/auth/SmsMfaNotEnabledError';
import {SmsMfaRequiresTotpError} from '@fluxer/errors/src/domains/auth/SmsMfaRequiresTotpError';
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import {UnknownWebAuthnCredentialError} from '@fluxer/errors/src/domains/auth/UnknownWebAuthnCredentialError';
import {WebAuthnCredentialLimitReachedError} from '@fluxer/errors/src/domains/auth/WebAuthnCredentialLimitReachedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {ISmsService} from '@fluxer/sms/src/ISmsService';
import type {AuthenticationResponseJSON, RegistrationResponseJSON} from '@simplewebauthn/server';
import {
generateAuthenticationOptions,
generateRegistrationOptions,
type VerifiedAuthenticationResponse,
type VerifiedRegistrationResponse,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import {seconds} from 'itty-time';
type WebAuthnChallengeContext = 'registration' | 'discoverable' | 'mfa' | 'sudo';
interface SudoMfaVerificationParams {
userId: UserID;
method: 'totp' | 'sms' | 'webauthn';
code?: string;
webauthnResponse?: AuthenticationResponseJSON;
webauthnChallenge?: string;
}
interface SudoMfaVerificationResult {
success: boolean;
error?: string;
}
interface VerifyMfaCodeParams {
userId: UserID;
mfaSecret: string;
code: string;
allowBackup?: boolean;
}
export class AuthMfaService {
constructor(
private repository: IUserRepository,
private cacheService: ICacheService,
private smsService: ISmsService,
private gatewayService: IGatewayService,
private botMfaMirrorService?: BotMfaMirrorService,
) {}
async verifyMfaCode({userId, mfaSecret, code, allowBackup = false}: VerifyMfaCodeParams): Promise<boolean> {
try {
const totp = new TotpGenerator(mfaSecret);
const isValidTotp = await totp.validateTotp(code);
if (isValidTotp) {
if (Config.dev.testModeEnabled) {
return true;
}
const reuseKey = `mfa-totp:${userId}:${code}`;
const lockToken = await this.cacheService.acquireLock(reuseKey, seconds('30 seconds'));
if (lockToken) {
return true;
}
}
} catch (error) {
Logger.error({userId, code: `${code.slice(0, 3)}***`, error}, 'Failed to validate TOTP code');
}
if (allowBackup) {
const backupCodes = await this.repository.listMfaBackupCodes(userId);
const backupCode = backupCodes.find((bc) => bc.code === code && !bc.consumed);
if (backupCode) {
await this.repository.consumeMfaBackupCode(userId, code);
return true;
}
}
return false;
}
async enableSmsMfa(userId: UserID): Promise<void> {
if (!Config.dev.testModeEnabled && !Config.sms.enabled) {
throw new SmsVerificationUnavailableError();
}
const user = await this.repository.findUniqueAssert(userId);
if (!user.totpSecret) {
throw new SmsMfaRequiresTotpError();
}
if (!user.phone) {
throw new PhoneRequiredForSmsMfaError();
}
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
authenticatorTypes.add(UserAuthenticatorTypes.SMS);
const updatedUser = await this.repository.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 this.gatewayService.dispatchPresence({
userId,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
}
async disableSmsMfa(userId: UserID): Promise<void> {
const user = await this.repository.findUniqueAssert(userId);
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
authenticatorTypes.delete(UserAuthenticatorTypes.SMS);
const updatedUser = await this.repository.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 this.gatewayService.dispatchPresence({
userId,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
}
async sendSmsMfaCode(userId: UserID): Promise<void> {
const user = await this.repository.findUniqueAssert(userId);
if (!user.authenticatorTypes?.has(UserAuthenticatorTypes.SMS)) {
throw new SmsMfaNotEnabledError();
}
if (!user.phone) {
throw new PhoneRequiredForSmsMfaError();
}
await this.smsService.startVerification(user.phone);
}
async sendSmsMfaCodeForTicket(ticket: string): Promise<void> {
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.create('ticket', 'Session timeout. Please refresh the page and log in again.');
}
await this.sendSmsMfaCode(createUserID(BigInt(userId)));
}
async verifySmsMfaCode(userId: UserID, code: string): Promise<boolean> {
const user = await this.repository.findUnique(userId);
if (!user || !user.phone) {
return false;
}
return await this.smsService.checkVerification(user.phone, code);
}
async generateWebAuthnRegistrationOptions(userId: UserID) {
const user = await this.repository.findUniqueAssert(userId);
const existingCredentials = await this.repository.listWebAuthnCredentials(userId);
if (existingCredentials.length >= 10) {
throw new WebAuthnCredentialLimitReachedError();
}
const rpName = Config.auth.passkeys.rpName;
const rpID = Config.auth.passkeys.rpId;
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: new TextEncoder().encode(user.id.toString()),
userName: user.username!,
userDisplayName: user.username!,
attestationType: 'none',
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
: undefined,
})),
authenticatorSelection: {
residentKey: 'required',
requireResidentKey: true,
userVerification: 'required',
},
});
await this.saveWebAuthnChallenge(options.challenge, {context: 'registration', userId});
return options;
}
async verifyWebAuthnRegistration(
userId: UserID,
response: RegistrationResponseJSON,
expectedChallenge: string,
name: string,
): Promise<void> {
const user = await this.repository.findUniqueAssert(userId);
const existingCredentials = await this.repository.listWebAuthnCredentials(userId);
await this.consumeWebAuthnChallenge(expectedChallenge, 'registration', {userId});
if (existingCredentials.length >= 10) {
throw new WebAuthnCredentialLimitReachedError();
}
if (Config.dev.testModeEnabled) {
const responseObj = response as {id?: string; response?: {transports?: Array<string>}};
const credentialId = responseObj.id ?? `test-credential:${userId.toString()}:${Date.now()}`;
const publicKeyBuffer = Buffer.from(`test-public-key:${credentialId}`);
await this.repository.createWebAuthnCredential(
userId,
credentialId,
publicKeyBuffer,
0n,
responseObj.response?.transports ? new Set(responseObj.response.transports) : null,
name,
);
} else {
const rpID = Config.auth.passkeys.rpId;
const expectedOrigin = Config.auth.passkeys.allowedOrigins;
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin,
expectedRPID: rpID,
});
} catch (error) {
Logger.error({error, userId, expectedChallenge, rpID, expectedOrigin}, 'WebAuthn verification failed');
throw new InvalidWebAuthnCredentialError();
}
if (!verification.verified || !verification.registrationInfo) {
Logger.error(
{userId, verified: verification.verified, hasRegistrationInfo: !!verification.registrationInfo},
'WebAuthn verification result invalid',
);
throw new InvalidWebAuthnCredentialError();
}
const {credential} = verification.registrationInfo;
let publicKeyBuffer: Buffer;
let counterBigInt: bigint;
try {
publicKeyBuffer = Buffer.from(credential.publicKey);
} catch (_error) {
throw new InvalidWebAuthnPublicKeyFormatError();
}
try {
if (credential.counter === undefined || credential.counter === null) {
throw new Error('Counter value is undefined or null');
}
counterBigInt = BigInt(credential.counter);
} catch (_error) {
throw new InvalidWebAuthnCredentialCounterError();
}
const responseObj = response as {response?: {transports?: Array<string>}};
await this.repository.createWebAuthnCredential(
userId,
credential.id,
publicKeyBuffer,
counterBigInt,
responseObj.response?.transports ? new Set(responseObj.response.transports) : null,
name,
);
}
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
if (!authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
const updatedUser = await this.repository.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 this.gatewayService.dispatchPresence({
userId,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
}
}
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
const credential = await this.repository.getWebAuthnCredential(userId, credentialId);
if (!credential) {
throw new UnknownWebAuthnCredentialError();
}
await this.repository.deleteWebAuthnCredential(userId, credentialId);
const remainingCredentials = await this.repository.listWebAuthnCredentials(userId);
if (remainingCredentials.length === 0) {
const user = await this.repository.findUniqueAssert(userId);
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
const updatedUser = await this.repository.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 this.gatewayService.dispatchPresence({
userId,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
}
}
async renameWebAuthnCredential(userId: UserID, credentialId: string, name: string): Promise<void> {
const credential = await this.repository.getWebAuthnCredential(userId, credentialId);
if (!credential) {
throw new UnknownWebAuthnCredentialError();
}
await this.repository.updateWebAuthnCredentialName(userId, credentialId, name);
}
async generateWebAuthnAuthenticationOptionsDiscoverable() {
const rpID = Config.auth.passkeys.rpId;
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'required',
});
await this.saveWebAuthnChallenge(options.challenge, {context: 'discoverable'});
return options;
}
async verifyWebAuthnAuthenticationDiscoverable(
response: AuthenticationResponseJSON,
expectedChallenge: string,
): Promise<User> {
const responseObj = response as {id: string};
const credentialId = responseObj.id;
const userId = await this.repository.getUserIdByCredentialId(credentialId);
if (!userId) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_invalid'},
});
throw new PasskeyAuthenticationFailedError();
}
await this.verifyWebAuthnAuthentication(userId, response, expectedChallenge, 'discoverable');
const user = await this.repository.findUniqueAssert(userId);
return user;
}
async generateWebAuthnAuthenticationOptionsForMfa(ticket: string) {
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.create('ticket', 'Session timeout. Please refresh the page and log in again.');
}
const credentials = await this.repository.listWebAuthnCredentials(createUserID(BigInt(userId)));
if (credentials.length === 0) {
throw new NoPasskeysRegisteredError();
}
const rpID = Config.auth.passkeys.rpId;
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: credentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
: undefined,
})),
userVerification: 'required',
});
await this.saveWebAuthnChallenge(options.challenge, {context: 'mfa', userId: createUserID(BigInt(userId)), ticket});
return options;
}
async verifyWebAuthnAuthentication(
userId: UserID,
response: AuthenticationResponseJSON,
expectedChallenge: string,
context: WebAuthnChallengeContext = 'mfa',
ticket?: string,
): Promise<void> {
await this.consumeWebAuthnChallenge(expectedChallenge, context, {userId, ticket});
const responseObj = response as {id: string};
const credentialId = responseObj.id;
const credential = await this.repository.getWebAuthnCredential(userId, credentialId);
if (!credential) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_invalid'},
});
throw new PasskeyAuthenticationFailedError();
}
if (Config.dev.testModeEnabled) {
const newCounter = credential.counter + 1n;
await this.repository.updateWebAuthnCredentialCounter(userId, credentialId, newCounter);
await this.repository.updateWebAuthnCredentialLastUsed(userId, credentialId);
return;
}
const rpID = Config.auth.passkeys.rpId;
const expectedOrigin = Config.auth.passkeys.allowedOrigins;
let verification: VerifiedAuthenticationResponse;
try {
let publicKeyUint8Array: Uint8Array<ArrayBuffer>;
try {
const buffer = Buffer.from(credential.publicKey);
const arrayBuffer: ArrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
publicKeyUint8Array = new Uint8Array(arrayBuffer);
} catch (_error) {
throw new InvalidWebAuthnPublicKeyFormatError();
}
verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin,
expectedRPID: rpID,
credential: {
id: credential.credentialId,
publicKey: publicKeyUint8Array,
counter: Number(credential.counter),
transports: credential.transports
? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
: undefined,
},
});
} catch (_error) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_invalid'},
});
throw new PasskeyAuthenticationFailedError();
}
if (!verification.verified) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_invalid'},
});
throw new PasskeyAuthenticationFailedError();
}
let newCounter: bigint;
try {
if (
verification.authenticationInfo.newCounter === undefined ||
verification.authenticationInfo.newCounter === null
) {
throw new Error('Counter value is undefined or null');
}
newCounter = BigInt(verification.authenticationInfo.newCounter);
} catch (_error) {
throw new InvalidWebAuthnAuthenticationCounterError();
}
await this.repository.updateWebAuthnCredentialCounter(userId, credentialId, newCounter);
await this.repository.updateWebAuthnCredentialLastUsed(userId, credentialId);
}
async generateWebAuthnOptionsForSudo(userId: UserID) {
const credentials = await this.repository.listWebAuthnCredentials(userId);
if (credentials.length === 0) {
throw new NoPasskeysRegisteredError();
}
const rpID = Config.auth.passkeys.rpId;
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: credentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
: undefined,
})),
userVerification: 'required',
});
await this.saveWebAuthnChallenge(options.challenge, {context: 'sudo', userId});
return options;
}
async verifySudoMfa(params: SudoMfaVerificationParams): Promise<SudoMfaVerificationResult> {
const {userId, method, code, webauthnResponse, webauthnChallenge} = params;
const user = await this.repository.findUnique(userId);
const hasMfa = (user?.authenticatorTypes?.size ?? 0) > 0;
if (!user || !hasMfa) {
return {success: false, error: 'MFA not enabled'};
}
switch (method) {
case 'totp': {
if (!code) {
return {success: false, error: 'TOTP code is required'};
}
if (!user.totpSecret) {
return {success: false, error: 'TOTP is not enabled'};
}
const isValid = await this.verifyMfaCode({
userId,
mfaSecret: user.totpSecret,
code,
allowBackup: true,
});
return {success: isValid, error: isValid ? undefined : 'Invalid TOTP code'};
}
case 'sms': {
if (!code) {
return {success: false, error: 'SMS code is required'};
}
if (!user.authenticatorTypes?.has(UserAuthenticatorTypes.SMS)) {
return {success: false, error: 'SMS MFA is not enabled'};
}
const isValid = await this.verifySmsMfaCode(userId, code);
return {success: isValid, error: isValid ? undefined : 'Invalid SMS code'};
}
case 'webauthn': {
if (!webauthnResponse || !webauthnChallenge) {
return {success: false, error: 'WebAuthn response and challenge are required'};
}
if (!user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN)) {
return {success: false, error: 'WebAuthn is not enabled'};
}
try {
await this.verifyWebAuthnAuthentication(userId, webauthnResponse, webauthnChallenge, 'sudo');
return {success: true};
} catch {
return {success: false, error: 'WebAuthn verification failed'};
}
}
default:
return {success: false, error: 'Invalid MFA method'};
}
}
async getAvailableMfaMethods(
userId: UserID,
): Promise<{totp: boolean; sms: boolean; webauthn: boolean; has_mfa: boolean}> {
const user = await this.repository.findUnique(userId);
if (!user) {
return {totp: false, sms: false, webauthn: false, has_mfa: false};
}
const hasMfa = (user.authenticatorTypes?.size ?? 0) > 0;
return {
totp: user.totpSecret !== null,
sms: user.authenticatorTypes?.has(UserAuthenticatorTypes.SMS) ?? false,
webauthn: user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false,
has_mfa: hasMfa,
};
}
private getWebAuthnChallengeCacheKey(challenge: string): string {
return `webauthn:challenge:${challenge}`;
}
private async saveWebAuthnChallenge(
challenge: string,
entry: {context: WebAuthnChallengeContext; userId?: UserID; ticket?: string},
): Promise<void> {
const key = this.getWebAuthnChallengeCacheKey(challenge);
await this.cacheService.set(
key,
{context: entry.context, userId: entry.userId?.toString(), ticket: entry.ticket},
seconds('5 minutes'),
);
}
private async consumeWebAuthnChallenge(
challenge: string,
expectedContext: WebAuthnChallengeContext,
{userId, ticket}: {userId?: UserID; ticket?: string} = {},
): Promise<void> {
const key = this.getWebAuthnChallengeCacheKey(challenge);
const cached = await this.cacheService.get<{context: WebAuthnChallengeContext; userId?: string; ticket?: string}>(
key,
);
const challengeMatches =
cached &&
cached.context === expectedContext &&
(userId === undefined || cached.userId === undefined || cached.userId === userId.toString()) &&
(ticket === undefined || cached.ticket === undefined || cached.ticket === ticket);
if (!challengeMatches) {
Logger.error(
{
challenge,
expectedContext,
userId: userId?.toString(),
ticket,
cached,
contextMatches: cached?.context === expectedContext,
userIdMatches: userId === undefined || cached?.userId === undefined || cached?.userId === userId.toString(),
ticketMatches: ticket === undefined || cached?.ticket === undefined || cached?.ticket === ticket,
},
'WebAuthn challenge mismatch',
);
throw this.createChallengeError(expectedContext);
}
await this.cacheService.delete(key);
}
private createChallengeError(context: WebAuthnChallengeContext) {
if (context === 'registration') {
return new InvalidWebAuthnCredentialError();
}
return new PasskeyAuthenticationFailedError();
}
}

View File

@@ -0,0 +1,311 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import {createPasswordResetToken} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import {Logger} from '@fluxer/api/src/Logger';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {
hashPassword as hashPasswordUtil,
verifyPassword as verifyPasswordUtil,
} from '@fluxer/api/src/utils/PasswordUtils';
import {FLUXER_USER_AGENT} from '@fluxer/constants/src/Core';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
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 type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import type {ForgotPasswordRequest, ResetPasswordRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {ms} from 'itty-time';
interface CacheEntry {
result: boolean;
expiresAt: number;
}
class PwnedPasswordCache {
private cache = new Map<string, CacheEntry>();
private readonly maxSize: number;
private readonly ttlMs: number;
constructor(maxSize = 1000, ttlMs = ms('1 hour')) {
this.maxSize = maxSize;
this.ttlMs = ttlMs;
}
get(key: string): boolean | undefined {
const entry = this.cache.get(key);
if (!entry) {
return undefined;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return undefined;
}
this.cache.delete(key);
this.cache.set(key, entry);
return entry.result;
}
set(key: string, result: boolean): void {
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, {
result,
expiresAt: Date.now() + this.ttlMs,
});
}
clear(): void {
this.cache.clear();
}
}
interface ForgotPasswordParams {
data: ForgotPasswordRequest;
request: Request;
}
interface ResetPasswordParams {
data: ResetPasswordRequest;
request: Request;
}
interface VerifyPasswordParams {
password: string;
passwordHash: string;
}
const pwnedPasswordCache = new PwnedPasswordCache(1000, ms('1 hour'));
export class AuthPasswordService {
constructor(
private repository: IUserRepository,
private emailService: IEmailService,
private rateLimitService: IRateLimitService,
private generateSecureToken: () => Promise<string>,
private handleBanStatus: (user: User) => Promise<User>,
private assertNonBotUser: (user: User) => void,
private createMfaTicketResponse: (user: User) => Promise<{
mfa: true;
ticket: string;
allowed_methods: Array<string>;
sms_phone_hint: string | null;
sms: boolean;
totp: boolean;
webauthn: boolean;
}>,
private createAuthSession: (params: {user: User; request: Request}) => Promise<[string, AuthSession]>,
) {}
async hashPassword(password: string): Promise<string> {
return hashPasswordUtil(password);
}
async verifyPassword({password, passwordHash}: VerifyPasswordParams): Promise<boolean> {
return verifyPasswordUtil({password, passwordHash});
}
async isPasswordPwned(password: string): Promise<boolean> {
const hashed = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const hashPrefix = hashed.slice(0, 5);
const hashSuffix = hashed.slice(5);
const cachedResult = pwnedPasswordCache.get(hashed);
if (cachedResult !== undefined) {
return cachedResult;
}
try {
const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`, {
headers: {
'User-Agent': FLUXER_USER_AGENT,
'Add-Padding': 'true',
},
});
if (!response.ok) {
Logger.warn(
{
status: response.status,
statusText: response.statusText,
hashPrefix,
},
'Pwned Passwords API returned non-OK status',
);
return false;
}
const body = await response.text();
const MAX_PWNED_LINES = 10_000;
const lines = body.split('\n');
if (lines.length > MAX_PWNED_LINES) {
Logger.warn(
{
lineCount: lines.length,
maxAllowed: MAX_PWNED_LINES,
hashPrefix,
},
'Pwned Passwords API response exceeded safe line limit, truncating',
);
}
const limit = Math.min(lines.length, MAX_PWNED_LINES);
for (let i = 0; i < limit; i++) {
const line = lines[i];
const [hashSuffixLine, count] = line.split(':', 2);
if (
hashSuffixLine.length === hashSuffix.length &&
crypto.timingSafeEqual(Buffer.from(hashSuffixLine), Buffer.from(hashSuffix)) &&
Number.parseInt(count, 10) > 0
) {
pwnedPasswordCache.set(hashed, true);
return true;
}
}
pwnedPasswordCache.set(hashed, false);
return false;
} catch (error) {
Logger.error({error}, 'Failed to check password against Pwned Passwords API');
return false;
}
}
async forgotPassword({data, request}: ForgotPasswordParams): Promise<void> {
const clientIp = requireClientIp(request, {
trustCfConnectingIp: Config.proxy.trust_cf_connecting_ip,
});
const ipLimitConfig = {maxAttempts: 20, windowMs: ms('30 minutes')};
const emailLimitConfig = {maxAttempts: 5, windowMs: ms('30 minutes')};
const ipRateLimit = await this.rateLimitService.checkLimit({
identifier: `password_reset:ip:${clientIp}`,
...ipLimitConfig,
});
const emailRateLimit = await this.rateLimitService.checkLimit({
identifier: `password_reset:email:${data.email.toLowerCase()}`,
...emailLimitConfig,
});
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,
});
}
const user = await this.repository.findByEmail(data.email);
if (!user) {
return;
}
this.assertNonBotUser(user);
const token = createPasswordResetToken(await this.generateSecureToken());
await this.repository.createPasswordResetToken({
token_: token,
user_id: user.id,
email: user.email!,
});
await this.emailService.sendPasswordResetEmail(user.email!, user.username, token, user.locale);
}
async resetPassword({data, request}: ResetPasswordParams): Promise<
| {user_id: string; token: string}
| {
mfa: true;
ticket: string;
allowed_methods: Array<string>;
sms_phone_hint: string | null;
sms: boolean;
totp: boolean;
webauthn: boolean;
}
> {
const tokenData = await this.repository.getPasswordResetToken(data.token);
if (!tokenData) {
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN);
}
const user = await this.repository.findUnique(tokenData.userId);
if (!user) {
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN);
}
this.assertNonBotUser(user);
if (user.flags & UserFlags.DELETED) {
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN);
}
await this.handleBanStatus(user);
if (await this.isPasswordPwned(data.password)) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
}
const newPasswordHash = await this.hashPassword(data.password);
const updatedUser = await this.repository.patchUpsert(
user.id,
{
password_hash: newPasswordHash,
password_last_changed_at: new Date(),
},
user.toRow(),
);
await this.repository.deleteAllAuthSessions(user.id);
await this.repository.deletePasswordResetToken(data.token);
const hasMfa = (updatedUser.authenticatorTypes?.size ?? 0) > 0;
if (hasMfa) {
return await this.createMfaTicketResponse(updatedUser);
}
const [token] = await this.createAuthSession({user: updatedUser, request});
return {user_id: updatedUser.id.toString(), token};
}
}

View File

@@ -0,0 +1,206 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createPhoneVerificationToken, type UserID} from '@fluxer/api/src/BrandedTypes';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import {Logger} from '@fluxer/api/src/Logger';
import type {User} from '@fluxer/api/src/models/User';
import {getUserSearchService} from '@fluxer/api/src/SearchFactory';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
import {mapUserToPrivateResponse} from '@fluxer/api/src/user/UserMappers';
import {SuspiciousActivityFlags, UserAuthenticatorTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
import {InvalidPhoneNumberError} from '@fluxer/errors/src/domains/auth/InvalidPhoneNumberError';
import {InvalidPhoneVerificationCodeError} from '@fluxer/errors/src/domains/auth/InvalidPhoneVerificationCodeError';
import {PhoneAlreadyUsedError} from '@fluxer/errors/src/domains/auth/PhoneAlreadyUsedError';
import {PhoneVerificationRequiredError} from '@fluxer/errors/src/domains/auth/PhoneVerificationRequiredError';
import {SmsMfaNotEnabledError} from '@fluxer/errors/src/domains/auth/SmsMfaNotEnabledError';
import {PHONE_E164_REGEX} from '@fluxer/schema/src/primitives/UserValidators';
import type {ISmsService} from '@fluxer/sms/src/ISmsService';
const PHONE_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS =
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE;
export class AuthPhoneService {
constructor(
private repository: IUserRepository,
private smsService: ISmsService,
private gatewayService: IGatewayService,
private assertNonBotUser: (user: User) => void,
private generateSecureToken: () => Promise<string>,
private contactChangeLogService: UserContactChangeLogService,
) {}
async sendPhoneVerificationCode(phone: string, userId: UserID | null): Promise<void> {
if (!PHONE_E164_REGEX.test(phone)) {
throw new InvalidPhoneNumberError();
}
const existingUser = await this.repository.findByPhone(phone);
if (userId) {
const requestingUser = await this.repository.findUnique(userId);
if (requestingUser) {
this.assertNonBotUser(requestingUser);
}
}
const allowReverification =
existingUser &&
userId &&
existingUser.id === userId &&
existingUser.suspiciousActivityFlags !== null &&
((existingUser.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE) !== 0 ||
(existingUser.suspiciousActivityFlags &
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE) !==
0 ||
(existingUser.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE) !==
0 ||
(existingUser.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE) !==
0);
if (existingUser) {
this.assertNonBotUser(existingUser);
}
if (existingUser && (!userId || existingUser.id !== userId) && !allowReverification) {
throw new PhoneAlreadyUsedError();
}
await this.smsService.startVerification(phone);
}
async verifyPhoneCode(phone: string, code: string, userId: UserID | null): Promise<string> {
if (!PHONE_E164_REGEX.test(phone)) {
throw new InvalidPhoneNumberError();
}
const isValid = await this.smsService.checkVerification(phone, code);
if (!isValid) {
throw new InvalidPhoneVerificationCodeError();
}
const phoneToken = await this.generateSecureToken();
const phoneVerificationToken = createPhoneVerificationToken(phoneToken);
await this.repository.createPhoneToken(phoneVerificationToken, phone, userId);
return phoneToken;
}
async addPhoneToAccount(userId: UserID, phoneToken: string): Promise<void> {
const phoneVerificationToken = createPhoneVerificationToken(phoneToken);
const tokenData = await this.repository.getPhoneToken(phoneVerificationToken);
if (!tokenData) {
throw new PhoneVerificationRequiredError();
}
if (tokenData.user_id && tokenData.user_id !== userId) {
throw new PhoneVerificationRequiredError();
}
const existingUser = await this.repository.findByPhone(tokenData.phone);
if (existingUser && existingUser.id !== userId) {
throw new PhoneAlreadyUsedError();
}
const user = await this.repository.findUnique(userId);
if (!user) {
throw new PhoneVerificationRequiredError();
}
this.assertNonBotUser(user);
if (user.flags & UserFlags.DELETED) {
throw new PhoneVerificationRequiredError();
}
const updates: {phone: string; suspicious_activity_flags?: number} = {
phone: tokenData.phone,
};
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
const newFlags = user.suspiciousActivityFlags & ~PHONE_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS;
if (newFlags !== user.suspiciousActivityFlags) {
updates.suspicious_activity_flags = newFlags;
}
}
const updatedUser = await this.repository.patchUpsert(userId, updates, user.toRow());
await this.repository.deletePhoneToken(phoneVerificationToken);
await this.contactChangeLogService.recordDiff({
oldUser: user,
newUser: updatedUser,
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 this.gatewayService.dispatchPresence({
userId,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
}
async removePhoneFromAccount(userId: UserID): Promise<void> {
const user = await this.repository.findUniqueAssert(userId);
this.assertNonBotUser(user);
if (user.authenticatorTypes?.has(UserAuthenticatorTypes.SMS)) {
throw new SmsMfaNotEnabledError();
}
const updatedUser = await this.repository.patchUpsert(userId, {phone: null}, user.toRow());
await this.contactChangeLogService.recordDiff({
oldUser: user,
newUser: updatedUser,
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 this.gatewayService.dispatchPresence({
userId,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
}
}

View File

@@ -0,0 +1,717 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import {FIRST_ADMIN_ACL_CONFIG_KEY} from '@fluxer/api/src/constants/InstanceConfig';
import {deleteOneOrMany, executeConditional} from '@fluxer/api/src/database/Cassandra';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import type {KVActivityTracker} from '@fluxer/api/src/infrastructure/KVActivityTracker';
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
import {InstanceConfigRepository} from '@fluxer/api/src/instance/InstanceConfigRepository';
import type {SnowflakeReservationService} from '@fluxer/api/src/instance/SnowflakeReservationService';
import type {InviteService} from '@fluxer/api/src/invite/InviteService';
import {Logger} from '@fluxer/api/src/Logger';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {User} from '@fluxer/api/src/models/User';
import {UserSettings} from '@fluxer/api/src/models/UserSettings';
import {getUserSearchService} from '@fluxer/api/src/SearchFactory';
import {InstanceConfiguration, UserByEmail} from '@fluxer/api/src/Tables';
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import * as AgeUtils from '@fluxer/api/src/utils/AgeUtils';
import * as FetchUtils from '@fluxer/api/src/utils/FetchUtils';
import {
formatGeoipLocation,
type GeoipResult,
getIpAddressReverse,
lookupGeoip,
UNKNOWN_LOCATION,
} from '@fluxer/api/src/utils/IpUtils';
import {generateRandomUsername} from '@fluxer/api/src/utils/UsernameGenerator';
import {deriveUsernameFromDisplayName} from '@fluxer/api/src/utils/UsernameSuggestionUtils';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
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 {parseAcceptLanguage} from '@fluxer/locale/src/LocaleService';
import type {IRateLimitService, RateLimitResult} from '@fluxer/rate_limit/src/IRateLimitService';
import type {RegisterRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {recordCounter} from '@fluxer/telemetry/src/Metrics';
import Bowser from 'bowser';
import {types} from 'cassandra-driver';
import {ms} from 'itty-time';
const MINIMUM_AGE_BY_COUNTRY: Record<string, number> = {
KR: 14,
VN: 15,
AW: 16,
BQ: 16,
CW: 16,
SX: 16,
AT: 14,
BG: 14,
HR: 16,
CY: 14,
CZ: 15,
FR: 15,
DE: 16,
GR: 15,
HU: 16,
IE: 16,
IT: 14,
LT: 14,
LU: 16,
NL: 16,
PL: 16,
RO: 16,
SM: 16,
RS: 15,
SK: 16,
SI: 16,
ES: 14,
CL: 14,
CO: 14,
PE: 14,
VE: 14,
};
const DEFAULT_MINIMUM_AGE = 13;
const USER_AGENT_TRUNCATE_LENGTH = 512;
interface RegistrationMetadataContext {
metadata: Map<string, string>;
clientIp: string;
countryCode: string;
location: string;
city: string | null;
region: string | null;
osInfo: string;
browserInfo: string;
deviceInfo: string;
truncatedUserAgent: string;
fluxerTag: string;
displayName: string;
email: string;
ipAddressReverse: string | null;
}
const AGE_BUCKETS: Array<{label: string; min: number; max: number}> = [
{label: '0-12', min: 0, max: 12},
{label: '13-17', min: 13, max: 17},
{label: '18-24', min: 18, max: 24},
{label: '25-34', min: 25, max: 34},
{label: '35-44', min: 35, max: 44},
{label: '45-54', min: 45, max: 54},
{label: '55-64', min: 55, max: 64},
];
function determineAgeGroup(age: number | null): string {
if (age === null || age < 0) return 'unknown';
for (const bucket of AGE_BUCKETS) {
if (age >= bucket.min && age <= bucket.max) return bucket.label;
}
return '65+';
}
function isIpv6(ip: string): boolean {
return ip.includes(':');
}
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 {
try {
return types.LocalDate.fromString(dateOfBirth);
} catch {
throw InputValidationError.create('date_of_birth', 'Invalid date of birth format');
}
}
interface RegisterParams {
data: RegisterRequest;
request: Request;
requestCache: RequestCache;
}
export class AuthRegistrationService {
private instanceConfigRepository = new InstanceConfigRepository();
private hasWarnedAboutMissingWebhook = false;
constructor(
private repository: IUserRepository,
private inviteService: InviteService | null,
private rateLimitService: IRateLimitService,
private emailService: IEmailService,
private snowflakeService: SnowflakeService,
private snowflakeReservationService: SnowflakeReservationService,
private discriminatorService: IDiscriminatorService,
private kvActivityTracker: KVActivityTracker,
private cacheService: ICacheService,
private hashPassword: (password: string) => Promise<string>,
private isPasswordPwned: (password: string) => Promise<boolean>,
private validateAge: (params: {dateOfBirth: string; minAge: number}) => boolean,
private generateSecureToken: () => Promise<string>,
private createAuthSession: (params: {user: User; request: Request}) => Promise<[string, AuthSession]>,
) {}
async register({data, request, requestCache}: RegisterParams): Promise<{user_id: string; token: string}> {
return await withBusinessSpan('fluxer.auth.register', 'fluxer.auth.registrations', {}, () =>
this.performRegister({data, request, requestCache}),
);
}
private async performRegister({
data,
request,
requestCache,
}: RegisterParams): Promise<{user_id: string; token: string}> {
if (!data.consent) {
throw InputValidationError.create('consent', 'You must agree to the Terms of Service and Privacy Policy');
}
const now = new Date();
const clientIp = requireClientIp(request, {
trustCfConnectingIp: Config.proxy.trust_cf_connecting_ip,
});
const geoipResult = await lookupGeoip(clientIp);
const countryCode = geoipResult.countryCode;
const minAge = (countryCode && MINIMUM_AGE_BY_COUNTRY[countryCode]) || DEFAULT_MINIMUM_AGE;
if (!this.validateAge({dateOfBirth: data.date_of_birth, minAge})) {
throw InputValidationError.create(
'date_of_birth',
`You must be at least ${minAge} years old to create an account`,
);
}
if (data.password && (await this.isPasswordPwned(data.password))) {
throw InputValidationError.create('password', 'Password is too common');
}
const rawEmail = data.email ?? null;
const emailKey = rawEmail ? rawEmail.toLowerCase() : null;
const enforceRateLimits = !Config.dev.relaxRegistrationRateLimits;
await this.enforceRegistrationRateLimits({enforceRateLimits, clientIp, emailKey});
if (rawEmail) {
const emailTaken = await this.repository.findByEmail(rawEmail);
if (emailTaken) throw InputValidationError.create('email', 'Email already in use');
}
let usernameCandidate: string | undefined = data.username ?? undefined;
let discriminator: number | null = null;
if (!usernameCandidate) {
const derivedUsername = deriveUsernameFromDisplayName(data.global_name ?? '');
if (derivedUsername) {
try {
discriminator = await this.allocateDiscriminator(derivedUsername);
usernameCandidate = derivedUsername;
} catch (error) {
if (!(error instanceof InputValidationError)) {
throw error;
}
}
}
}
if (!usernameCandidate) {
usernameCandidate = generateRandomUsername();
discriminator = await this.allocateDiscriminator(usernameCandidate);
} else if (discriminator === null) {
discriminator = await this.allocateDiscriminator(usernameCandidate);
}
const username = usernameCandidate!;
const userId = await this.generateUserId(emailKey);
if (rawEmail) {
const {applied} = await executeConditional(
UserByEmail.insertIfNotExists({
email_lower: rawEmail.toLowerCase(),
user_id: userId,
}),
);
if (!applied) {
throw InputValidationError.create('email', 'Email already in use');
}
}
const acceptLanguage = request.headers.get('accept-language');
const userLocale = parseAcceptLanguage(acceptLanguage);
const passwordHash = data.password ? await this.hashPassword(data.password) : null;
const instanceConfig = await this.instanceConfigRepository.getInstanceConfig();
const flags = Config.nodeEnv === 'development' ? UserFlags.STAFF : 0n;
let user: User;
try {
user = await this.repository.create({
user_id: userId,
username,
discriminator,
global_name: data.global_name || null,
bot: false,
system: false,
email: rawEmail,
email_verified: false,
email_bounced: false,
phone: null,
password_hash: passwordHash,
password_last_changed_at: passwordHash ? now : null,
totp_secret: null,
authenticator_types: new Set(),
avatar_hash: null,
avatar_color: null,
banner_hash: null,
banner_color: null,
bio: null,
pronouns: null,
accent_color: null,
date_of_birth: parseDobLocalDate(data.date_of_birth),
locale: userLocale,
flags,
premium_type: null,
premium_since: null,
premium_until: null,
premium_will_cancel: null,
premium_billing_cycle: null,
premium_lifetime_sequence: null,
stripe_subscription_id: null,
stripe_customer_id: null,
has_ever_purchased: null,
suspicious_activity_flags: null,
terms_agreed_at: now,
privacy_agreed_at: now,
last_active_at: now,
last_active_ip: clientIp,
temp_banned_until: null,
pending_deletion_at: null,
pending_bulk_message_deletion_at: null,
pending_bulk_message_deletion_channel_count: null,
pending_bulk_message_deletion_message_count: null,
deletion_reason_code: null,
deletion_public_reason: null,
deletion_audit_log_reason: null,
acls: null,
traits: null,
first_refund_at: null,
gift_inventory_server_seq: null,
gift_inventory_client_seq: null,
premium_onboarding_dismissed_at: null,
version: 1,
});
if (!Config.dev.testModeEnabled) {
const firstAdminAclAssigned = await this.reserveFirstAdminAcl();
if (firstAdminAclAssigned) {
user = await this.repository.patchUpsert(user.id, {acls: new Set([AdminACLs.WILDCARD])}, user.toRow());
}
}
await this.kvActivityTracker.updateActivity(user.id, now);
recordCounter({
name: 'user.registration',
dimensions: {
country: countryCode ?? 'unknown',
state: geoipResult.region ?? 'unknown',
ip_version: isIpv6(clientIp) ? 'v6' : 'v4',
},
});
const age = data.date_of_birth ? AgeUtils.calculateAge(data.date_of_birth) : null;
recordCounter({
name: 'user.age',
dimensions: {
country: countryCode ?? 'unknown',
state: geoipResult.region ?? 'unknown',
age: age !== null ? age.toString() : 'unknown',
age_group: determineAgeGroup(age),
},
});
await this.repository.upsertSettings(
UserSettings.getDefaultUserSettings({
userId,
locale: userLocale,
isAdult: AgeUtils.isUserAdult(data.date_of_birth),
}),
);
await this.maybeIndexUser(user);
if (rawEmail) await this.maybeSendVerificationEmail({user, email: rawEmail});
const registrationMetadata = await this.buildRegistrationMetadataContext({
user,
clientIp,
request,
geoipResult,
});
await this.repository.createAuthorizedIp(userId, clientIp);
await this.maybeAutoJoinInvite({
userId,
inviteCode: data.invite_code || Config.instance.autoJoinInviteCode,
requestCache,
});
const [token] = await this.createAuthSession({user, request});
this.sendRegistrationWebhook(user, registrationMetadata, instanceConfig.registrationAlertsWebhookUrl)
.catch((error) => {
Logger.warn(
{error, userId: user.id.toString()},
'[AuthRegistrationService] Failed to send registration webhook',
);
})
.catch((error) => {
Logger.error({error}, '[AuthRegistrationService] Failed to log webhook error');
});
return {
user_id: user.id.toString(),
token,
};
} catch (error) {
if (rawEmail) {
try {
await deleteOneOrMany(
UserByEmail.deleteByPk({
email_lower: rawEmail.toLowerCase(),
user_id: userId,
}),
);
} catch (cleanupError) {
Logger.error(
{email: rawEmail, userId: userId.toString(), error: cleanupError},
'Failed to clean up email reservation after registration failure',
);
}
}
throw error;
}
}
private async 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');
}
}
}
private async maybeSendVerificationEmail(params: {user: User; email: string}): Promise<void> {
const {user, email} = params;
const token = createEmailVerificationToken(await this.generateSecureToken());
await this.repository.createEmailVerificationToken({
token_: token,
user_id: user.id,
email,
});
await this.emailService.sendEmailVerification(email, user.username, token, user.locale);
}
private async maybeAutoJoinInvite(params: {
userId: UserID;
inviteCode: string | null | undefined;
requestCache: RequestCache;
}): Promise<void> {
const {userId, inviteCode, requestCache} = params;
const normalizedInviteCode = inviteCode?.trim();
if (!normalizedInviteCode) return;
if (!this.inviteService) return;
try {
await this.inviteService.acceptInvite({
userId,
inviteCode: createInviteCode(normalizedInviteCode),
requestCache,
});
} catch (error) {
Logger.warn({inviteCode: normalizedInviteCode, error}, 'Failed to auto-join invite on registration');
}
}
private async enforceRegistrationRateLimits(params: {
enforceRateLimits: boolean;
clientIp: string;
emailKey: string | null;
}): Promise<void> {
const {enforceRateLimits, clientIp, emailKey} = params;
if (!enforceRateLimits) return;
if (emailKey) {
const emailRateLimit = await this.rateLimitService.checkLimit({
identifier: `registration:email:${emailKey}`,
maxAttempts: 3,
windowMs: ms('15 minutes'),
});
if (!emailRateLimit.allowed) throwRegistrationRateLimit(emailRateLimit);
}
const ipRateLimit = await this.rateLimitService.checkLimit({
identifier: `registration:ip:${clientIp}`,
maxAttempts: 5,
windowMs: ms('30 minutes'),
});
if (!ipRateLimit.allowed) throwRegistrationRateLimit(ipRateLimit);
}
private async allocateDiscriminator(username: string): Promise<number> {
const result = await this.discriminatorService.generateDiscriminator({username});
if (!result.available || result.discriminator === -1) {
throw InputValidationError.create('username', 'Too many users with this username');
}
return result.discriminator;
}
private async generateUserId(emailKey: string | null): Promise<UserID> {
if (emailKey) {
const reserved = this.snowflakeReservationService.getReservedSnowflake(emailKey);
if (reserved) {
return createUserID(reserved);
}
}
return createUserID(await this.snowflakeService.generate());
}
private truncateUserAgent(userAgent: string): string {
if (userAgent.length <= USER_AGENT_TRUNCATE_LENGTH) return userAgent;
return `${userAgent.slice(0, USER_AGENT_TRUNCATE_LENGTH)}...`;
}
private parseUserAgentSafe(userAgent: string): {osInfo: string; browserInfo: string; deviceInfo: string} {
try {
const result = Bowser.parse(userAgent);
return {
osInfo: this.formatOsInfo(result.os) ?? 'Unknown',
browserInfo: this.formatNameVersion(result.browser?.name, result.browser?.version) ?? 'Unknown',
deviceInfo: this.formatDeviceInfo(result.platform),
};
} catch (error) {
Logger.warn({error}, 'Failed to parse user agent with Bowser');
return {osInfo: 'Unknown', browserInfo: 'Unknown', deviceInfo: 'Desktop/Unknown'};
}
}
private formatNameVersion(name?: string, version?: string): string | null {
if (!name) return null;
return version ? `${name} ${version}` : name;
}
private formatOsInfo(os?: {name?: string; version?: string; versionName?: string}): string | null {
if (!os?.name) return null;
if (os.versionName && os.version) return `${os.name} ${os.versionName} (${os.version})`;
if (os.versionName) return `${os.name} ${os.versionName}`;
if (os.version) return `${os.name} ${os.version}`;
return os.name;
}
private formatDeviceInfo(platform?: {type?: string; vendor?: string; model?: string}): string {
const type = this.formatPlatformType(platform?.type);
const vendorModel = [platform?.vendor, platform?.model].filter(Boolean).join(' ').trim();
if (vendorModel && type) return `${vendorModel} (${type})`;
if (vendorModel) return vendorModel;
if (type) return type;
return 'Desktop/Unknown';
}
private formatPlatformType(type?: string): string | null {
switch ((type ?? '').toLowerCase()) {
case 'mobile':
return 'Mobile';
case 'tablet':
return 'Tablet';
case 'desktop':
return 'Desktop';
default:
return null;
}
}
private async buildRegistrationMetadataContext(params: {
user: User;
clientIp: string;
request: Request;
geoipResult: GeoipResult;
}): Promise<RegistrationMetadataContext> {
const {user, clientIp, request, geoipResult} = params;
const userAgentHeader = (request.headers.get('user-agent') ?? '').trim();
const fluxerTag = `${user.username}#${user.discriminator.toString().padStart(4, '0')}`;
const displayName = user.globalName || user.username;
const emailDisplay = user.email || 'Not provided';
const hasUserAgent = userAgentHeader.length > 0;
const userAgentForDisplay = hasUserAgent ? userAgentHeader : 'Not provided';
const truncatedUserAgent = this.truncateUserAgent(userAgentForDisplay);
const uaInfo = hasUserAgent
? this.parseUserAgentSafe(userAgentHeader)
: {osInfo: 'Unknown', browserInfo: 'Unknown', deviceInfo: 'Desktop/Unknown'};
const normalizedIp = geoipResult.normalizedIp ?? clientIp;
const locationLabel = formatGeoipLocation(geoipResult) ?? UNKNOWN_LOCATION;
const safeCountryCode = geoipResult.countryCode ?? 'unknown';
const ipAddressReverse = await getIpAddressReverse(normalizedIp, this.cacheService);
const metadataEntries: Array<[string, string]> = [
['fluxer_tag', fluxerTag],
['display_name', displayName],
['email', emailDisplay],
['ip_address', clientIp],
['normalized_ip', normalizedIp],
['country_code', safeCountryCode],
['location', locationLabel],
['os', uaInfo.osInfo],
['browser', uaInfo.browserInfo],
['device', uaInfo.deviceInfo],
['user_agent', truncatedUserAgent],
];
if (geoipResult.city) metadataEntries.push(['city', geoipResult.city]);
if (geoipResult.region) metadataEntries.push(['region', geoipResult.region]);
if (geoipResult.countryName) metadataEntries.push(['country_name', geoipResult.countryName]);
if (ipAddressReverse) metadataEntries.push(['ip_address_reverse', ipAddressReverse]);
return {
metadata: new Map(metadataEntries),
clientIp,
countryCode: safeCountryCode,
location: locationLabel,
city: geoipResult.city,
region: geoipResult.region,
osInfo: uaInfo.osInfo,
browserInfo: uaInfo.browserInfo,
deviceInfo: uaInfo.deviceInfo,
truncatedUserAgent,
fluxerTag,
displayName,
email: emailDisplay,
ipAddressReverse,
};
}
private async reserveFirstAdminAcl(): Promise<boolean> {
const now = new Date();
const {applied} = await executeConditional(
InstanceConfiguration.insertIfNotExists({
key: FIRST_ADMIN_ACL_CONFIG_KEY,
value: 'true',
updated_at: now,
}),
);
return applied;
}
private async sendRegistrationWebhook(
user: User,
context: RegistrationMetadataContext,
webhookUrl: string | null,
): Promise<void> {
if (!webhookUrl) {
if (!this.hasWarnedAboutMissingWebhook) {
Logger.warn(
'registrationAlertsWebhookUrl is not configured registration alerts will be disabled until configured',
);
this.hasWarnedAboutMissingWebhook = true;
}
return;
}
const locationDisplay = context.city ? context.location : context.countryCode;
const embedFields = [
{name: 'User ID', value: user.id.toString(), inline: true},
{name: 'FluxerTag', value: context.fluxerTag, inline: true},
{name: 'Display Name', value: context.displayName, inline: true},
{name: 'Email', value: context.email, inline: true},
{name: 'IP Address', value: `\`${context.clientIp}\``, inline: true},
...(context.ipAddressReverse ? [{name: 'Reverse DNS', value: context.ipAddressReverse, inline: true}] : []),
{name: 'Location', value: locationDisplay, inline: true},
{name: 'OS', value: context.osInfo, inline: true},
{name: 'Browser', value: context.browserInfo, inline: true},
{name: 'Device', value: context.deviceInfo, inline: true},
{name: 'User Agent', value: context.truncatedUserAgent, inline: false},
];
const payload = {
username: 'Registration Monitor',
embeds: [
{
title: 'New Account Registered',
color: 0x10b981,
fields: embedFields,
timestamp: new Date().toISOString(),
},
],
};
try {
const response = await FetchUtils.sendRequest({
url: webhookUrl,
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
timeout: ms('10 seconds'),
serviceName: 'registration_webhook',
});
if (response.status < 200 || response.status >= 300) {
const body = await FetchUtils.streamToString(response.stream);
Logger.warn({status: response.status, body}, 'Failed to send registration webhook');
}
} catch (error) {
Logger.warn({error}, 'Failed to send registration webhook');
}
}
}

View File

@@ -0,0 +1,181 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {mapAuthSessionsToResponse} from '@fluxer/api/src/auth/AuthModel';
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {BotUserAuthSessionCreationDeniedError} from '@fluxer/errors/src/domains/auth/BotUserAuthSessionCreationDeniedError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {recordCounter} from '@fluxer/telemetry/src/Metrics';
interface CreateAuthSessionParams {
user: User;
request: Request;
}
interface LogoutAuthSessionsParams {
user: User;
sessionIdHashes: Array<string>;
}
interface UpdateUserActivityParams {
userId: UserID;
clientIp: string;
}
export class AuthSessionService {
constructor(
private repository: IUserRepository,
private gatewayService: IGatewayService,
private generateAuthToken: () => Promise<string>,
private getTokenIdHash: (token: string) => Uint8Array,
) {}
async createAuthSession({user, request}: CreateAuthSessionParams): Promise<[token: string, AuthSession]> {
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
const now = new Date();
const token = await this.generateAuthToken();
const ip = requireClientIp(request, {
trustCfConnectingIp: Config.proxy.trust_cf_connecting_ip,
});
const platformHeader = request.headers.get('x-fluxer-platform')?.trim().toLowerCase() ?? null;
const uaRaw = request.headers.get('user-agent') ?? '';
const isDesktopClient = platformHeader === 'desktop';
const authSession = await this.repository.createAuthSession({
user_id: user.id,
session_id_hash: Buffer.from(this.getTokenIdHash(token)),
created_at: now,
approx_last_used_at: now,
client_ip: ip,
client_user_agent: uaRaw || null,
client_is_desktop: isDesktopClient,
client_os: null,
client_platform: null,
version: 1,
});
recordCounter({
name: 'auth.session.created',
dimensions: {
client_type: isDesktopClient ? 'desktop' : 'web',
},
});
return [token, authSession];
}
async getAuthSessionByToken(token: string): Promise<AuthSession | null> {
return this.repository.getAuthSessionByToken(Buffer.from(this.getTokenIdHash(token)));
}
async getAuthSessions(userId: UserID): Promise<Array<AuthSessionResponse>> {
const authSessions = await this.repository.listAuthSessions(userId);
return await mapAuthSessionsToResponse({authSessions});
}
async updateAuthSessionLastUsed(tokenHash: Uint8Array): Promise<void> {
await this.repository.updateAuthSessionLastUsed(Buffer.from(tokenHash));
recordCounter({
name: 'auth.session.refreshed',
dimensions: {},
});
}
async updateUserActivity({userId, clientIp}: UpdateUserActivityParams): Promise<void> {
await this.repository.updateUserActivity(userId, clientIp);
}
async revokeToken(token: string): Promise<void> {
const tokenHash = Buffer.from(this.getTokenIdHash(token));
const authSession = await this.repository.getAuthSessionByToken(tokenHash);
if (!authSession) return;
await this.repository.revokeAuthSession(tokenHash);
recordCounter({
name: 'auth.session.revoked',
dimensions: {revoke_type: 'single'},
});
await this.gatewayService.terminateSession({
userId: authSession.userId,
sessionIdHashes: [Buffer.from(authSession.sessionIdHash).toString('base64url')],
});
}
async logoutAuthSessions({user, sessionIdHashes}: LogoutAuthSessionsParams): Promise<void> {
const hashes = sessionIdHashes.map((hash) => Buffer.from(hash, 'base64url'));
await this.repository.deleteAuthSessions(user.id, hashes);
recordCounter({
name: 'auth.session.revoked',
dimensions: {revoke_type: 'batch', count: sessionIdHashes.length.toString()},
});
await this.gatewayService.terminateSession({
userId: user.id,
sessionIdHashes,
});
}
async terminateAllUserSessions(userId: UserID): Promise<void> {
const authSessions = await this.repository.listAuthSessions(userId);
if (authSessions.length === 0) return;
const hashes = authSessions.map((s) => s.sessionIdHash);
await this.repository.deleteAuthSessions(userId, hashes);
recordCounter({
name: 'auth.session.revoked',
dimensions: {revoke_type: 'all', count: authSessions.length.toString()},
});
await this.gatewayService.terminateSession({
userId,
sessionIdHashes: authSessions.map((s) => Buffer.from(s.sessionIdHash).toString('base64url')),
});
}
async dispatchAuthSessionChange(params: {
userId: UserID;
oldAuthSessionIdHash: string;
newAuthSessionIdHash: string;
newToken: string;
}): Promise<void> {
const {userId, oldAuthSessionIdHash, newAuthSessionIdHash, newToken} = params;
await this.gatewayService.dispatchPresence({
userId,
event: 'AUTH_SESSION_CHANGE',
data: {
old_auth_session_id_hash: oldAuthSessionIdHash,
new_auth_session_id_hash: newAuthSessionIdHash,
new_token: newToken,
},
});
}
}

View File

@@ -0,0 +1,171 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import {promisify} from 'node:util';
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import type {User} from '@fluxer/api/src/models/User';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import * as AgeUtils from '@fluxer/api/src/utils/AgeUtils';
import * as RandomUtils from '@fluxer/api/src/utils/RandomUtils';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {BotUserAuthEndpointAccessDeniedError} from '@fluxer/errors/src/domains/auth/BotUserAuthEndpointAccessDeniedError';
import {AccountPermanentlySuspendedError} from '@fluxer/errors/src/domains/user/AccountPermanentlySuspendedError';
import {AccountTemporarilySuspendedError} from '@fluxer/errors/src/domains/user/AccountTemporarilySuspendedError';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import {ms} from 'itty-time';
const randomBytesAsync = promisify(crypto.randomBytes);
const ALPHANUMERIC_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function base62Encode(buffer: Uint8Array): string {
let num = BigInt(`0x${Buffer.from(buffer).toString('hex')}`);
const base = BigInt(ALPHANUMERIC_CHARS.length);
let encoded = '';
while (num > 0) {
const remainder = num % base;
encoded = ALPHANUMERIC_CHARS[Number(remainder)] + encoded;
num = num / base;
}
return encoded;
}
interface ValidateAgeParams {
dateOfBirth: string;
minAge: number;
}
interface CheckEmailChangeRateLimitParams {
userId: UserID;
}
export class AuthUtilityService {
constructor(
private repository: IUserRepository,
private rateLimitService: IRateLimitService,
) {}
async generateSecureToken(length = 64): Promise<string> {
return RandomUtils.randomString(length);
}
async generateAuthToken(): Promise<string> {
const bytes = await randomBytesAsync(27);
let token = base62Encode(new Uint8Array(bytes));
while (token.length < 36) {
const extraBytes = await randomBytesAsync(1);
token += ALPHANUMERIC_CHARS[extraBytes[0] % ALPHANUMERIC_CHARS.length];
}
if (token.length > 36) {
token = token.slice(0, 36);
}
return `flx_${token}`;
}
generateBackupCodes(): Array<string> {
return Array.from({length: 10}, () => {
return `${RandomUtils.randomString(4).toLowerCase()}-${RandomUtils.randomString(4).toLowerCase()}`;
});
}
getTokenIdHash(token: string): Uint8Array {
return new Uint8Array(crypto.createHash('sha256').update(token).digest());
}
async checkEmailChangeRateLimit({
userId,
}: CheckEmailChangeRateLimitParams): Promise<{allowed: boolean; retryAfter?: number}> {
const rateLimit = await this.rateLimitService.checkLimit({
identifier: `email_change:${userId}`,
maxAttempts: 3,
windowMs: ms('1 hour'),
});
return {
allowed: rateLimit.allowed,
retryAfter: rateLimit.retryAfter,
};
}
validateAge({dateOfBirth, minAge}: ValidateAgeParams): boolean {
const birthDate = new Date(dateOfBirth);
const age = AgeUtils.calculateAge({
year: birthDate.getFullYear(),
month: birthDate.getMonth() + 1,
day: birthDate.getDate(),
});
return age >= minAge;
}
assertNonBotUser(user: User): void {
if (user.isBot) {
throw new BotUserAuthEndpointAccessDeniedError();
}
}
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
return this.repository.authorizeIpByToken(token);
}
checkAccountBanStatus(user: User): {
isPermanentlyBanned: boolean;
isTempBanned: boolean;
tempBanExpired: boolean;
} {
const isPermanentlyBanned = !!(user.flags & UserFlags.DELETED);
const hasTempBan = !!(user.flags & UserFlags.DISABLED && user.tempBannedUntil);
const tempBanExpired = hasTempBan && user.tempBannedUntil! <= new Date();
return {
isPermanentlyBanned,
isTempBanned: hasTempBan && !tempBanExpired,
tempBanExpired,
};
}
async handleBanStatus(user: User): Promise<User> {
const banStatus = this.checkAccountBanStatus(user);
if (banStatus.isPermanentlyBanned) {
throw new AccountPermanentlySuspendedError();
}
if (banStatus.isTempBanned) {
throw new AccountTemporarilySuspendedError();
}
if (banStatus.tempBanExpired) {
const updatedUser = await this.repository.patchUpsert(
user.id,
{
flags: user.flags & ~UserFlags.DISABLED,
temp_banned_until: null,
},
user.toRow(),
);
return updatedUser;
}
return user;
}
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomBytes} from 'node:crypto';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {HandoffCodeExpiredError} from '@fluxer/errors/src/domains/auth/HandoffCodeExpiredError';
import {InvalidHandoffCodeError} from '@fluxer/errors/src/domains/auth/InvalidHandoffCodeError';
import {ms, seconds} from 'itty-time';
const HANDOFF_CODE_PREFIX = 'desktop-handoff:';
const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:';
const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 8;
const NORMALIZED_CODE_REGEX = /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}$/;
interface HandoffData {
createdAt: number;
userAgent?: string;
}
interface HandoffTokenData {
token: string;
userId: string;
}
function generateHandoffCode(): string {
const bytes = randomBytes(CODE_LENGTH);
let code = '';
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_CHARACTERS[bytes[i] % CODE_CHARACTERS.length];
}
return `${code.slice(0, 4)}-${code.slice(4, 8)}`;
}
function normalizeHandoffCode(code: string): string {
return code.replace(/[-\s]/g, '').toUpperCase();
}
function assertValidHandoffCode(code: string): void {
if (!NORMALIZED_CODE_REGEX.test(code)) {
throw new InvalidHandoffCodeError();
}
}
export class DesktopHandoffService {
constructor(private readonly cacheService: ICacheService) {}
async initiateHandoff(userAgent?: string): Promise<{code: string; expiresAt: Date}> {
const code = generateHandoffCode();
const normalizedCode = normalizeHandoffCode(code);
const handoffData: HandoffData = {
createdAt: Date.now(),
userAgent,
};
const expirySeconds = seconds('5 minutes');
await this.cacheService.set(`${HANDOFF_CODE_PREFIX}${normalizedCode}`, handoffData, expirySeconds);
const expiresAt = new Date(Date.now() + ms('5 minutes'));
return {code, expiresAt};
}
async completeHandoff(code: string, token: string, userId: string): Promise<void> {
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
const handoffData = await this.cacheService.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
if (!handoffData) {
throw new InvalidHandoffCodeError();
}
const tokenData: HandoffTokenData = {
token,
userId,
};
const remainingSeconds = Math.max(
0,
seconds('5 minutes') - Math.floor((Date.now() - handoffData.createdAt) / 1000),
);
if (remainingSeconds <= 0) {
throw new HandoffCodeExpiredError();
}
await this.cacheService.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
await this.cacheService.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
}
async getHandoffStatus(
code: string,
): Promise<{status: 'pending' | 'completed' | 'expired'; token?: string; userId?: string}> {
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
const tokenData = await this.cacheService.getAndDelete<HandoffTokenData>(
`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`,
);
if (tokenData) {
return {
status: 'completed',
token: tokenData.token,
userId: tokenData.userId,
};
}
const handoffData = await this.cacheService.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
if (handoffData) {
return {status: 'pending'};
}
return {status: 'expired'};
}
async cancelHandoff(code: string): Promise<void> {
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
await this.cacheService.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
await this.cacheService.delete(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`);
}
}

View File

@@ -0,0 +1,626 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createHash, randomBytes} from 'node:crypto';
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
import {
parseTokenEndpointResponse,
sanitizeSsoRedirectTo,
tryDiscoverOidcProviderMetadata,
} from '@fluxer/api/src/auth/services/SsoUtils';
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
import type {KVActivityTracker} from '@fluxer/api/src/infrastructure/KVActivityTracker';
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
import type {InstanceConfigRepository, InstanceSsoConfig} from '@fluxer/api/src/instance/InstanceConfigRepository';
import {Logger} from '@fluxer/api/src/Logger';
import type {User} from '@fluxer/api/src/models/User';
import {UserSettings} from '@fluxer/api/src/models/UserSettings';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import * as FetchUtils from '@fluxer/api/src/utils/FetchUtils';
import {generateRandomUsername} from '@fluxer/api/src/utils/UsernameGenerator';
import {deriveUsernameFromDisplayName} from '@fluxer/api/src/utils/UsernameSuggestionUtils';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {SsoRequiredError} from '@fluxer/errors/src/domains/auth/SsoRequiredError';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {createPublicInternetRequestUrlPolicy} from '@fluxer/http_client/src/PublicInternetRequestUrlPolicy';
import {ms, seconds} from 'itty-time';
import {
type CryptoKey,
createRemoteJWKSet,
decodeJwt,
type FlattenedJWSInput,
type JSONWebKeySet,
type JWSHeaderParameters,
type JWTPayload,
jwtVerify,
} from 'jose';
interface SsoStatePayload {
codeVerifier: string;
nonce: string;
redirectTo?: string;
createdAt: number;
}
export interface PublicSsoStatus {
enabled: boolean;
enforced: boolean;
display_name: string | null;
redirect_uri: string;
}
interface ResolvedSsoConfig extends InstanceSsoConfig {
redirectUri: string;
scope: string;
ready: boolean;
providerId: string;
isTestProvider: boolean;
issuerForVerification: string | null;
}
interface RemoteJwkSetResolver {
(protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput): Promise<CryptoKey>;
coolingDown: boolean;
fresh: boolean;
reloading: boolean;
reload: () => Promise<void>;
jwks: () => JSONWebKeySet | undefined;
}
interface JwksCacheEntry {
jwks: RemoteJwkSetResolver;
cachedAt: number;
}
export class SsoService {
private readonly logger = Logger.child({logger: 'SsoService'});
private static readonly STATE_TTL_SECONDS = seconds('10 minutes');
private static readonly DISCOVERY_TTL_SECONDS = seconds('1 hour');
private static readonly JWKS_CACHE_TTL_MS = ms('1 hour');
private static readonly SSO_REQUEST_URL_POLICY = createPublicInternetRequestUrlPolicy();
private readonly jwksCache = new Map<string, JwksCacheEntry>();
constructor(
private readonly instanceConfigRepository: InstanceConfigRepository,
private readonly cacheService: ICacheService,
private readonly userRepository: IUserRepository,
private readonly discriminatorService: IDiscriminatorService,
private readonly snowflakeService: SnowflakeService,
private readonly authService: AuthService,
private readonly kvActivityTracker: KVActivityTracker,
) {}
async getPublicStatus(): Promise<PublicSsoStatus> {
const config = await this.getResolvedConfig();
return {
enabled: config.enabled && config.ready,
enforced: config.enabled && config.ready,
display_name: config.displayName ?? null,
redirect_uri: config.redirectUri,
};
}
async isEnforced(): Promise<boolean> {
const config = await this.getResolvedConfig();
return config.enabled && config.ready;
}
async startLogin(redirectTo?: string): Promise<{authorization_url: string; state: string; redirect_uri: string}> {
const config = await this.requireReadyConfig();
const state = this.randomState();
const codeVerifier = this.randomCodeVerifier();
const codeChallenge = this.buildCodeChallenge(codeVerifier);
const nonce = this.randomNonce();
const statePayload: SsoStatePayload = {
codeVerifier,
nonce,
redirectTo: sanitizeSsoRedirectTo(redirectTo),
createdAt: Date.now(),
};
await this.cacheService.set(this.buildStateCacheKey(state), statePayload, SsoService.STATE_TTL_SECONDS);
const searchParams = new URLSearchParams({
response_type: 'code',
client_id: config.clientId ?? '',
redirect_uri: config.redirectUri,
scope: config.scope,
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
nonce,
});
let authorizationUrlString: string;
try {
const authorizationUrl = new URL(config.authorizationUrl ?? '');
for (const [k, v] of searchParams.entries()) {
authorizationUrl.searchParams.set(k, v);
}
authorizationUrlString = authorizationUrl.toString();
} catch {
if (config.isTestProvider) {
const joiner = (config.authorizationUrl ?? '').includes('?') ? '&' : '?';
authorizationUrlString = `${config.authorizationUrl}${joiner}${searchParams.toString()}`;
} else {
throw new FeatureTemporarilyDisabledError();
}
}
return {authorization_url: authorizationUrlString, state, redirect_uri: config.redirectUri};
}
async completeLogin({
code,
state,
request,
}: {
code: string;
state: string;
request: Request;
}): Promise<{token: string; user_id: string; redirect_to: string}> {
const config = await this.requireReadyConfig();
const statePayload = await this.cacheService.getAndDelete<SsoStatePayload>(this.buildStateCacheKey(state));
if (!statePayload) {
throw InputValidationError.create('state', 'Invalid or expired SSO state');
}
const tokenResponse = await this.exchangeCode({
code,
codeVerifier: statePayload.codeVerifier,
config,
});
const claims = await this.resolveClaims(tokenResponse, config, statePayload.nonce);
const user = await this.resolveUserFromClaims(claims, config);
const {token, user_id} = await this.authService.createAuthSessionForUser(user, request);
return {token, user_id, redirect_to: statePayload.redirectTo ?? ''};
}
private async resolveUserFromClaims(
claims: {email: string; emailVerified: boolean; name?: string | null},
config: ResolvedSsoConfig,
): Promise<User> {
const emailLower = claims.email.toLowerCase();
const existingUser = await this.userRepository.findByEmail(emailLower);
if (existingUser) {
return existingUser;
}
if (!config.autoProvision) {
throw new SsoRequiredError();
}
return await this.provisionUserFromClaims(claims, config);
}
private async provisionUserFromClaims(
claims: {email: string; emailVerified: boolean; name?: string | null},
config: ResolvedSsoConfig,
): Promise<User> {
const userId = (await this.snowflakeService.generate()) as UserID;
const baseName = claims.name?.trim() || claims.email.split('@')[0] || generateRandomUsername();
const username = deriveUsernameFromDisplayName(baseName) ?? generateRandomUsername();
const discriminatorResult = await this.discriminatorService.generateDiscriminator({username});
if (!discriminatorResult.available) {
throw InputValidationError.create('username', 'Unable to allocate discriminator for SSO user');
}
const now = new Date();
const traits = new Set<string>(['sso', `sso:${config.providerId}`]);
const userRow = {
user_id: userId,
username,
discriminator: discriminatorResult.discriminator,
global_name: claims.name?.substring(0, 256) ?? username,
bot: false,
system: false,
email: claims.email.toLowerCase(),
email_verified: claims.emailVerified,
email_bounced: false,
phone: null,
password_hash: null,
password_last_changed_at: null,
totp_secret: null,
authenticator_types: null,
avatar_hash: null,
avatar_color: null,
banner_hash: null,
banner_color: null,
bio: null,
pronouns: null,
accent_color: null,
date_of_birth: null,
locale: null,
flags: 0n,
premium_type: null,
premium_since: null,
premium_until: null,
premium_will_cancel: null,
premium_billing_cycle: null,
premium_lifetime_sequence: null,
stripe_subscription_id: null,
stripe_customer_id: null,
has_ever_purchased: false,
suspicious_activity_flags: 0,
terms_agreed_at: now,
privacy_agreed_at: now,
last_active_at: now,
last_active_ip: null,
temp_banned_until: null,
pending_bulk_message_deletion_at: null,
pending_bulk_message_deletion_channel_count: null,
pending_bulk_message_deletion_message_count: null,
pending_deletion_at: null,
deletion_reason_code: null,
deletion_public_reason: null,
deletion_audit_log_reason: null,
acls: new Set<string>(),
traits,
first_refund_at: null,
gift_inventory_server_seq: null,
gift_inventory_client_seq: null,
premium_onboarding_dismissed_at: null,
version: 1,
} as const;
const user = await this.userRepository.create(userRow);
await this.userRepository.upsertSettings(
UserSettings.getDefaultUserSettings({
userId,
locale: 'en-US',
isAdult: true,
}),
);
await this.kvActivityTracker.updateActivity(user.id, now);
return user;
}
private async resolveClaims(
tokenResponse: {id_token?: string; access_token?: string},
config: ResolvedSsoConfig,
expectedNonce: string,
): Promise<{email: string; emailVerified: boolean; name?: string | null}> {
if (config.isTestProvider) {
const email = tokenResponse.id_token || tokenResponse.access_token;
if (!email) throw InputValidationError.create('code', 'SSO test code missing email payload');
this.validateEmailAgainstAllowlist(email, config.allowedEmailDomains);
return {email, emailVerified: true, name: 'Test SSO User'};
}
let claims: JWTPayload | null = null;
if (tokenResponse.id_token) {
if (!config.jwksUrl) {
this.logger.warn('SSO id_token returned but no JWKS URL is configured; ignoring id_token claims');
} else {
claims = await this.verifyIdToken(tokenResponse.id_token, config, expectedNonce);
}
}
let userInfo: Record<string, unknown> | null = null;
if (config.userInfoUrl && tokenResponse.access_token) {
userInfo = await this.fetchUserInfo(config.userInfoUrl, tokenResponse.access_token);
}
if (!claims && !userInfo) {
throw InputValidationError.create('sso', 'SSO is misconfigured (missing JWKS or user info endpoint)');
}
const email =
(userInfo?.['email'] as string | undefined) ??
(claims?.['email'] as string | undefined) ??
(() => {
throw InputValidationError.create('email', 'SSO provider did not return an email');
})();
const emailVerified =
this.coerceEmailVerified(userInfo?.['email_verified']) ??
this.coerceEmailVerified(claims?.['email_verified']) ??
false;
const name = (userInfo?.['name'] as string | undefined) ?? (claims?.['name'] as string | undefined) ?? null;
this.validateEmailAgainstAllowlist(email, config.allowedEmailDomains);
return {email, emailVerified, name};
}
private async verifyIdToken(idToken: string, config: ResolvedSsoConfig, expectedNonce: string): Promise<JWTPayload> {
try {
if (config.jwksUrl) {
const jwks = await this.getOrCreateJwks(config.jwksUrl);
const {payload} = await jwtVerify(idToken, jwks, {
issuer: config.issuerForVerification ?? undefined,
audience: config.clientId ?? undefined,
clockTolerance: 10,
});
const nonce = payload['nonce'];
if (nonce === undefined) {
this.logger.warn('SSO id_token missing required nonce claim');
throw new Error('nonce missing');
}
if (typeof nonce !== 'string' || nonce.length === 0 || nonce !== expectedNonce) {
throw new Error('nonce mismatch');
}
return payload;
}
return decodeJwt(idToken);
} catch (error) {
this.logger.error({error}, 'Failed to verify SSO id_token');
throw InputValidationError.create('id_token', 'Invalid SSO token');
}
}
private async getOrCreateJwks(jwksUrl: string): Promise<RemoteJwkSetResolver> {
await this.validatePublicOutboundUrl(jwksUrl, 'jwks_url');
const now = Date.now();
const cached = this.jwksCache.get(jwksUrl);
if (cached && now - cached.cachedAt < SsoService.JWKS_CACHE_TTL_MS) {
return cached.jwks;
}
const jwks = createRemoteJWKSet(new URL(jwksUrl));
this.jwksCache.set(jwksUrl, {jwks, cachedAt: now});
if (this.jwksCache.size > 10) {
for (const [url, entry] of this.jwksCache.entries()) {
if (now - entry.cachedAt >= SsoService.JWKS_CACHE_TTL_MS) {
this.jwksCache.delete(url);
}
}
}
return jwks;
}
private async fetchUserInfo(userInfoUrl: string, accessToken: string): Promise<Record<string, unknown>> {
const resp = await FetchUtils.sendRequest({
url: userInfoUrl,
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
timeout: ms('15 seconds'),
serviceName: 'sso_user_info',
});
if (resp.status < 200 || resp.status >= 300) {
throw InputValidationError.create('access_token', 'Failed to fetch SSO user info');
}
try {
const rawBody = await FetchUtils.streamToString(resp.stream);
return JSON.parse(rawBody) as Record<string, unknown>;
} catch {
throw InputValidationError.create('access_token', 'Failed to parse SSO user info response');
}
}
private async exchangeCode({
code,
codeVerifier,
config,
}: {
code: string;
codeVerifier: string;
config: ResolvedSsoConfig;
}): Promise<{id_token?: string; access_token?: string}> {
if (config.isTestProvider) {
return {id_token: code};
}
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.redirectUri,
client_id: config.clientId ?? '',
code_verifier: codeVerifier,
});
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
};
if (config.clientSecret) {
const encoded = Buffer.from(`${config.clientId}:${config.clientSecret}`, 'utf8').toString('base64');
headers['Authorization'] = `Basic ${encoded}`;
}
const resp = await FetchUtils.sendRequest({
url: config.tokenUrl ?? '',
method: 'POST',
headers,
body,
timeout: ms('15 seconds'),
serviceName: 'sso_token_exchange',
});
if (resp.status < 200 || resp.status >= 300) {
throw InputValidationError.create('code', 'Invalid SSO authorization code');
}
const rawBody = await FetchUtils.streamToString(resp.stream);
return parseTokenEndpointResponse(resp.headers.get('content-type'), rawBody);
}
private buildCodeChallenge(codeVerifier: string): string {
return createHash('sha256').update(codeVerifier).digest('base64url');
}
private randomCodeVerifier(): string {
return randomBytes(32).toString('base64url');
}
private randomState(): string {
return randomBytes(16).toString('hex');
}
private randomNonce(): string {
return randomBytes(16).toString('base64url');
}
private buildStateCacheKey(state: string): string {
return `sso:state:${state}`;
}
private buildDiscoveryCacheKey(issuer: string): string {
const key = createHash('sha256').update(issuer).digest('hex').slice(0, 32);
return `sso:oidc-discovery:${key}`;
}
private coerceEmailVerified(value: unknown): boolean | undefined {
if (value === true || value === false) return value;
if (typeof value === 'string') {
const v = value.trim().toLowerCase();
if (v === 'true') return true;
if (v === 'false') return false;
}
return undefined;
}
private validateEmailAgainstAllowlist(email: string, domains: Array<string>): void {
if (!domains || domains.length === 0) return;
const domain = email.split('@')[1]?.toLowerCase() ?? '';
const allowed = domains.map((d) => d.toLowerCase().trim()).filter(Boolean);
if (!allowed.includes(domain)) {
throw InputValidationError.create('email', 'Email domain is not allowed for SSO');
}
}
private async getResolvedConfig(): Promise<ResolvedSsoConfig> {
const stored = await this.instanceConfigRepository.getSsoConfig();
const redirectUri = stored.redirectUri ?? `${Config.endpoints.webApp}/auth/sso/callback`;
const scope = stored.scope?.trim() || 'openid email profile';
const providerId = stored.issuer || stored.authorizationUrl || 'sso';
let authorizationUrl = stored.authorizationUrl;
let tokenUrl = stored.tokenUrl;
let userInfoUrl = stored.userInfoUrl;
let jwksUrl = stored.jwksUrl;
let issuerForVerification = stored.issuer;
const isTestProvider =
authorizationUrl === 'test' ||
tokenUrl === 'test' ||
(Config.dev.testModeEnabled && (authorizationUrl?.startsWith('test-') ?? false));
const validatedIssuer =
stored.issuer && !isTestProvider
? await this.validateOptionalPublicOutboundUrl(stored.issuer, 'issuer')
: stored.issuer;
if (validatedIssuer) {
const cacheKey = this.buildDiscoveryCacheKey(validatedIssuer);
let discovered = await this.cacheService.get<{
issuer: string;
authorization_endpoint?: string;
token_endpoint?: string;
userinfo_endpoint?: string;
jwks_uri?: string;
}>(cacheKey);
if (!discovered && (!authorizationUrl || !tokenUrl || !jwksUrl || !userInfoUrl)) {
discovered = await tryDiscoverOidcProviderMetadata(validatedIssuer);
if (discovered) {
await this.cacheService.set(cacheKey, discovered, SsoService.DISCOVERY_TTL_SECONDS);
}
}
authorizationUrl = authorizationUrl ?? discovered?.authorization_endpoint ?? null;
tokenUrl = tokenUrl ?? discovered?.token_endpoint ?? null;
userInfoUrl = userInfoUrl ?? discovered?.userinfo_endpoint ?? null;
jwksUrl = jwksUrl ?? discovered?.jwks_uri ?? null;
issuerForVerification = discovered?.issuer ?? validatedIssuer;
}
if (!isTestProvider) {
tokenUrl = await this.validateOptionalPublicOutboundUrl(tokenUrl, 'token_url');
userInfoUrl = await this.validateOptionalPublicOutboundUrl(userInfoUrl, 'user_info_url');
jwksUrl = await this.validateOptionalPublicOutboundUrl(jwksUrl, 'jwks_url');
}
const ready = stored.enabled && Boolean(authorizationUrl) && Boolean(tokenUrl) && Boolean(stored.clientId);
return {
...stored,
authorizationUrl,
tokenUrl,
userInfoUrl,
jwksUrl,
redirectUri,
scope,
ready,
providerId,
isTestProvider,
issuerForVerification,
};
}
private async validatePublicOutboundUrl(rawUrl: string, fieldName: string): Promise<URL> {
let parsedUrl: URL;
try {
parsedUrl = new URL(rawUrl);
} catch {
throw InputValidationError.create(fieldName, 'Invalid URL');
}
await SsoService.SSO_REQUEST_URL_POLICY.validate(parsedUrl, {
phase: 'initial',
redirectCount: 0,
});
return parsedUrl;
}
private async validateOptionalPublicOutboundUrl(rawUrl: string | null, fieldName: string): Promise<string | null> {
if (!rawUrl) {
return null;
}
try {
const validUrl = await this.validatePublicOutboundUrl(rawUrl, fieldName);
return validUrl.toString();
} catch (error) {
this.logger.warn({fieldName, rawUrl, error}, 'Ignoring SSO URL that failed outbound policy validation');
return null;
}
}
private async requireReadyConfig(): Promise<ResolvedSsoConfig> {
const config = await this.getResolvedConfig();
if (!config.ready) {
throw new FeatureTemporarilyDisabledError();
}
return config;
}
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@fluxer/api/src/Logger';
import * as FetchUtils from '@fluxer/api/src/utils/FetchUtils';
import {ms} from 'itty-time';
export interface DiscoveredOidcProviderMetadata {
issuer: string;
authorization_endpoint?: string;
token_endpoint?: string;
userinfo_endpoint?: string;
jwks_uri?: string;
}
export function sanitizeSsoRedirectTo(redirectTo?: string): string | undefined {
if (!redirectTo) return undefined;
const trimmed = redirectTo.trim();
if (!trimmed) return undefined;
if (!trimmed.startsWith('/')) return undefined;
if (trimmed.startsWith('//')) return undefined;
if (trimmed.length > 2048) return undefined;
if (trimmed.includes('\r') || trimmed.includes('\n')) return undefined;
return trimmed;
}
export function parseTokenEndpointResponse(
contentTypeHeader: string | null,
rawResponseBody: string,
): {id_token?: string; access_token?: string} {
const contentType = contentTypeHeader?.toLowerCase() ?? '';
const raw = rawResponseBody;
if (contentType.includes('application/json') || contentType.includes('application/jwt')) {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
id_token: typeof parsed['id_token'] === 'string' ? parsed['id_token'] : undefined,
access_token: typeof parsed['access_token'] === 'string' ? parsed['access_token'] : undefined,
};
} catch {
return {id_token: undefined, access_token: undefined};
}
}
const params = new URLSearchParams(raw);
return {
id_token: params.get('id_token') ?? undefined,
access_token: params.get('access_token') ?? undefined,
};
}
function buildDiscoveryUrl(issuer: string): URL {
const issuerUrl = new URL(issuer);
const normalized = issuerUrl.toString().replace(/\/$/, '');
return new URL(`${normalized}/.well-known/openid-configuration`);
}
function normalizeIssuerForCompare(value: string): string {
return value.replace(/\/$/, '');
}
export async function tryDiscoverOidcProviderMetadata(issuer: string): Promise<DiscoveredOidcProviderMetadata | null> {
try {
const url = buildDiscoveryUrl(issuer);
const resp = await FetchUtils.sendRequest({
url: url.toString(),
method: 'GET',
headers: {Accept: 'application/json'},
timeout: ms('10 seconds'),
serviceName: 'sso_oidc_discovery',
});
if (resp.status < 200 || resp.status >= 300) return null;
const rawBody = await FetchUtils.streamToString(resp.stream);
const json = JSON.parse(rawBody) as Record<string, unknown>;
const discoveredIssuer = typeof json['issuer'] === 'string' ? json['issuer'] : null;
if (!discoveredIssuer) return null;
if (normalizeIssuerForCompare(discoveredIssuer) !== normalizeIssuerForCompare(issuer)) {
return null;
}
return {
issuer: discoveredIssuer,
authorization_endpoint:
typeof json['authorization_endpoint'] === 'string' ? json['authorization_endpoint'] : undefined,
token_endpoint: typeof json['token_endpoint'] === 'string' ? json['token_endpoint'] : undefined,
userinfo_endpoint: typeof json['userinfo_endpoint'] === 'string' ? json['userinfo_endpoint'] : undefined,
jwks_uri: typeof json['jwks_uri'] === 'string' ? json['jwks_uri'] : undefined,
};
} catch (error) {
Logger.debug({issuer, error}, 'Failed to discover OIDC provider metadata');
return null;
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import {seconds} from 'itty-time';
import {jwtVerify, SignJWT} from 'jose';
export class SudoModeService {
private readonly secret: Uint8Array;
constructor() {
this.secret = new TextEncoder().encode(Config.auth.sudoModeSecret);
}
async generateSudoToken(userId: UserID): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const jwt = await new SignJWT({
type: 'sudo',
})
.setProtectedHeader({alg: 'HS256'})
.setSubject(userId.toString())
.setIssuedAt(now)
.setExpirationTime(now + seconds('5 minutes'))
.sign(this.secret);
return jwt;
}
async verifySudoToken(token: string, userId: UserID): Promise<boolean> {
try {
const {payload} = await jwtVerify(token, this.secret, {
algorithms: ['HS256'],
});
if (payload['type'] !== 'sudo') {
return false;
}
if (payload.sub !== userId.toString()) {
return false;
}
return true;
} catch {
return false;
}
}
}
let sudoModeServiceInstance: SudoModeService | null = null;
export function getSudoModeService(): SudoModeService {
if (!sudoModeServiceInstance) {
sudoModeServiceInstance = new SudoModeService();
}
return sudoModeServiceInstance;
}

View File

@@ -0,0 +1,158 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AuthService} from '@fluxer/api/src/auth/AuthService';
import type {AuthMfaService} from '@fluxer/api/src/auth/services/AuthMfaService';
import {getSudoModeService} from '@fluxer/api/src/auth/services/SudoModeService';
import {SUDO_MODE_HEADER} from '@fluxer/api/src/middleware/SudoModeMiddleware';
import type {User} from '@fluxer/api/src/models/User';
import type {HonoEnv} from '@fluxer/api/src/types/HonoEnv';
import {setSudoCookie} from '@fluxer/api/src/utils/SudoCookieUtils';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {SudoModeRequiredError} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {AuthenticationResponseJSON} from '@simplewebauthn/server';
import type {Context} from 'hono';
export interface SudoVerificationBody {
password?: string;
mfa_method?: 'totp' | 'sms' | 'webauthn';
mfa_code?: string;
webauthn_response?: AuthenticationResponseJSON;
webauthn_challenge?: string;
}
type SudoVerificationMethod = 'password' | 'mfa' | 'sudo_token';
export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boolean {
return (user.authenticatorTypes?.size ?? 0) > 0;
}
export interface SudoVerificationResult {
verified: boolean;
method: SudoVerificationMethod;
sudoToken?: string;
}
export interface SudoVerificationOptions {
issueSudoToken?: boolean;
}
async function verifySudoMode(
ctx: Context<HonoEnv>,
user: User,
body: SudoVerificationBody,
authService: AuthService,
mfaService: AuthMfaService,
options: SudoVerificationOptions = {},
): Promise<SudoVerificationResult> {
if (user.isBot) {
return {verified: true, method: 'sudo_token'};
}
const hasMfa = userHasMfa(user);
const issueSudoToken = options.issueSudoToken ?? hasMfa;
if (hasMfa && ctx.get('sudoModeValid')) {
const sudoToken = ctx.get('sudoModeToken') ?? ctx.req.header(SUDO_MODE_HEADER) ?? undefined;
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? sudoToken : undefined};
}
const incomingToken = ctx.req.header(SUDO_MODE_HEADER);
if (!hasMfa && incomingToken && ctx.get('sudoModeValid')) {
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? incomingToken : undefined};
}
if (hasMfa && body.mfa_method) {
const result = await mfaService.verifySudoMfa({
userId: user.id,
method: body.mfa_method,
code: body.mfa_code,
webauthnResponse: body.webauthn_response,
webauthnChallenge: body.webauthn_challenge,
});
if (!result.success) {
throw InputValidationError.fromCode('mfa_code', ValidationErrorCodes.INVALID_MFA_CODE);
}
const sudoModeService = getSudoModeService();
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
return {verified: true, sudoToken, method: 'mfa'};
}
const isUnclaimedAccount = user.isUnclaimedAccount();
if (isUnclaimedAccount && !hasMfa) {
return {verified: true, method: 'password'};
}
if (body.password && !hasMfa) {
if (!user.passwordHash) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
}
const passwordValid = await authService.verifyPassword({
password: body.password,
passwordHash: user.passwordHash,
});
if (!passwordValid) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.INVALID_PASSWORD);
}
return {verified: true, method: 'password'};
}
throw new SudoModeRequiredError(hasMfa);
}
function setSudoTokenHeader(
ctx: Context<HonoEnv>,
result: SudoVerificationResult,
options: SudoVerificationOptions = {},
): void {
const issueSudoToken = options.issueSudoToken ?? true;
if (!issueSudoToken) {
return;
}
const tokenToSet = result.sudoToken ?? ctx.req.header(SUDO_MODE_HEADER);
if (tokenToSet) {
ctx.header(SUDO_MODE_HEADER, tokenToSet);
const user = ctx.get('user');
if (user) {
setSudoCookie(ctx, tokenToSet, user.id.toString());
}
}
}
export async function requireSudoMode(
ctx: Context<HonoEnv>,
user: User,
body: SudoVerificationBody,
authService: AuthService,
mfaService: AuthMfaService,
options: SudoVerificationOptions = {},
): Promise<SudoVerificationResult> {
const sudoResult = await verifySudoMode(ctx, user, body, authService, mfaService, options);
setSudoTokenHeader(ctx, sudoResult, options);
return sudoResult;
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createUniqueEmail,
createUniqueUsername,
loginUser,
registerUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
async function setUserSecurityFlags(harness: ApiTestHarness, userId: string, setFlags: Array<string>): Promise<void> {
await createBuilderWithoutAuth(harness)
.post(`/test/users/${userId}/security-flags`)
.body({
set_flags: setFlags,
})
.expect(200)
.execute();
}
describe('Auth app store reviewer IP bypass', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('allows login from any IP address when APP_STORE_REVIEWER flag is set', async () => {
const email = createUniqueEmail('app-store-reviewer');
const username = createUniqueUsername('reviewer');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username,
global_name: 'App Store Reviewer',
password,
date_of_birth: '2000-01-01',
consent: true,
});
await setUserSecurityFlags(harness, reg.user_id, ['APP_STORE_REVIEWER']);
const login = await loginUser(harness, {
email,
password,
});
expect('mfa' in login).toBe(false);
if (!('mfa' in login)) {
const nonMfaLogin = login as {user_id: string; token: string};
expect(nonMfaLogin.token).toBeTruthy();
expect(nonMfaLogin.user_id).toBe(reg.user_id);
}
});
});

View File

@@ -0,0 +1,84 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createUniqueEmail,
createUniqueUsername,
loginUser,
registerUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
async function setUserSecurityFlags(harness: ApiTestHarness, userId: string, setFlags: Array<string>): Promise<void> {
await createBuilderWithoutAuth(harness)
.post(`/test/users/${userId}/security-flags`)
.body({
set_flags: setFlags,
})
.expect(200)
.execute();
}
describe('Auth app store reviewer with other flags', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('allows login with APP_STORE_REVIEWER flag combined with other flags', async () => {
const email = createUniqueEmail('reviewer-multi-flag');
const username = createUniqueUsername('reviewer');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username,
global_name: 'Multi Flag Reviewer',
password,
date_of_birth: '2000-01-01',
consent: true,
});
await setUserSecurityFlags(harness, reg.user_id, ['APP_STORE_REVIEWER', 'STAFF']);
const login = await loginUser(harness, {
email,
password,
});
expect('mfa' in login).toBe(false);
if (!('mfa' in login)) {
const nonMfaLogin = login as {user_id: string; token: string};
expect(nonMfaLogin.token).toBeTruthy();
expect(nonMfaLogin.user_id).toBe(reg.user_id);
}
});
});

View File

@@ -0,0 +1,147 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomBytes} from 'node:crypto';
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {TotpGenerator} from '@fluxer/api/src/utils/TotpGenerator';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface MfaMethodsResponse {
totp: boolean;
sms: boolean;
webauthn: boolean;
has_mfa: boolean;
}
interface BackupCodesResponse {
backup_codes: Array<{code: string; consumed: boolean}>;
}
function generateTotpSecret(): string {
const buffer = randomBytes(20);
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let result = '';
for (let i = 0; i < buffer.length; i += 5) {
const bytes = [buffer[i]!, buffer[i + 1]!, buffer[i + 2]!, buffer[i + 3]!, buffer[i + 4]!];
const n = (bytes[0]! << 24) | (bytes[1]! << 16) | (bytes[2]! << 8) | bytes[3]!;
const indices = [
(n >> 3) & 0x1f,
((n >> 11) | ((bytes[4]! << 4) & 0xf)) & 0x1f,
((n >> 19) | ((bytes[4]! << 2) & 0x3c)) & 0x1f,
(bytes[4]! >> 1) & 0x1f,
];
result += base32Chars[indices[0]!];
result += base32Chars[indices[1]!];
result += base32Chars[indices[2]!];
result += base32Chars[indices[3]!];
}
return result;
}
async function generateTotpCode(secret: string): Promise<string> {
const totp = new TotpGenerator(secret);
const codes = await totp.generateTotp();
return codes[0]!;
}
describe('Auth sudo MFA methods', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('returns all MFA methods as false for user without MFA', async () => {
const account = await createTestAccount(harness);
const payload = await createBuilder<MfaMethodsResponse>(harness, account.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(payload.has_mfa).toBe(false);
expect(payload.totp).toBe(false);
expect(payload.sms).toBe(false);
expect(payload.webauthn).toBe(false);
});
it('returns TOTP as enabled after user enables TOTP', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
const code = await generateTotpCode(secret);
const enableResp = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code,
password: account.password,
})
.execute();
expect(enableResp.backup_codes.length).toBeGreaterThan(0);
const loginResp = await createBuilderWithoutAuth<{
mfa: true;
ticket: string;
allowed_methods: Array<string>;
sms_phone_hint: string | null;
}>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
expect(loginResp.mfa).toBe(true);
expect(loginResp.allowed_methods).toContain('totp');
const mfaLoginResp = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
ticket: loginResp.ticket,
code: await generateTotpCode(secret),
})
.execute();
account.token = mfaLoginResp.token;
const payload = await createBuilder<MfaMethodsResponse>(harness, account.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(payload.has_mfa).toBe(true);
expect(payload.totp).toBe(true);
expect(payload.sms).toBe(false);
expect(payload.webauthn).toBe(false);
});
});

View File

@@ -0,0 +1,100 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth sudo password verification', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('logs out session with correct password and returns sudo token', async () => {
const account = await createTestAccount(harness);
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
.get('/auth/sessions')
.execute();
expect(sessions.length).toBeGreaterThan(0);
await createBuilder(harness, account.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
password: account.password,
})
.expect(204)
.execute();
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
});
it('rejects logout with wrong password and preserves token', async () => {
const account: TestAccount = await createTestAccount(harness);
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
.get('/auth/sessions')
.execute();
expect(sessions.length).toBeGreaterThan(0);
await createBuilder(harness, account.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
password: 'wrong-password',
})
.expect(400)
.execute();
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
});
it('rejects logout without password with 403', async () => {
const account = await createTestAccount(harness);
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
.get('/auth/sessions')
.execute();
expect(sessions.length).toBeGreaterThan(0);
await createBuilder(harness, account.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
})
.expect(403)
.execute();
});
});

View File

@@ -0,0 +1,309 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomBytes} from 'node:crypto';
import {
createAuthHarness,
createTestAccount,
loginAccount,
type TestAccount,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {TotpGenerator} from '@fluxer/api/src/utils/TotpGenerator';
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface BackupCodesResponse {
backup_codes: Array<{code: string; consumed: boolean}>;
}
interface OAuth2ApplicationResponse {
id: string;
client_id: string;
client_secret: string;
bot: {
id: string;
token: string;
};
}
function generateTotpSecret(): string {
const buffer = randomBytes(20);
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let result = '';
for (let i = 0; i < buffer.length; i += 5) {
const bytes = [buffer[i]!, buffer[i + 1]!, buffer[i + 2]!, buffer[i + 3]!, buffer[i + 4]!];
const n = (bytes[0]! << 24) | (bytes[1]! << 16) | (bytes[2]! << 8) | bytes[3]!;
const indices = [
(n >> 3) & 0x1f,
((n >> 11) | ((bytes[4]! << 4) & 0xf)) & 0x1f,
((n >> 19) | ((bytes[4]! << 2) & 0x3c)) & 0x1f,
(bytes[4]! >> 1) & 0x1f,
];
result += base32Chars[indices[0]!];
result += base32Chars[indices[1]!];
result += base32Chars[indices[2]!];
result += base32Chars[indices[3]!];
}
return result;
}
async function generateTotpCode(secret: string): Promise<string> {
const totp = new TotpGenerator(secret);
const codes = await totp.generateTotp();
return codes[0]!;
}
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
const loginResp = await createBuilderWithoutAuth<{mfa: true; ticket: string} | {mfa: false; token: string}>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
if (!loginResp.mfa) {
throw new Error('Expected MFA login');
}
const mfaLoginResp = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
ticket: loginResp.ticket,
code: await generateTotpCode(secret),
})
.execute();
return {...account, token: mfaLoginResp.token};
}
async function createOAuth2BotApplication(
harness: ApiTestHarness,
account: TestAccount,
name: string,
redirectUris: Array<string>,
): Promise<string> {
const created = await createBuilder<OAuth2ApplicationResponse>(harness, account.token)
.post('/oauth2/applications')
.body({
name,
redirect_uris: redirectUris,
})
.execute();
return created.id;
}
async function deleteOAuth2Application(harness: ApiTestHarness, account: TestAccount, appId: string): Promise<void> {
await createBuilder(harness, account.token)
.delete(`/oauth2/applications/${appId}`)
.body({
password: account.password,
})
.expect(204)
.execute();
}
describe('Auth sudo required operations', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('requires sudo for session logout', async () => {
const account = await createTestAccount(harness);
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
.get('/auth/sessions')
.execute();
expect(sessions.length).toBeGreaterThan(0);
await createBuilder(harness, account.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
})
.expect(403)
.execute();
await createBuilder(harness, account.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
password: account.password,
})
.expect(204)
.execute();
await loginAccount(harness, account);
});
it('requires sudo for delete OAuth2 application', async () => {
const account = await createTestAccount(harness);
const appName = `Test App ${Date.now()}`;
const appId = await createOAuth2BotApplication(harness, account, appName, ['https://example.com/callback']);
await createBuilder(harness, account.token).delete(`/oauth2/applications/${appId}`).body({}).expect(403).execute();
await createBuilder(harness, account.token)
.delete(`/oauth2/applications/${appId}`)
.body({
password: account.password,
})
.expect(204)
.execute();
});
it('requires sudo for reset bot token', async () => {
const account = await createTestAccount(harness);
const appName = `Test App ${Date.now()}`;
const appId = await createOAuth2BotApplication(harness, account, appName, ['https://example.com/callback']);
await createBuilder(harness, account.token)
.post(`/oauth2/applications/${appId}/bot/reset-token`)
.body({})
.expect(403)
.execute();
await createBuilder(harness, account.token)
.post(`/oauth2/applications/${appId}/bot/reset-token`)
.body({
password: account.password,
})
.expect(200)
.execute();
await deleteOAuth2Application(harness, account, appId);
});
it('requires sudo for disable TOTP MFA', async () => {
let account = await createTestAccount(harness);
const secret = generateTotpSecret();
const code = await generateTotpCode(secret);
const enableResp = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code,
password: account.password,
})
.execute();
expect(enableResp.backup_codes.length).toBeGreaterThan(0);
const backupCode = enableResp.backup_codes[0]!.code;
account = await loginWithTotp(harness, account, secret);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCode,
})
.expect(403)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCode,
mfa_method: 'totp',
mfa_code: await generateTotpCode(secret),
})
.expect(204)
.execute();
});
it('requires sudo for enable TOTP MFA', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
const code = await generateTotpCode(secret);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code,
})
.expect(403)
.execute();
await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code,
password: account.password,
})
.execute();
});
it('requires sudo for disable account', async () => {
const testUser = await createTestAccount(harness);
await createBuilder(harness, testUser.token).post('/users/@me/disable').body({}).expect(403).execute();
await createBuilder(harness, testUser.token)
.post('/users/@me/disable')
.body({
password: testUser.password,
})
.expect(204)
.execute();
});
it('requires sudo for delete account', async () => {
const testUser = await createTestAccount(harness);
await createBuilder(harness, testUser.token).post('/users/@me/delete').body({}).expect(403).execute();
await createBuilder(harness, testUser.token)
.post('/users/@me/delete')
.body({
password: testUser.password,
})
.expect(204)
.execute();
});
it('allows non-sudo operations without sudo', async () => {
const account = await createTestAccount(harness);
await loginAccount(harness, account);
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
await createBuilder(harness, account.token).get('/auth/sessions').expect(200).execute();
await createBuilder(harness, account.token).get('/users/@me/sudo/mfa-methods').expect(200).execute();
});
});

View File

@@ -0,0 +1,240 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createTestAccount,
createTotpSecret,
type TestAccount,
totpCodeNow,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface BackupCodesResponse {
backup_codes: Array<{code: string; consumed: boolean}>;
}
const SUDO_MODE_HEADER = 'X-Fluxer-Sudo-Mode-JWT';
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
const loginResp = await createBuilderWithoutAuth<{mfa: true; ticket: string} | {mfa: false; token: string}>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
if (!loginResp.mfa) {
throw new Error('Expected MFA login');
}
const mfaLoginResp = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
ticket: loginResp.ticket,
code: totpCodeNow(secret),
})
.execute();
return {...account, token: mfaLoginResp.token};
}
describe('Auth sudo TOTP verification', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('logs out session with TOTP and returns sudo token', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const code = totpCodeNow(secret);
const enableResp = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code,
password: account.password,
})
.execute();
expect(enableResp.backup_codes.length).toBeGreaterThan(0);
let loggedIn = await loginWithTotp(harness, account, secret);
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, loggedIn.token)
.get('/auth/sessions')
.execute();
expect(sessions.length).toBeGreaterThan(0);
const {response: logoutResponse} = await createBuilder(harness, loggedIn.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.executeWithResponse();
const sudoToken = logoutResponse.headers.get(SUDO_MODE_HEADER);
expect(sudoToken).toBeTruthy();
await createBuilder(harness, loggedIn.token).get('/users/@me').expect(401).execute();
loggedIn = await loginWithTotp(harness, loggedIn, secret);
const sessions2 = await createBuilder<Array<AuthSessionResponse>>(harness, loggedIn.token)
.get('/auth/sessions')
.execute();
expect(sessions2.length).toBeGreaterThan(0);
await createBuilder(harness, loggedIn.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions2[0]!.id_hash],
mfa_method: 'totp',
mfa_code: '000000',
})
.expect(400)
.execute();
await createBuilder(harness, loggedIn.token).get('/users/@me').expect(200).execute();
});
it('logs out session with backup code and returns sudo token', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const code = totpCodeNow(secret);
const enableResp = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code,
password: account.password,
})
.execute();
expect(enableResp.backup_codes.length).toBeGreaterThan(0);
const backupCode = enableResp.backup_codes[0]!.code;
const loggedIn = await loginWithTotp(harness, account, secret);
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, loggedIn.token)
.get('/auth/sessions')
.execute();
expect(sessions.length).toBeGreaterThan(0);
const {response: logoutResponse} = await createBuilder(harness, loggedIn.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
mfa_method: 'totp',
mfa_code: backupCode,
})
.expect(204)
.executeWithResponse();
const sudoToken = logoutResponse.headers.get(SUDO_MODE_HEADER);
expect(sudoToken).toBeTruthy();
await createBuilder(harness, loggedIn.token).get('/users/@me').expect(401).execute();
});
it('rejects password when MFA is enabled with 403', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const code = totpCodeNow(secret);
await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code,
password: account.password,
})
.execute();
const loggedIn = await loginWithTotp(harness, account, secret);
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, loggedIn.token)
.get('/auth/sessions')
.execute();
expect(sessions.length).toBeGreaterThan(0);
await createBuilder(harness, loggedIn.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
password: account.password,
})
.expect(403)
.execute();
});
it('rejects logout without MFA method when MFA is enabled with 403', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const code = totpCodeNow(secret);
await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code,
password: account.password,
})
.execute();
const loggedIn = await loginWithTotp(harness, account, secret);
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, loggedIn.token)
.get('/auth/sessions')
.execute();
expect(sessions.length).toBeGreaterThan(0);
await createBuilder(harness, loggedIn.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
})
.expect(403)
.execute();
});
});

View File

@@ -0,0 +1,426 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createHmac, randomBytes, randomUUID} from 'node:crypto';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {generateUniquePassword, TEST_CREDENTIALS, TEST_USER_DATA} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {decode as base32Decode, encode as base32Encode} from 'hi-base32';
import {expect} from 'vitest';
export interface RegisterResponse {
user_id: string;
token: string;
}
export interface LoginSuccessResponse {
user_id: string;
token: string;
}
export interface LoginMfaResponse {
mfa: true;
ticket: string;
allowed_methods: Array<string>;
sms_phone_hint: string | null;
sms: boolean;
totp: boolean;
webauthn: boolean;
}
export type LoginResponse =
| {user_id: string; token: string}
| {
mfa: true;
ticket: string;
allowed_methods: Array<string>;
sms_phone_hint: string | null;
sms: boolean;
totp: boolean;
webauthn: boolean;
};
export interface UserMeResponse {
id: string;
email: string | null;
username: string;
global_name: string | null;
}
export interface UserSettingsResponse {
incoming_call_flags: number;
}
export interface TestEmailRecord {
to: string;
subject: string;
type: string;
timestamp: string;
metadata: Record<string, string>;
}
export interface TestAccount {
email: string;
password: string;
userId: string;
token: string;
username?: string;
}
export function createUniqueEmail(prefix = 'integration'): string {
return `${prefix}-${randomUUID()}@example.com`;
}
export function createUniqueUsername(prefix = 'itest'): string {
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
}
export async function createAuthHarness(): Promise<ApiTestHarness> {
return await createApiTestHarness();
}
export async function registerUser(harness: ApiTestHarness, body: Record<string, unknown>): Promise<RegisterResponse> {
return createBuilder<RegisterResponse>(harness, '').post('/auth/register').body(body).execute();
}
export async function createTestAccount(
harness: ApiTestHarness,
params?: {
email?: string;
password?: string;
username?: string;
globalName?: string;
dateOfBirth?: string;
skipSessionStart?: boolean;
},
): Promise<TestAccount> {
const email = params?.email ?? createUniqueEmail('account');
const password = params?.password ?? TEST_CREDENTIALS.STRONG_PASSWORD;
const username = params?.username ?? createUniqueUsername('account');
const reg = await registerUser(harness, {
email,
username,
global_name: params?.globalName ?? TEST_USER_DATA.DEFAULT_GLOBAL_NAME,
password,
date_of_birth: params?.dateOfBirth ?? TEST_USER_DATA.DEFAULT_DATE_OF_BIRTH,
consent: true,
});
if (!params?.skipSessionStart) {
const HAS_SESSION_STARTED = BigInt(1) << BigInt(39);
await createBuilder<unknown>(harness, reg.token)
.patch(`/test/users/${reg.user_id}/flags`)
.body({
flags: HAS_SESSION_STARTED.toString(),
})
.execute();
}
return {email, password, userId: reg.user_id, token: reg.token, username};
}
export async function loginUser(
harness: ApiTestHarness,
body: {email: string; password: string; invite_code?: string | null},
): Promise<LoginResponse> {
return createBuilder<LoginResponse>(harness, '').post('/auth/login').body(body).execute();
}
export async function loginAccount(harness: ApiTestHarness, account: TestAccount): Promise<TestAccount> {
const login = await loginUser(harness, {email: account.email, password: account.password});
if ('mfa' in login) {
throw new Error('Expected non-MFA login for test account');
}
const {token, user_id} = login as {user_id: string; token: string};
return {...account, token, userId: user_id};
}
export async function fetchMe(
harness: ApiTestHarness,
token: string,
expectedStatus: 200 | 401 = 200,
): Promise<{response: Response; json: unknown}> {
const {response, json} = await createBuilder(harness, token).get('/users/@me').expect(expectedStatus).executeRaw();
return {response, json};
}
export async function fetchSettings(
harness: ApiTestHarness,
token: string,
expectedStatus: 200 | 401 = 200,
): Promise<{response: Response; json: unknown}> {
const {response, json} = await createBuilder(harness, token)
.get('/users/@me/settings')
.expect(expectedStatus)
.executeRaw();
return {response, json};
}
export async function listTestEmails(
harness: ApiTestHarness,
params?: {recipient?: string},
): Promise<Array<TestEmailRecord>> {
const query = params?.recipient ? `?recipient=${encodeURIComponent(params.recipient)}` : '';
const response = await createBuilder<{emails: Array<TestEmailRecord>}>(harness, '')
.get(`/test/emails${query}`)
.execute();
return response.emails;
}
export async function clearTestEmails(harness: ApiTestHarness): Promise<void> {
await createBuilder(harness, '').delete('/test/emails').expect(204).execute();
}
export function findLastTestEmail(emails: Array<TestEmailRecord>, type: string): TestEmailRecord | null {
for (let i = emails.length - 1; i >= 0; i--) {
const email = emails[i];
if (email?.type === type) return email;
}
return null;
}
export function titleCaseEmail(email: string): string {
return email
.toLowerCase()
.replace(/(^|[.@])([a-z])/g, (_match, prefix: string, char: string) => `${prefix}${char.toUpperCase()}`);
}
export interface BackupCodesResponse {
backup_codes: Array<{code: string}>;
}
export interface MfaLoginResponse {
token: string;
}
export interface PhoneVerifyResponse {
phone: string;
verified: boolean;
phone_token: string;
}
export function createTotpSecret(): string {
const buf = randomBytes(20);
return base32Encode(buf).replace(/=/g, '');
}
export function generateTotpCode(secret: string, time = Date.now()): string {
const key = Buffer.from(base32Decode.asBytes(secret.toUpperCase()));
const epoch = Math.floor(time / 1000);
const counter = Math.floor(epoch / 30);
const counterBuf = Buffer.alloc(8);
counterBuf.writeBigUInt64BE(BigInt(counter));
const hmac = createHmac('sha1', key);
hmac.update(counterBuf);
const hash = hmac.digest();
const offset = hash[hash.length - 1] & 0x0f;
const binary =
((hash[offset]! & 0x7f) << 24) |
((hash[offset + 1]! & 0xff) << 16) |
((hash[offset + 2]! & 0xff) << 8) |
(hash[offset + 3]! & 0xff);
const otp = binary % 1_000_000;
return otp.toString().padStart(6, '0');
}
export function totpCodeNow(secret: string): string {
return generateTotpCode(secret, Date.now());
}
export function totpCodeNext(secret: string): string {
return generateTotpCode(secret, Date.now() + 30000);
}
export async function seedMfaTicket(
harness: ApiTestHarness,
ticket: string,
userId: string,
ttlSeconds: number,
): Promise<void> {
await createBuilder(harness, '')
.post('/test/auth/mfa-ticket')
.body({
ticket,
user_id: userId,
ttl_seconds: ttlSeconds,
})
.execute();
}
export async function setUserACLs(
harness: ApiTestHarness,
account: TestAccount,
acls: Array<string>,
): Promise<TestAccount> {
await createBuilder(harness, `Bearer ${account.token}`)
.post(`/test/users/${account.userId}/acls`)
.body({acls})
.execute();
return await loginAccount(harness, account);
}
export async function unclaimAccount(harness: ApiTestHarness, userId: string): Promise<void> {
await createBuilder(harness, '').post(`/test/users/${userId}/unclaim`).body(null).execute();
}
export interface SsoConfig {
enabled: boolean;
authorization_url: string;
token_url: string;
client_id: string;
client_secret: string;
scope: string;
allowed_domains: Array<string>;
auto_provision: boolean;
redirect_uri: string;
display_name?: string;
}
export async function enableSso(
harness: ApiTestHarness,
token: string,
overrides: Partial<SsoConfig> = {},
): Promise<void> {
const ssoConfig: SsoConfig = {
enabled: true,
authorization_url: 'test',
token_url: 'test',
client_id: 'itest-client',
client_secret: '',
scope: 'openid email profile',
allowed_domains: ['example.com'],
auto_provision: true,
redirect_uri: '',
...overrides,
};
await createBuilder(harness, token).post('/admin/instance-config/update').body({sso: ssoConfig}).execute();
}
export async function disableSso(harness: ApiTestHarness, token: string): Promise<void> {
await createBuilder(harness, token)
.post('/admin/instance-config/update')
.body({
sso: {
enabled: false,
},
})
.execute();
}
export async function verifyTokenValid(harness: ApiTestHarness, token: string): Promise<boolean> {
const {response} = await createBuilder(harness, token).get('/users/@me').executeWithResponse();
return response.status === 200;
}
export async function verifySessionInvalidated(harness: ApiTestHarness, token: string): Promise<boolean> {
const valid = await verifyTokenValid(harness, token);
return !valid;
}
export async function createSessionFromLogin(harness: ApiTestHarness, account: TestAccount): Promise<string> {
const login = await loginUser(harness, {email: account.email, password: account.password});
if ('mfa' in login && login.mfa) {
throw new Error('Expected non-MFA login for test account');
}
const nonMfaLogin = login as {user_id: string; token: string};
return nonMfaLogin.token;
}
export async function listSessions(harness: ApiTestHarness, token: string): Promise<Array<{id: string}>> {
return createBuilder<Array<{id: string}>>(harness, token).get('/auth/sessions').execute();
}
export async function logoutSession(harness: ApiTestHarness, token: string): Promise<void> {
await createBuilder(harness, token).post('/auth/logout').expect(204).execute();
}
export async function changePassword(
harness: ApiTestHarness,
token: string,
oldPassword: string,
newPassword: string,
): Promise<void> {
await createBuilder(harness, token)
.patch('/users/@me')
.body({
password: oldPassword,
new_password: newPassword,
})
.execute();
}
export async function requestPasswordReset(harness: ApiTestHarness, email: string): Promise<void> {
const {response} = await createBuilder(harness, '').post('/auth/forgot').body({email}).executeWithResponse();
if (response.status !== 204 && response.status !== 200 && response.status !== 202) {
throw new Error(`Expected 204/200/202 for password reset request, got ${response.status}`);
}
}
export async function resetPassword(
harness: ApiTestHarness,
token: string,
newPassword: string,
): Promise<LoginSuccessResponse> {
return createBuilder<LoginSuccessResponse>(harness, '')
.post('/auth/reset')
.body({token, password: newPassword})
.execute();
}
export function generateStrongPassword(): string {
return generateUniquePassword();
}
export function createFakeAuthToken(): string {
const randomPart = Array.from({length: 36}, () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
return chars[Math.floor(Math.random() * chars.length)];
}).join('');
return `flx_${randomPart}`;
}
export async function assertLoginFails(harness: ApiTestHarness, email: string, password: string): Promise<void> {
const {response} = await createBuilder(harness, '').post('/auth/login').body({email, password}).executeWithResponse();
expect(response.status).toBe(400);
}
export async function assertEndpointProtected(harness: ApiTestHarness, path: string): Promise<void> {
const {response} = await createBuilder(harness, '').get(path).executeWithResponse();
expect(response.status).toBe(401);
}
export async function logoutSpecificSessions(
harness: ApiTestHarness,
token: string,
sessionIdHashes: Array<string>,
password: string,
): Promise<void> {
await createBuilder(harness, token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: sessionIdHashes,
password,
})
.expect(204)
.execute();
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createTestAccount,
findLastTestEmail,
listTestEmails,
type TestAccount,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface BouncedEmailRequestNewResponse {
ticket: string;
new_email: string;
new_code_expires_at: string;
resend_available_at: string | null;
}
interface UserPrivateResponse {
email: string | null;
verified: boolean;
email_bounced?: boolean;
required_actions: Array<string> | null;
}
async function markEmailAsBounced(harness: ApiTestHarness, account: TestAccount): Promise<void> {
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/security-flags`)
.body({
suspicious_activity_flag_names: ['REQUIRE_REVERIFIED_EMAIL'],
email_bounced: true,
email_verified: false,
})
.expect(200)
.execute();
}
describe('Bounced email recovery flow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
});
afterAll(async () => {
await harness?.shutdown();
});
it('allows bounced users to replace email without original-email verification', async () => {
const account = await createTestAccount(harness);
await markEmailAsBounced(harness, account);
await createBuilder(harness, account.token).get('/users/@me').expect(403).execute();
await createBuilder(harness, account.token).post('/users/@me/email-change/start').body({}).expect(403).execute();
const replacementEmail = `replacement-${Date.now()}@example.com`;
const requestNewResponse = await createBuilder<BouncedEmailRequestNewResponse>(harness, account.token)
.post('/users/@me/email-change/bounced/request-new')
.body({new_email: replacementEmail})
.execute();
expect(requestNewResponse.new_email).toBe(replacementEmail);
const originalEmailMessages = await listTestEmails(harness, {recipient: account.email});
expect(findLastTestEmail(originalEmailMessages, 'email_change_original')).toBeNull();
const replacementEmailMessages = await listTestEmails(harness, {recipient: replacementEmail});
const replacementVerificationEmail = findLastTestEmail(replacementEmailMessages, 'email_change_new');
expect(replacementVerificationEmail?.metadata?.code).toBeDefined();
const updatedUser = await createBuilder<UserPrivateResponse>(harness, account.token)
.post('/users/@me/email-change/bounced/verify-new')
.body({
ticket: requestNewResponse.ticket,
code: replacementVerificationEmail!.metadata!.code!,
})
.execute();
expect(updatedUser.email).toBe(replacementEmail);
expect(updatedUser.verified).toBe(true);
expect(updatedUser.email_bounced).toBe(false);
expect(updatedUser.required_actions).toBeNull();
const me = await createBuilder<UserPrivateResponse>(harness, account.token).get('/users/@me').execute();
expect(me.email).toBe(replacementEmail);
expect(me.email_bounced).toBe(false);
});
it('rejects bounced-email recovery for accounts that are not marked as bounced', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/email-change/bounced/request-new')
.body({new_email: `replacement-${Date.now()}@example.com`})
.expect(403, 'ACCESS_DENIED')
.execute();
});
});

View File

@@ -0,0 +1,188 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createUniqueEmail,
createUniqueUsername,
fetchMe,
type LoginSuccessResponse,
loginUser,
registerUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth case-insensitive email', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
describe('login with different case variations succeeds', () => {
const baseEmail = createUniqueEmail('login-case');
const password = 'Xk9#mP2$vL5@nQ8';
beforeEach(async () => {
await registerUser(harness, {
email: baseEmail,
username: createUniqueUsername('login'),
global_name: 'Login Test User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
});
it('allows login with lowercase email', async () => {
const login = await loginUser(harness, {
email: baseEmail.toLowerCase(),
password,
});
expect('mfa' in login).toBe(false);
expect((login as LoginSuccessResponse).token).toBeTruthy();
});
it('allows login with uppercase email', async () => {
const login = await loginUser(harness, {
email: baseEmail.toUpperCase(),
password,
});
expect('mfa' in login).toBe(false);
expect((login as LoginSuccessResponse).token).toBeTruthy();
});
it('allows login with mixed case email', async () => {
const mixedCaseEmail = baseEmail
.split('')
.map((char, index) => (index % 2 === 0 ? char.toUpperCase() : char.toLowerCase()))
.join('');
const login = await loginUser(harness, {
email: mixedCaseEmail,
password,
});
expect('mfa' in login).toBe(false);
expect((login as LoginSuccessResponse).token).toBeTruthy();
});
it('allows login with title case email', async () => {
const titleCaseEmail = baseEmail
.toLowerCase()
.replace(/(^|[.@])([a-z])/g, (_match, prefix, char) => `${prefix}${char.toUpperCase()}`);
const login = await loginUser(harness, {
email: titleCaseEmail,
password,
});
expect('mfa' in login).toBe(false);
expect((login as LoginSuccessResponse).token).toBeTruthy();
});
});
it('rejects registration with different case as duplicate', async () => {
const baseEmail = createUniqueEmail('duplicate-case');
const password = 'Rt7&kW3!qL9@mP2';
await registerUser(harness, {
email: baseEmail,
username: createUniqueUsername('duplicate1'),
global_name: 'Duplicate Test User 1',
password,
date_of_birth: '2000-01-01',
consent: true,
});
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body({
email: baseEmail.toUpperCase(),
username: createUniqueUsername('duplicate2'),
global_name: 'Duplicate Test User 2',
password: 'different-password-456',
date_of_birth: '2000-01-01',
consent: true,
})
.expect(400)
.execute();
});
it('allows forgot password with different case', async () => {
const baseEmail = createUniqueEmail('forgot-case');
const password = 'Mn8$jX4&vB6@pL1';
await registerUser(harness, {
email: baseEmail,
username: createUniqueUsername('forgot'),
global_name: 'Forgot Test User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
await createBuilderWithoutAuth(harness)
.post('/auth/forgot')
.body({
email: baseEmail.toUpperCase(),
})
.expect(204)
.execute();
});
it('preserves original email case in user record', async () => {
const mixedEmail = `${createUniqueEmail('normalized').split('@')[0]}@Example.COM`;
const password = 'Df5&gH9@kW3!qL2';
const reg = await registerUser(harness, {
email: mixedEmail,
username: createUniqueUsername('normalize'),
global_name: 'Normalize Test User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const {response, json} = await fetchMe(harness, reg.token);
expect(response.status).toBe(200);
const user = json as {email: string | null; username: string; global_name: string | null};
expect(user.email).toBe(mixedEmail);
const login = await loginUser(harness, {
email: mixedEmail.toLowerCase(),
password,
});
expect('mfa' in login).toBe(false);
const nonMfaLogin = login as {user_id: string; token: string};
expect(nonMfaLogin.token).toBeTruthy();
});
});

View File

@@ -0,0 +1,132 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount, loginAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth concurrent sessions', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('same user can have multiple concurrent sessions', async () => {
const account = await createTestAccount(harness);
const session1Token = account.token;
const account2 = await loginAccount(harness, account);
const session2Token = account2.token;
if (session1Token === session2Token) {
console.warn('warning: multiple logins returned the same token, may indicate single-session behavior');
}
await createBuilder(harness, session1Token).get('/users/@me').expect(200).execute();
await createBuilder(harness, session2Token).get('/users/@me').expect(200).execute();
});
it('logging out one session does not affect other sessions', async () => {
const account = await createTestAccount(harness);
const session1Token = account.token;
const account2 = await loginAccount(harness, account);
const session2Token = account2.token;
const account3 = await loginAccount(harness, account);
const session3Token = account3.token;
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, session1Token)
.get('/auth/sessions')
.execute();
expect(sessions.length).toBeGreaterThanOrEqual(3);
await createBuilder(harness, session2Token).post('/auth/logout').expect(204).execute();
await createBuilder(harness, session1Token).get('/users/@me').expect(200).execute();
await createBuilder(harness, session3Token).get('/users/@me').expect(200).execute();
await createBuilder(harness, session2Token).get('/users/@me').expect(401).execute();
});
it('can list all active sessions', async () => {
const account = await createTestAccount(harness);
await loginAccount(harness, account);
await loginAccount(harness, account);
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
.get('/auth/sessions')
.execute();
expect(sessions.length).toBeGreaterThan(0);
for (const session of sessions) {
expect(session.id_hash).toBeTruthy();
expect(session.id_hash.length).toBeGreaterThan(0);
}
});
it('can log out specific session by ID', async () => {
let account = await createTestAccount(harness);
await loginAccount(harness, account);
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
.get('/auth/sessions')
.execute();
expect(sessions.length).toBeGreaterThanOrEqual(2);
const targetSessionID = sessions[0]!.id_hash;
await createBuilder(harness, account.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [targetSessionID],
password: account.password,
})
.expect(204)
.execute();
account = await loginAccount(harness, account);
const sessions2 = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
.get('/auth/sessions')
.execute();
expect(sessions2.find((s) => s.id_hash === targetSessionID)).toBeUndefined();
});
});

View File

@@ -0,0 +1,91 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount, loginAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface HandoffInitiateResponse {
code: string;
}
interface HandoffStatusResponse {
status: 'pending' | 'completed' | 'expired';
token?: string;
user_id?: string;
}
function validateHandoffCodeFormat(code: string): boolean {
return /^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(code);
}
describe('Auth desktop handoff code normalization', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('accepts codes without dashes and is case-insensitive', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
.post('/auth/handoff/initiate')
.body(null)
.execute();
expect(validateHandoffCodeFormat(initResp.code)).toBe(true);
const codeWithoutDash = initResp.code.replace(/-/g, '');
const status1 = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${codeWithoutDash}/status`)
.execute();
expect(status1.status).toBe('pending');
const lowercaseCode = initResp.code.toLowerCase();
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({
code: lowercaseCode,
token: login.token,
user_id: login.userId,
})
.expect(204)
.execute();
const status2 = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.execute();
expect(status2.status).toBe('completed');
expect(status2.token).toBeTruthy();
expect(status2.token).not.toBe(login.token);
});
});

View File

@@ -0,0 +1,124 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount, fetchMe, loginAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface HandoffInitiateResponse {
code: string;
}
interface HandoffStatusResponse {
status: 'pending' | 'completed' | 'expired';
token?: string;
user_id?: string;
}
interface UserMeResponse {
id: string;
email: string | null;
username: string;
global_name: string | null;
}
function validateHandoffCodeFormat(code: string): boolean {
return /^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(code);
}
describe('Auth desktop handoff flow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('completes full handoff flow and cancels correctly', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
.post('/auth/handoff/initiate')
.body(null)
.execute();
expect(initResp.code).toBeTruthy();
expect(validateHandoffCodeFormat(initResp.code)).toBe(true);
const pending = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.execute();
expect(pending.status).toBe('pending');
expect(pending.token).toBeUndefined();
expect(pending.user_id).toBeUndefined();
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({
code: initResp.code,
token: login.token,
user_id: login.userId,
})
.expect(204)
.execute();
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.execute();
expect(completed.status).toBe('completed');
expect(completed.token).toBeTruthy();
expect(completed.token).not.toBe(login.token);
expect(completed.user_id).toBe(login.userId);
const originalSession = await fetchMe(harness, login.token);
expect(originalSession.response.status).toBe(200);
const originalUser = originalSession.json as UserMeResponse;
expect(originalUser.id).toBe(login.userId);
const handoffSession = await fetchMe(harness, completed.token!);
expect(handoffSession.response.status).toBe(200);
const handoffUser = handoffSession.json as UserMeResponse;
expect(handoffUser.id).toBe(login.userId);
const retrieved = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.execute();
expect(retrieved.status).toBe('expired');
const second = await createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
.post('/auth/handoff/initiate')
.body(null)
.execute();
await createBuilderWithoutAuth(harness).delete(`/auth/handoff/${second.code}`).expect(204).execute();
const cancelled = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${second.code}/status`)
.execute();
expect(cancelled.status).toBe('expired');
});
});

View File

@@ -0,0 +1,67 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
describe('Auth desktop handoff negative paths', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('rejects unknown handoff code on status endpoint', async () => {
await createBuilderWithoutAuth(harness)
.get('/auth/handoff/unknown-code/status')
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
});
it('rejects handoff complete with bad token', async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({
code: 'bad-code',
token: 'bad-token',
user_id: '123',
})
.expect(HTTP_STATUS.UNAUTHORIZED, 'INVALID_TOKEN')
.execute();
});
it('handles cancel for unknown handoff code gracefully', async () => {
await createBuilderWithoutAuth(harness)
.delete('/auth/handoff/unknown-code')
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
});
});

View File

@@ -0,0 +1,89 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount, loginAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface HandoffInitiateResponse {
code: string;
}
interface HandoffStatusResponse {
status: 'pending' | 'completed' | 'expired';
token?: string;
user_id?: string;
}
describe('Auth desktop handoff complete single use', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('prevents reuse of handoff code after completion', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
.post('/auth/handoff/initiate')
.body(null)
.execute();
expect(initResp.code).toBeTruthy();
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({
code: initResp.code,
token: login.token,
user_id: login.userId,
})
.expect(204)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({
code: initResp.code,
token: login.token,
user_id: login.userId,
})
.expect(400)
.execute();
const status = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.execute();
expect(status.status).toBe('completed');
expect(status.token).toBeTruthy();
expect(status.token).not.toBe(login.token);
});
});

View File

@@ -0,0 +1,342 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createTestAccount,
findLastTestEmail,
listTestEmails,
type TestAccount,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface EmailChangeStartResponse {
ticket: string;
require_original: boolean;
original_proof?: string;
original_code_expires_at?: string;
resend_available_at?: string;
}
interface EmailChangeVerifyOriginalResponse {
original_proof: string;
}
interface EmailChangeRequestNewResponse {
ticket: string;
new_email: string;
new_code_expires_at: string;
resend_available_at?: string;
}
interface EmailChangeVerifyNewResponse {
email_token: string;
}
interface UserPrivateResponse {
id: string;
email: string;
phone?: string | null;
username: string;
discriminator: string;
global_name: string;
bio: string;
verified: boolean;
mfa_enabled: boolean;
authenticator_types: Array<number>;
password_last_changed_at?: string;
}
async function startEmailChange(
harness: ApiTestHarness,
account: TestAccount,
password: string,
): Promise<EmailChangeStartResponse> {
return createBuilder<EmailChangeStartResponse>(harness, account.token)
.post('/users/@me/email-change/start')
.body({password})
.execute();
}
async function verifyOriginalEmailChange(
harness: ApiTestHarness,
account: TestAccount,
ticket: string,
code: string,
password: string,
): Promise<string> {
const resp = await createBuilder<EmailChangeVerifyOriginalResponse>(harness, account.token)
.post('/users/@me/email-change/verify-original')
.body({ticket, code, password})
.execute();
return resp.original_proof;
}
async function requestNewEmailChange(
harness: ApiTestHarness,
account: TestAccount,
ticket: string,
newEmail: string,
originalProof: string,
password: string,
): Promise<EmailChangeRequestNewResponse> {
return createBuilder<EmailChangeRequestNewResponse>(harness, account.token)
.post('/users/@me/email-change/request-new')
.body({
ticket,
new_email: newEmail,
original_proof: originalProof,
password,
})
.execute();
}
async function verifyNewEmailChange(
harness: ApiTestHarness,
account: TestAccount,
ticket: string,
code: string,
originalProof: string,
password: string,
): Promise<string> {
const resp = await createBuilder<EmailChangeVerifyNewResponse>(harness, account.token)
.post('/users/@me/email-change/verify-new')
.body({
ticket,
code,
original_proof: originalProof,
password,
})
.execute();
return resp.email_token;
}
async function unclaimAccount(harness: ApiTestHarness, userId: string): Promise<void> {
await createBuilderWithoutAuth(harness).post(`/test/users/${userId}/unclaim`).body(null).expect(200).execute();
}
describe('Email change flow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
});
afterAll(async () => {
await harness?.shutdown();
});
it('uses ticketed dual-code flow with sudo and proof token', async () => {
const account = await createTestAccount(harness);
const startResp = await startEmailChange(harness, account, account.password);
let originalProof: string;
if (startResp.require_original) {
const emails = await listTestEmails(harness, {recipient: account.email});
const originalEmail = findLastTestEmail(emails, 'email_change_original');
expect(originalEmail?.metadata?.code).toBeDefined();
const originalCode = originalEmail!.metadata!.code!;
originalProof = await verifyOriginalEmailChange(
harness,
account,
startResp.ticket,
originalCode,
account.password,
);
} else {
expect(startResp.original_proof).toBeDefined();
originalProof = startResp.original_proof!;
}
const newEmail = `integration-new-${Date.now()}@example.com`;
const newReq = await requestNewEmailChange(
harness,
account,
startResp.ticket,
newEmail,
originalProof,
account.password,
);
expect(newReq.new_email).toBe(newEmail);
const newEmails = await listTestEmails(harness, {recipient: newEmail});
const newEmailData = findLastTestEmail(newEmails, 'email_change_new');
expect(newEmailData?.metadata?.code).toBeDefined();
const newCode = newEmailData!.metadata!.code!;
const token = await verifyNewEmailChange(
harness,
account,
startResp.ticket,
newCode,
originalProof,
account.password,
);
const updated = await createBuilder<UserPrivateResponse>(harness, account.token)
.patch('/users/@me')
.body({
email_token: token,
password: account.password,
})
.execute();
expect(updated.email).toBe(newEmail);
});
it('rejects direct email field update', async () => {
const account = await createTestAccount(harness);
const newEmail = `integration-direct-${Date.now()}@example.com`;
await createBuilder(harness, account.token)
.patch('/users/@me')
.body({
email: newEmail,
password: account.password,
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
});
it('request-new fails without original_proof', async () => {
const account = await createTestAccount(harness);
const startResp = await startEmailChange(harness, account, account.password);
const newEmail = `integration-no-proof-${Date.now()}@example.com`;
await createBuilder(harness, account.token)
.post('/users/@me/email-change/request-new')
.body({
ticket: startResp.ticket,
new_email: newEmail,
original_proof: 'invalid-proof-token',
password: account.password,
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
});
it('verify-new fails without original_proof', async () => {
const account = await createTestAccount(harness);
const startResp = await startEmailChange(harness, account, account.password);
let originalProof: string;
if (startResp.require_original) {
const emails = await listTestEmails(harness, {recipient: account.email});
const originalEmail = findLastTestEmail(emails, 'email_change_original');
expect(originalEmail?.metadata?.code).toBeDefined();
const originalCode = originalEmail!.metadata!.code!;
originalProof = await verifyOriginalEmailChange(
harness,
account,
startResp.ticket,
originalCode,
account.password,
);
} else {
originalProof = startResp.original_proof!;
}
const newEmail = `integration-verify-no-proof-${Date.now()}@example.com`;
await requestNewEmailChange(harness, account, startResp.ticket, newEmail, originalProof, account.password);
const newEmails = await listTestEmails(harness, {recipient: newEmail});
const newEmailData = findLastTestEmail(newEmails, 'email_change_new');
expect(newEmailData?.metadata?.code).toBeDefined();
const newCode = newEmailData!.metadata!.code!;
await createBuilder(harness, account.token)
.post('/users/@me/email-change/verify-new')
.body({
ticket: startResp.ticket,
code: newCode,
original_proof: 'invalid-proof-token',
password: account.password,
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
});
it('returns original_proof from start when require_original is false', async () => {
const account = await createTestAccount(harness);
await unclaimAccount(harness, account.userId);
const startResp = await createBuilder<EmailChangeStartResponse>(harness, account.token)
.post('/users/@me/email-change/start')
.body({})
.execute();
expect(startResp.require_original).toBe(false);
expect(startResp.original_proof).toBeDefined();
expect(startResp.original_proof!.length).toBeGreaterThan(0);
});
it('verify-original returns original_proof for verified email accounts', async () => {
const account = await createTestAccount(harness);
const startResp = await startEmailChange(harness, account, account.password);
let originalProof: string;
if (startResp.require_original) {
const emails = await listTestEmails(harness, {recipient: account.email});
const originalEmail = findLastTestEmail(emails, 'email_change_original');
expect(originalEmail?.metadata?.code).toBeDefined();
const originalCode = originalEmail!.metadata!.code!;
originalProof = await verifyOriginalEmailChange(
harness,
account,
startResp.ticket,
originalCode,
account.password,
);
expect(originalProof.length).toBeGreaterThan(0);
} else {
expect(startResp.original_proof).toBeDefined();
expect(startResp.original_proof!.length).toBeGreaterThan(0);
originalProof = startResp.original_proof!;
}
const newEmail = `integration-verify-flow-${Date.now()}@example.com`;
const newReq = await requestNewEmailChange(
harness,
account,
startResp.ticket,
newEmail,
originalProof,
account.password,
);
expect(newReq.new_email).toBe(newEmail);
});
});

View File

@@ -0,0 +1,161 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createTestAccount,
findLastTestEmail,
listTestEmails,
type TestAccount,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
interface EmailChangeStartResponse {
ticket: string;
require_original: boolean;
original_proof?: string;
original_code_expires_at?: string;
resend_available_at?: string;
}
interface EmailChangeVerifyOriginalResponse {
original_proof: string;
}
interface EmailChangeRequestNewResponse {
ticket: string;
new_email: string;
new_code_expires_at: string;
resend_available_at?: string;
}
async function startEmailChange(
harness: ApiTestHarness,
account: TestAccount,
password: string,
): Promise<EmailChangeStartResponse> {
return createBuilder<EmailChangeStartResponse>(harness, account.token)
.post('/users/@me/email-change/start')
.body({password})
.execute();
}
async function verifyOriginalEmailChange(
harness: ApiTestHarness,
account: TestAccount,
ticket: string,
code: string,
password: string,
): Promise<string> {
const resp = await createBuilder<EmailChangeVerifyOriginalResponse>(harness, account.token)
.post('/users/@me/email-change/verify-original')
.body({ticket, code, password})
.execute();
return resp.original_proof;
}
async function requestNewEmailChange(
harness: ApiTestHarness,
account: TestAccount,
ticket: string,
newEmail: string,
originalProof: string,
password: string,
): Promise<EmailChangeRequestNewResponse> {
return createBuilder<EmailChangeRequestNewResponse>(harness, account.token)
.post('/users/@me/email-change/request-new')
.body({
ticket,
new_email: newEmail,
original_proof: originalProof,
password,
})
.execute();
}
describe('Email change resend cooldown', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
vi.useFakeTimers();
await harness.reset();
await clearTestEmails(harness);
});
afterEach(() => {
vi.useRealTimers();
});
afterAll(async () => {
await harness?.shutdown();
});
it('enforces cooldown period for resending new email verification', async () => {
const account = await createTestAccount(harness);
const startResp = await startEmailChange(harness, account, account.password);
let originalProof: string;
if (startResp.require_original) {
const emails = await listTestEmails(harness, {recipient: account.email});
const originalEmail = findLastTestEmail(emails, 'email_change_original');
expect(originalEmail?.metadata?.code).toBeDefined();
const originalCode = originalEmail!.metadata!.code!;
originalProof = await verifyOriginalEmailChange(
harness,
account,
startResp.ticket,
originalCode,
account.password,
);
} else {
expect(startResp.original_proof).toBeDefined();
originalProof = startResp.original_proof!;
}
const newEmail = `cooldown-${Date.now()}@example.com`;
await requestNewEmailChange(harness, account, startResp.ticket, newEmail, originalProof, account.password);
await createBuilder(harness, account.token)
.post('/users/@me/email-change/resend-new')
.body({
ticket: startResp.ticket,
})
.expect(429)
.execute();
await vi.advanceTimersByTimeAsync(31000);
await createBuilder(harness, account.token)
.post('/users/@me/email-change/resend-new')
.body({
ticket: startResp.ticket,
})
.expect(204)
.execute();
});
});

View File

@@ -0,0 +1,246 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createTestAccount,
findLastTestEmail,
listTestEmails,
loginUser,
type TestAccount,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface EmailChangeStartResponse {
ticket: string;
require_original: boolean;
original_proof?: string;
original_code_expires_at?: string;
resend_available_at?: string;
}
interface EmailChangeVerifyOriginalResponse {
original_proof: string;
}
interface EmailChangeRequestNewResponse {
ticket: string;
new_email: string;
new_code_expires_at: string;
resend_available_at?: string;
}
interface EmailChangeVerifyNewResponse {
email_token: string;
}
interface UserPrivateResponse {
id: string;
email: string;
phone?: string | null;
username: string;
discriminator: string;
global_name: string;
bio: string;
verified: boolean;
mfa_enabled: boolean;
authenticator_types: Array<number>;
password_last_changed_at?: string;
}
interface EmailRevertResponse {
token: string;
}
async function startEmailChange(
harness: ApiTestHarness,
account: TestAccount,
password: string,
): Promise<EmailChangeStartResponse> {
return createBuilder<EmailChangeStartResponse>(harness, account.token)
.post('/users/@me/email-change/start')
.body({password})
.execute();
}
async function verifyOriginalEmailChange(
harness: ApiTestHarness,
account: TestAccount,
ticket: string,
code: string,
password: string,
): Promise<string> {
const resp = await createBuilder<EmailChangeVerifyOriginalResponse>(harness, account.token)
.post('/users/@me/email-change/verify-original')
.body({ticket, code, password})
.execute();
return resp.original_proof;
}
async function requestNewEmailChange(
harness: ApiTestHarness,
account: TestAccount,
ticket: string,
newEmail: string,
originalProof: string,
password: string,
): Promise<EmailChangeRequestNewResponse> {
return createBuilder<EmailChangeRequestNewResponse>(harness, account.token)
.post('/users/@me/email-change/request-new')
.body({
ticket,
new_email: newEmail,
original_proof: originalProof,
password,
})
.execute();
}
async function verifyNewEmailChange(
harness: ApiTestHarness,
account: TestAccount,
ticket: string,
code: string,
originalProof: string,
password: string,
): Promise<string> {
const resp = await createBuilder<EmailChangeVerifyNewResponse>(harness, account.token)
.post('/users/@me/email-change/verify-new')
.body({
ticket,
code,
original_proof: originalProof,
password,
})
.execute();
return resp.email_token;
}
function uniquePassword(): string {
return `Sup3r-${Date.now()}-Pass!`;
}
describe('Email revert flow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
});
afterAll(async () => {
await harness?.shutdown();
});
it('restores original email and clears mfa', async () => {
const account = await createTestAccount(harness);
const startResp = await startEmailChange(harness, account, account.password);
let originalProof: string;
if (startResp.require_original) {
const emails = await listTestEmails(harness, {recipient: account.email});
const originalEmail = findLastTestEmail(emails, 'email_change_original');
expect(originalEmail?.metadata?.code).toBeDefined();
const originalCode = originalEmail!.metadata!.code!;
originalProof = await verifyOriginalEmailChange(
harness,
account,
startResp.ticket,
originalCode,
account.password,
);
} else {
originalProof = startResp.original_proof!;
}
const newEmail = `integration-revert-${Date.now()}@example.com`;
await requestNewEmailChange(harness, account, startResp.ticket, newEmail, originalProof, account.password);
const newEmails = await listTestEmails(harness, {recipient: newEmail});
const newEmailData = findLastTestEmail(newEmails, 'email_change_new');
expect(newEmailData?.metadata?.code).toBeDefined();
const newCode = newEmailData!.metadata!.code!;
const emailToken = await verifyNewEmailChange(
harness,
account,
startResp.ticket,
newCode,
originalProof,
account.password,
);
await createBuilder(harness, account.token)
.patch('/users/@me')
.body({
email_token: emailToken,
password: account.password,
})
.execute();
const revertEmails = await listTestEmails(harness, {recipient: account.email});
const revertEmail = findLastTestEmail(revertEmails, 'email_change_revert');
expect(revertEmail?.metadata?.token).toBeDefined();
const revertToken = revertEmail!.metadata!.token!;
const newPassword = uniquePassword();
const revertResp = await createBuilderWithoutAuth<EmailRevertResponse>(harness)
.post('/auth/email-revert')
.body({
token: revertToken,
password: newPassword,
})
.execute();
expect(revertResp.token.length).toBeGreaterThan(0);
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
const user = await createBuilder<UserPrivateResponse>(harness, revertResp.token).get('/users/@me').execute();
expect(user.email).toBe(account.email);
expect(user.mfa_enabled).toBe(false);
expect(user.authenticator_types.length).toBe(0);
expect(user.phone).toBeNull();
expect(user.password_last_changed_at).toBeDefined();
const login = await loginUser(harness, {email: account.email, password: newPassword});
expect('mfa' in login).toBe(false);
const nonMfaLogin = login as {user_id: string; token: string};
expect(nonMfaLogin.token.length).toBeGreaterThan(0);
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.expect(400)
.execute();
});
});

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createTestAccount,
findLastTestEmail,
listTestEmails,
loginAccount,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Email verification flow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
});
afterAll(async () => {
await harness?.shutdown();
});
it('allows resending verification email and verifying email', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token).post('/auth/verify/resend').body({}).expect(204).execute();
const emails = await listTestEmails(harness, {recipient: account.email});
const verificationEmail = findLastTestEmail(emails, 'email_verification');
expect(verificationEmail?.metadata?.token).toBeDefined();
const token = verificationEmail!.metadata!.token!;
await createBuilderWithoutAuth(harness).post('/auth/verify').body({token}).expect(204).execute();
const login = await loginAccount(harness, account);
expect(login.token).toBeDefined();
});
});

View File

@@ -0,0 +1,90 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createTestAccount,
findLastTestEmail,
listTestEmails,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface SuspiciousActivityErrorResponse {
error: string;
data: {
suspicious_activity_flags: number;
};
}
describe('Email verification suspicious flags', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
});
afterAll(async () => {
await harness?.shutdown();
});
it('clears only email-related suspicious flags after verification', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/security-flags`)
.body({
suspicious_activity_flag_names: ['REQUIRE_VERIFIED_EMAIL', 'REQUIRE_VERIFIED_PHONE'],
})
.expect(200)
.execute();
const checkSuspiciousFlags = async (expected: number): Promise<void> => {
const errBody = await createBuilder<SuspiciousActivityErrorResponse>(harness, account.token)
.get('/users/@me')
.expect(403)
.execute();
expect(errBody.data.suspicious_activity_flags).toBe(expected);
};
await checkSuspiciousFlags(
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE,
);
await createBuilder(harness, account.token).post('/auth/verify/resend').body({}).expect(204).execute();
const emails = await listTestEmails(harness, {recipient: account.email});
const verificationEmail = findLastTestEmail(emails, 'email_verification');
expect(verificationEmail?.metadata?.token).toBeDefined();
const token = verificationEmail!.metadata!.token!;
await createBuilderWithoutAuth(harness).post('/auth/verify').body({token}).expect(204).execute();
await checkSuspiciousFlags(SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE);
});
});

View File

@@ -0,0 +1,246 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createUniqueEmail,
createUniqueUsername,
registerUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth IP Authorization Bypass Flags', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
const testCases = [
{
name: 'APP_STORE_REVIEWER only',
flags: ['APP_STORE_REVIEWER'],
},
{
name: 'APP_STORE_REVIEWER with STAFF',
flags: ['APP_STORE_REVIEWER', 'STAFF'],
},
{
name: 'APP_STORE_REVIEWER with CTP_MEMBER',
flags: ['APP_STORE_REVIEWER', 'CTP_MEMBER'],
},
{
name: 'APP_STORE_REVIEWER with BUG_HUNTER',
flags: ['APP_STORE_REVIEWER', 'BUG_HUNTER'],
},
];
for (const tc of testCases) {
it(`validates that users with ${tc.name} can login from any IP without authorization`, async () => {
const email = createUniqueEmail(`bypass-flags-${tc.flags.join('-')}`);
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('bypass'),
global_name: 'Bypass User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
await createBuilderWithoutAuth(harness)
.post(`/test/users/${reg.user_id}/security-flags`)
.body({
set_flags: tc.flags,
})
.execute();
const newIP = '10.50.60.70';
const loginResp = await createBuilderWithoutAuth<{
token?: string;
user_id?: string;
}>(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', newIP)
.execute();
expect(loginResp.token).toBeTruthy();
expect(loginResp.user_id).toBe(reg.user_id);
});
}
it('validates that users without bypass flags still require IP authorization for new IPs', async () => {
const email = createUniqueEmail('regular-with-flags');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('regularflags'),
global_name: 'Regular Flags User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
await createBuilderWithoutAuth(harness)
.post(`/test/users/${reg.user_id}/security-flags`)
.body({
set_flags: ['CTP_MEMBER', 'BUG_HUNTER'],
})
.execute();
const newIP = '10.160.170.180';
const ipAuthResp = await createBuilderWithoutAuth<{
ip_authorization_required?: boolean;
}>(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', newIP)
.expect(403)
.execute();
expect(ipAuthResp.ip_authorization_required).toBe(true);
});
it('validates that when a bypass flag is removed from a user, they once again require IP authorization for new IPs', async () => {
const email = createUniqueEmail('bypass-removed');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('bypassremoved'),
global_name: 'Bypass Removed User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
await createBuilderWithoutAuth(harness)
.post(`/test/users/${reg.user_id}/security-flags`)
.body({
set_flags: ['APP_STORE_REVIEWER'],
})
.execute();
const newIP = '10.100.110.120';
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', newIP)
.execute();
await createBuilderWithoutAuth(harness)
.post(`/test/users/${reg.user_id}/security-flags`)
.body({
clear_flags: ['APP_STORE_REVIEWER'],
})
.execute();
const anotherNewIP = '10.130.140.150';
const ipAuthResp = await createBuilderWithoutAuth<{
ip_authorization_required?: boolean;
}>(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', anotherNewIP)
.expect(403)
.execute();
expect(ipAuthResp.ip_authorization_required).toBe(true);
});
it('validates that when a bypass flag is added to an existing user, they can immediately login from new IPs without requiring authorization', async () => {
const email = createUniqueEmail('bypass-added-later');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('bypassadded'),
global_name: 'Bypass Added User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const newIP = '10.80.90.100';
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', newIP)
.expect(403)
.execute();
await createBuilderWithoutAuth(harness)
.post(`/test/users/${reg.user_id}/security-flags`)
.body({
set_flags: ['APP_STORE_REVIEWER'],
})
.execute();
const loginResp = await createBuilderWithoutAuth<{
token?: string;
}>(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', newIP)
.execute();
expect(loginResp.token).toBeTruthy();
});
it('verifies that regular users still require IP authorization when logging in from a new location', async () => {
const email = createUniqueEmail('regular-user-ip-check');
const password = 'a-strong-password';
await registerUser(harness, {
email,
username: createUniqueUsername('regularip'),
global_name: 'Regular IP User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const differentIP = '10.88.77.66';
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', differentIP)
.expect(403)
.execute();
});
});

View File

@@ -0,0 +1,134 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createUniqueEmail,
createUniqueUsername,
findLastTestEmail,
listTestEmails,
registerUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth IP Authorization Flow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
});
afterAll(async () => {
await harness?.shutdown();
});
it('validates the complete IP authorization flow', async () => {
const email = createUniqueEmail('ip-auth-flow');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('ipflow'),
global_name: 'IP Auth Flow User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
await clearTestEmails(harness);
const newIP = '10.20.30.40';
const ipAuthResp = await createBuilderWithoutAuth<{
code?: string;
ticket?: string;
ip_authorization_required?: boolean;
email?: string;
resend_available_in?: number;
message?: string;
}>(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', newIP)
.expect(403)
.execute();
expect(ipAuthResp.ip_authorization_required).toBe(true);
expect(ipAuthResp.ticket).toBeTruthy();
expect(ipAuthResp.email).toBe(email);
const emails = await listTestEmails(harness, {recipient: email});
const ipAuthEmail = findLastTestEmail(emails, 'ip_authorization');
expect(ipAuthEmail).not.toBeNull();
const authToken = ipAuthEmail?.metadata['token'];
expect(authToken).toBeTruthy();
await createBuilderWithoutAuth(harness)
.post('/auth/authorize-ip')
.body({token: authToken})
.header('x-forwarded-for', newIP)
.expect(204)
.execute();
const loginResp = await createBuilderWithoutAuth<{
token?: string;
user_id?: string;
}>(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', newIP)
.execute();
expect(loginResp.token).toBeTruthy();
expect(loginResp.user_id).toBe(reg.user_id);
});
it('validates that login from a known IP does not trigger IP authorization', async () => {
const email = createUniqueEmail('ip-known');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('ipknown'),
global_name: 'IP Known User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const loginResp = await createBuilderWithoutAuth<{
token?: string;
user_id?: string;
}>(harness)
.post('/auth/login')
.body({email, password})
.execute();
expect(loginResp.token).toBeTruthy();
expect(loginResp.user_id).toBe(reg.user_id);
});
});

View File

@@ -0,0 +1,123 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createUniqueEmail,
createUniqueUsername,
listTestEmails,
registerUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth IP Authorization Multiple IPs', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
});
afterAll(async () => {
await harness?.shutdown();
});
it('validates that a user can authorize multiple different IPs independently', async () => {
const email = createUniqueEmail('ip-multi');
const password = 'a-strong-password';
await registerUser(harness, {
email,
username: createUniqueUsername('multiip'),
global_name: 'Multi IP User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
await clearTestEmails(harness);
const firstNewIP = '10.11.12.13';
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', firstNewIP)
.expect(403)
.execute();
const emails1 = await listTestEmails(harness);
const ipAuthEmail1 = emails1.find((e) => e.type === 'ip_authorization' && e.to === email);
expect(ipAuthEmail1).toBeDefined();
const token1 = ipAuthEmail1!.metadata['token'];
expect(token1).toBeTruthy();
await createBuilderWithoutAuth(harness)
.post('/auth/authorize-ip')
.body({token: token1})
.header('x-forwarded-for', firstNewIP)
.expect(204)
.execute();
await clearTestEmails(harness);
const secondNewIP = '10.22.33.44';
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', secondNewIP)
.expect(403)
.execute();
const emails2 = await listTestEmails(harness);
const ipAuthEmail2 = emails2.find((e) => e.type === 'ip_authorization' && e.to === email);
expect(ipAuthEmail2).toBeDefined();
const token2 = ipAuthEmail2!.metadata['token'];
expect(token2).toBeTruthy();
await createBuilderWithoutAuth(harness)
.post('/auth/authorize-ip')
.body({token: token2})
.header('x-forwarded-for', secondNewIP)
.expect(204)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', firstNewIP)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', secondNewIP)
.execute();
});
});

View File

@@ -0,0 +1,205 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createUniqueEmail,
createUniqueUsername,
registerUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth IP Authorization Poll', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('returns not completed when authorization is pending', async () => {
const email = createUniqueEmail('ip-poll');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('poll'),
global_name: 'Poll User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const ticket = `poll-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const token = `token-${Date.now()}-${Math.random().toString(36).slice(2)}`;
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization')
.body({
ticket,
token,
user_id: reg.user_id,
email,
username: 'poll-user',
client_ip: '192.0.2.10',
user_agent: 'IntegrationTest/1.0',
client_location: 'Testland',
created_at: Date.now() - 60 * 1000,
ttl_seconds: 900,
})
.expect(200)
.execute();
const pollBefore = await createBuilderWithoutAuth<{completed: boolean}>(harness)
.get(`/auth/ip-authorization/poll?ticket=${ticket}`)
.execute();
expect(pollBefore).toMatchObject({completed: false});
});
it('returns completed with credentials after authorization', async () => {
const email = createUniqueEmail('ip-poll-complete');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('pollcomplete'),
global_name: 'Poll Complete User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const ticket = `poll-complete-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const token = `token-${Date.now()}-${Math.random().toString(36).slice(2)}`;
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization')
.body({
ticket,
token,
user_id: reg.user_id,
email,
username: 'poll-complete-user',
client_ip: '192.0.2.10',
user_agent: 'IntegrationTest/1.0',
client_location: 'Testland',
created_at: Date.now() - 60 * 1000,
ttl_seconds: 900,
})
.expect(200)
.execute();
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization/publish')
.body({
ticket,
token,
user_id: reg.user_id,
})
.expect(200)
.execute();
const pollAfter = await createBuilderWithoutAuth<{completed: boolean; token: string; user_id: string}>(harness)
.get(`/auth/ip-authorization/poll?ticket=${ticket}`)
.execute();
expect(pollAfter).toMatchObject({
completed: true,
token,
user_id: reg.user_id,
});
});
it('returns completed after authorization even when ticket cache is deleted', async () => {
const email = createUniqueEmail('ip-poll-ticket-deleted');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('polldeleted'),
global_name: 'Poll Deleted Ticket User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const ticket = `poll-deleted-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const token = `token-${Date.now()}-${Math.random().toString(36).slice(2)}`;
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization')
.body({
ticket,
token,
user_id: reg.user_id,
email,
username: 'poll-deleted-user',
client_ip: '192.0.2.10',
user_agent: 'IntegrationTest/1.0',
client_location: 'Testland',
created_at: Date.now() - 60 * 1000,
ttl_seconds: 900,
})
.expect(200)
.execute();
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization/publish')
.body({
ticket,
token,
user_id: reg.user_id,
})
.expect(200)
.execute();
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization/expire')
.body({ticket, token})
.expect(200)
.execute();
const pollAfter = await createBuilderWithoutAuth<{completed: boolean; token: string; user_id: string}>(harness)
.get(`/auth/ip-authorization/poll?ticket=${ticket}`)
.execute();
expect(pollAfter).toMatchObject({
completed: true,
token,
user_id: reg.user_id,
});
});
it('rejects poll with invalid ticket', async () => {
await createBuilderWithoutAuth(harness)
.get('/auth/ip-authorization/poll?ticket=does-not-exist')
.expect(400, 'INVALID_FORM_BODY')
.execute();
});
});

View File

@@ -0,0 +1,379 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createUniqueEmail,
createUniqueUsername,
listTestEmails,
registerUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const HTTP_TOO_MANY_REQUESTS = 429;
interface IpAuthorizationResponse {
ticket?: string;
ip_authorization_required?: boolean;
email?: string;
resend_available_in?: number;
code?: string;
message?: string;
}
interface ResendErrorResponse {
code?: string;
message?: string;
resend_available_in?: number;
retry_after?: number;
}
describe('Auth IP Authorization Resend', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
});
afterAll(async () => {
await harness?.shutdown();
});
async function triggerIpAuthorization(email: string, password: string, ip: string): Promise<IpAuthorizationResponse> {
const ipAuthResp = await createBuilderWithoutAuth<IpAuthorizationResponse>(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', ip)
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
expect(ipAuthResp.ip_authorization_required).toBe(true);
expect(ipAuthResp.ticket).toBeTruthy();
return ipAuthResp;
}
describe('Resend rate limit enforcement', () => {
it('returns 429 when resend is attempted immediately after ticket creation', async () => {
const email = createUniqueEmail('ip-resend-immediate');
const password = 'a-strong-password';
await registerUser(harness, {
email,
username: createUniqueUsername('resendimm'),
global_name: 'Resend Immediate User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const newIP = '10.200.210.220';
const ipAuthResp = await triggerIpAuthorization(email, password, newIP);
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
.post('/auth/ip-authorization/resend')
.body({ticket: ipAuthResp.ticket})
.header('x-forwarded-for', newIP)
.expect(HTTP_TOO_MANY_REQUESTS)
.execute();
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_COOLDOWN);
});
it('returns resend_available_in when rate limited', async () => {
const email = createUniqueEmail('ip-resend-cooldown');
const password = 'a-strong-password';
await registerUser(harness, {
email,
username: createUniqueUsername('resendcool'),
global_name: 'Resend Cooldown User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const newIP = '10.230.240.250';
const ipAuthResp = await triggerIpAuthorization(email, password, newIP);
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
.post('/auth/ip-authorization/resend')
.body({ticket: ipAuthResp.ticket})
.header('x-forwarded-for', newIP)
.expect(HTTP_TOO_MANY_REQUESTS)
.execute();
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_COOLDOWN);
expect(typeof errorResp.resend_available_in).toBe('number');
expect(errorResp.resend_available_in).toBeGreaterThan(0);
});
});
describe('Multiple resend attempts handling', () => {
it('returns 429 for multiple consecutive resend attempts', async () => {
const email = createUniqueEmail('ip-multi-resend');
const password = 'a-strong-password';
await registerUser(harness, {
email,
username: createUniqueUsername('multiresend'),
global_name: 'Multi Resend User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const newIP = '10.30.31.32';
const ipAuthResp = await triggerIpAuthorization(email, password, newIP);
for (let i = 0; i < 3; i++) {
await createBuilderWithoutAuth(harness)
.post('/auth/ip-authorization/resend')
.body({ticket: ipAuthResp.ticket})
.header('x-forwarded-for', newIP)
.expect(HTTP_TOO_MANY_REQUESTS)
.execute();
}
});
});
describe('Resend with already-used ticket', () => {
it('returns 429 when resend flag is already set on the ticket', async () => {
const email = createUniqueEmail('ip-resend-used');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('resendused'),
global_name: 'Resend Used User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const ticket = `ticket-used-${Date.now()}`;
const token = `token-used-${Date.now()}`;
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization')
.body({
ticket,
token,
user_id: reg.user_id,
email,
username: 'resend-used-user',
client_ip: '203.0.113.10',
user_agent: 'IntegrationTest/1.0',
client_location: 'Testland',
resend_used: true,
created_at: Date.now() - 2 * 60 * 1000,
ttl_seconds: 900,
})
.execute();
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
.post('/auth/ip-authorization/resend')
.body({ticket})
.expect(HTTP_TOO_MANY_REQUESTS)
.execute();
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED);
});
it('returns 429 after IP has already been authorized', async () => {
const email = createUniqueEmail('ip-resend-after-auth');
const password = 'a-strong-password';
await registerUser(harness, {
email,
username: createUniqueUsername('resendafterauth'),
global_name: 'Resend After Auth User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
await clearTestEmails(harness);
const newIP = '10.26.27.28';
const ipAuthResp = await triggerIpAuthorization(email, password, newIP);
const emails = await listTestEmails(harness);
const ipAuthEmail = emails.find((e) => e.type === 'ip_authorization' && e.to === email);
expect(ipAuthEmail).toBeDefined();
const authToken = ipAuthEmail!.metadata['token'];
expect(authToken).toBeTruthy();
await createBuilderWithoutAuth(harness)
.post('/auth/authorize-ip')
.body({token: authToken})
.header('x-forwarded-for', newIP)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/ip-authorization/resend')
.body({ticket: ipAuthResp.ticket})
.header('x-forwarded-for', newIP)
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});
describe('Resend with empty ticket', () => {
it('returns 400 when ticket is empty string', async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/ip-authorization/resend')
.body({ticket: ''})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});
describe('Resend with invalid ticket', () => {
it('returns 400 when ticket does not exist', async () => {
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
.post('/auth/ip-authorization/resend')
.body({ticket: 'invalid-ticket-xyz-12345'})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(errorResp.code).toBeDefined();
expect(errorResp.message).toBeDefined();
});
it('returns 400 when ticket has expired', async () => {
const email = createUniqueEmail('ip-ticket-expired');
const password = 'a-strong-password';
await registerUser(harness, {
email,
username: createUniqueUsername('ticketexp'),
global_name: 'Ticket Expired User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const newIP = '10.70.80.90';
const ipAuthResp = await triggerIpAuthorization(email, password, newIP);
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization/expire')
.body({ticket: ipAuthResp.ticket})
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/ip-authorization/resend')
.body({ticket: ipAuthResp.ticket})
.header('x-forwarded-for', newIP)
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});
describe('Resend response format validation', () => {
it('returns proper error structure with code and message on rate limit', async () => {
const email = createUniqueEmail('ip-resend-format');
const password = 'a-strong-password';
await registerUser(harness, {
email,
username: createUniqueUsername('resendformat'),
global_name: 'Resend Format User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const newIP = '10.231.241.251';
const ipAuthResp = await triggerIpAuthorization(email, password, newIP);
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
.post('/auth/ip-authorization/resend')
.body({ticket: ipAuthResp.ticket})
.header('x-forwarded-for', newIP)
.expect(HTTP_TOO_MANY_REQUESTS)
.execute();
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_COOLDOWN);
expect(typeof errorResp.message).toBe('string');
expect(errorResp.message!.length).toBeGreaterThan(0);
});
it('returns proper error structure when ticket is invalid', async () => {
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
.post('/auth/ip-authorization/resend')
.body({ticket: 'nonexistent-ticket-abc'})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(typeof errorResp.code).toBe('string');
expect(typeof errorResp.message).toBe('string');
});
it('returns proper error structure when resend limit exceeded', async () => {
const email = createUniqueEmail('ip-limit-format');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('limitformat'),
global_name: 'Limit Format User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const ticket = `ticket-limit-${Date.now()}`;
const token = `token-limit-${Date.now()}`;
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization')
.body({
ticket,
token,
user_id: reg.user_id,
email,
username: 'limit-format-user',
client_ip: '203.0.113.20',
user_agent: 'IntegrationTest/1.0',
client_location: 'Testland',
resend_used: true,
created_at: Date.now() - 2 * 60 * 1000,
ttl_seconds: 900,
})
.execute();
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
.post('/auth/ip-authorization/resend')
.body({ticket})
.expect(HTTP_TOO_MANY_REQUESTS)
.execute();
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED);
expect(typeof errorResp.message).toBe('string');
});
});
});

View File

@@ -0,0 +1,145 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createUniqueEmail,
createUniqueUsername,
registerUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth IP Authorization Poll', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('returns not completed when authorization is pending', async () => {
const email = createUniqueEmail('ip-poll');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('poll'),
global_name: 'Poll User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const ticket = `poll-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const token = `token-${Date.now()}-${Math.random().toString(36).slice(2)}`;
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization')
.body({
ticket,
token,
user_id: reg.user_id,
email,
username: 'poll-user',
client_ip: '192.0.2.10',
user_agent: 'IntegrationTest/1.0',
client_location: 'Testland',
created_at: Date.now() - 60 * 1000,
ttl_seconds: 900,
})
.expect(200)
.execute();
const pollBefore = await createBuilderWithoutAuth<{completed: boolean}>(harness)
.get(`/auth/ip-authorization/poll?ticket=${ticket}`)
.execute();
expect(pollBefore).toMatchObject({completed: false});
});
it('returns completed with credentials after authorization', async () => {
const email = createUniqueEmail('ip-poll-complete');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('pollcomplete'),
global_name: 'Poll Complete User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const ticket = `poll-complete-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const token = `token-${Date.now()}-${Math.random().toString(36).slice(2)}`;
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization')
.body({
ticket,
token,
user_id: reg.user_id,
email,
username: 'poll-complete-user',
client_ip: '192.0.2.10',
user_agent: 'IntegrationTest/1.0',
client_location: 'Testland',
created_at: Date.now() - 60 * 1000,
ttl_seconds: 900,
})
.expect(200)
.execute();
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization/publish')
.body({
ticket,
token,
user_id: reg.user_id,
})
.expect(200)
.execute();
const pollAfter = await createBuilderWithoutAuth<{completed: boolean; token: string; user_id: string}>(harness)
.get(`/auth/ip-authorization/poll?ticket=${ticket}`)
.execute();
expect(pollAfter).toMatchObject({
completed: true,
token,
user_id: reg.user_id,
});
});
it('rejects poll with invalid ticket', async () => {
await createBuilderWithoutAuth(harness)
.get('/auth/ip-authorization/poll?ticket=does-not-exist')
.expect(400, 'INVALID_FORM_BODY')
.execute();
});
});

View File

@@ -0,0 +1,125 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createUniqueEmail,
createUniqueUsername,
registerUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth IP Authorization Ticket', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('validates the new IP authorization ticket error response and the resend rate limit behavior', async () => {
const email = createUniqueEmail('ip-ticket');
const password = 'a-strong-password';
await registerUser(harness, {
email,
username: createUniqueUsername('ticket'),
global_name: 'Ticket User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const otherIP = '10.55.44.33';
const body = await createBuilderWithoutAuth<{
code?: string;
ticket?: string;
ip_authorization_required?: boolean;
email?: string;
resend_available_in?: number;
message?: string;
}>(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', otherIP)
.expect(403)
.execute();
expect(body.ip_authorization_required).toBe(true);
expect(body.ticket).toBeTruthy();
expect(body.email).toBeTruthy();
await createBuilderWithoutAuth(harness)
.post('/auth/ip-authorization/resend')
.body({ticket: body.ticket})
.header('x-forwarded-for', otherIP)
.expect(429)
.execute();
});
it('validates that the ticket returned in the login response expires and cannot be used for resending after expiration', async () => {
const email = createUniqueEmail('ip-ticket-expire');
const password = 'a-strong-password';
await registerUser(harness, {
email,
username: createUniqueUsername('ticketexpire'),
global_name: 'Ticket Expire User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const newIP = '10.70.80.90';
const ipAuthResp = await createBuilderWithoutAuth<{
ticket?: string;
}>(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', newIP)
.expect(403)
.execute();
expect(ipAuthResp.ticket).toBeTruthy();
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization/expire')
.body({ticket: ipAuthResp.ticket})
.expect(200)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/ip-authorization/resend')
.body({ticket: ipAuthResp.ticket})
.header('x-forwarded-for', newIP)
.expect(400, 'INVALID_FORM_BODY')
.execute();
});
});

View File

@@ -0,0 +1,193 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createUniqueEmail,
createUniqueUsername,
listTestEmails,
registerUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth IP Authorization Token Validation', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
});
afterAll(async () => {
await harness?.shutdown();
});
it('validates that attempting to authorize an IP with an invalid token fails with appropriate error', async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/authorize-ip')
.body({token: 'invalid-token-12345'})
.expect(400, 'INVALID_FORM_BODY')
.execute();
});
it('validates that an IP authorization token can only be used once and becomes invalid after successful use', async () => {
const email = createUniqueEmail('ip-single-use');
const password = 'a-strong-password';
await registerUser(harness, {
email,
username: createUniqueUsername('singleuse'),
global_name: 'Single Use User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
await clearTestEmails(harness);
const newIP = '10.100.101.102';
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', newIP)
.expect(403)
.execute();
const emails = await listTestEmails(harness);
const ipAuthEmail = emails.find((e) => e.type === 'ip_authorization' && e.to === email);
expect(ipAuthEmail).toBeDefined();
const authToken = ipAuthEmail!.metadata['token'];
expect(authToken).toBeTruthy();
await createBuilderWithoutAuth(harness)
.post('/auth/authorize-ip')
.body({token: authToken})
.header('x-forwarded-for', newIP)
.expect(204)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/authorize-ip')
.body({token: authToken})
.header('x-forwarded-for', newIP)
.expect(400, 'INVALID_FORM_BODY')
.execute();
});
it('validates that a token generated for one IP cannot be used to authorize a different IP', async () => {
const email = createUniqueEmail('ip-wrong-ip');
const password = 'a-strong-password';
await registerUser(harness, {
email,
username: createUniqueUsername('wrongip'),
global_name: 'Wrong IP User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
await clearTestEmails(harness);
const firstNewIP = '10.110.120.130';
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', firstNewIP)
.expect(403)
.execute();
const emails = await listTestEmails(harness);
const ipAuthEmail = emails.find((e) => e.type === 'ip_authorization' && e.to === email);
expect(ipAuthEmail).toBeDefined();
const authToken = ipAuthEmail!.metadata['token'];
expect(authToken).toBeTruthy();
const secondNewIP = '10.140.150.160';
await createBuilderWithoutAuth(harness)
.post('/auth/authorize-ip')
.body({token: authToken})
.header('x-forwarded-for', secondNewIP)
.expect(204)
.execute();
});
it('validates that IP authorization tokens expire after a reasonable time period and cannot be used after expiration', async () => {
const email = createUniqueEmail('ip-expire');
const password = 'a-strong-password';
await registerUser(harness, {
email,
username: createUniqueUsername('expire'),
global_name: 'Expire User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
await clearTestEmails(harness);
const newIP = '10.40.50.60';
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', newIP)
.expect(403)
.execute();
const emails = await listTestEmails(harness);
const ipAuthEmail = emails.find((e) => e.type === 'ip_authorization' && e.to === email);
expect(ipAuthEmail).toBeDefined();
const authToken = ipAuthEmail!.metadata['token'];
expect(authToken).toBeTruthy();
await createBuilderWithoutAuth(harness)
.post('/test/auth/ip-authorization/expire')
.body({token: authToken})
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/authorize-ip')
.body({token: authToken})
.header('x-forwarded-for', newIP)
.expect(400, 'INVALID_FORM_BODY')
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email, password})
.header('x-forwarded-for', newIP)
.expect(403)
.execute();
});
});

View File

@@ -0,0 +1,173 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createFakeAuthToken,
createTestAccount,
loginAccount,
type TestAccount,
type UserMeResponse,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth login and sessions', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('rejects invalid login credentials', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email: account.email, password: 'WrongPassword123!'})
.expect(400)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email: 'nonexistent@example.com', password: 'SomePassword123!'})
.expect(400)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email: 'not-an-email', password: 'SomePassword123!'})
.expect(400)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email: 'test@example.com', password: ''})
.expect(400)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email: '', password: 'SomePassword123!'})
.expect(400)
.execute();
});
it('enforces token validity for /users/@me', async () => {
const malformedTokens = ['', 'not-a-token', 'Bearer invalid', 'invalid.token.format', 'ey123.ey456.sig789'];
for (const token of malformedTokens) {
await createBuilder(harness, token).get('/users/@me').expect(401).execute();
}
const fakeToken = createFakeAuthToken();
await createBuilder(harness, fakeToken).get('/users/@me').expect(401).execute();
const account = await createTestAccount(harness);
const meOkPayload = await createBuilder<UserMeResponse & {id?: string}>(harness, account.token)
.get('/users/@me')
.execute();
expect((meOkPayload as {id: string}).id).toBe(account.userId);
await createBuilder(harness, account.token).post('/auth/logout').expect(204).execute();
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
const fresh = await createTestAccount(harness);
const tamperedToken = `${fresh.token.slice(0, Math.max(0, fresh.token.length - 10))}0123456789`;
await createBuilder(harness, tamperedToken).get('/users/@me').expect(401).execute();
await createBuilderWithoutAuth(harness).get('/users/@me').expect(401).execute();
});
it('supports session listing and logout flows', async () => {
let account = await createTestAccount(harness);
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
.get('/auth/sessions')
.execute();
expect(sessions.length).toBeGreaterThan(0);
expect(sessions[0]?.id_hash?.length).toBeGreaterThan(0);
await createBuilder(harness, account.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
password: account.password,
})
.expect(204)
.execute();
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
account = await loginAccount(harness, account);
await createBuilder(harness, account.token).post('/auth/logout').expect(204).execute();
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
});
it('treats /auth/sessions/logout as idempotent and removes targeted sessions', async () => {
let account: TestAccount = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: ['nonexistent-hash-1', 'nonexistent-hash-2'],
password: account.password,
})
.expect(204)
.execute();
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
.get('/auth/sessions')
.execute();
expect(sessions.length).toBeGreaterThan(0);
const target = sessions[0]!.id_hash;
await createBuilder(harness, account.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [target],
password: account.password,
})
.expect(204)
.execute();
account = await loginAccount(harness, account);
const sessionsAfter = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
.get('/auth/sessions')
.execute();
expect(sessionsAfter.some((sess) => sess.id_hash === target)).toBe(false);
});
});

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface DataExistsResponse {
user_exists: boolean;
has_self_deleted_flag: boolean;
has_deleted_flag: boolean;
flags: string;
}
describe('Auth login disabled flag recovery', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('covers auto-clearing of DISABLED flag on login (when not temp-banned)', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/security-flags`)
.body({
set_flags: ['DISABLED'],
})
.expect(200)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.expect(200)
.execute();
const payload = await createBuilder<DataExistsResponse>(harness, account.token)
.get(`/test/users/${account.userId}/data-exists`)
.execute();
expect(payload.has_deleted_flag).toBe(false);
expect(payload.has_self_deleted_flag).toBe(false);
const flags = payload.flags ? BigInt(payload.flags) : 0n;
expect(flags & UserFlags.DISABLED).toBe(0n);
});
});

View File

@@ -0,0 +1,96 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
describe('Auth login invalid credentials', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('wrong password returns bad request with field errors', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({
email: account.email,
password: 'WrongPassword123!',
})
.expect(400)
.execute();
});
it('non-existent email returns bad request with field errors', async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({
email: 'nonexistent@example.com',
password: 'SomePassword123!',
})
.expect(400)
.execute();
});
it('invalid email format returns bad request', async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({
email: 'not-an-email',
password: 'SomePassword123!',
})
.expect(400)
.execute();
});
it('empty password returns bad request or unauthorized', async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({
email: 'test@example.com',
password: '',
})
.expect(400)
.execute();
});
it('empty email returns bad request or unauthorized', async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({
email: '',
password: 'SomePassword123!',
})
.expect(400)
.execute();
});
});

View File

@@ -0,0 +1,101 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount, loginAccount, loginUser} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth login with invite code auto-join', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('auto-joins a guild when logging in with invite_code', async () => {
let owner = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${owner.userId}/acls`)
.body({
acls: ['*'],
})
.expect(200)
.execute();
owner = await loginAccount(harness, owner);
const guildName = `InviteGuild-${Date.now()}`;
const guild = await createBuilder<GuildResponse>(harness, owner.token)
.post('/guilds')
.body({
name: guildName,
})
.execute();
if (!guild.system_channel_id) {
throw new Error('Guild creation did not return a system_channel_id');
}
const invite = await createBuilder<{code: string}>(harness, owner.token)
.post(`/channels/${guild.system_channel_id}/invites`)
.body({
max_uses: 0,
max_age: 0,
unique: false,
temporary: false,
})
.execute();
const member = await createTestAccount(harness);
const login = await loginUser(harness, {
email: member.email,
password: member.password,
invite_code: invite.code,
});
expect('mfa' in login).toBe(false);
if (!('mfa' in login)) {
const nonMfaLogin = login as {user_id: string; token: string};
expect(nonMfaLogin.token).toBeTruthy();
}
if (!('mfa' in login)) {
const nonMfaLogin = login as {user_id: string; token: string};
const guilds = await createBuilder<Array<GuildResponse>>(harness, nonMfaLogin.token)
.get('/users/@me/guilds')
.execute();
const foundGuild = guilds.find((g) => g.id === guild.id);
expect(foundGuild).toBeDefined();
expect(foundGuild?.id).toBe(guild.id);
}
});
});

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount, loginUser} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth login with invalid invite code', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('login with invalid invite code succeeds but does not add guilds', async () => {
const member = await createTestAccount(harness);
const login = await loginUser(harness, {
email: member.email,
password: member.password,
invite_code: 'invalidcode123',
});
expect('mfa' in login).toBe(false);
if (!('mfa' in login)) {
const nonMfaLogin = login as {user_id: string; token: string};
expect(nonMfaLogin.token).toBeTruthy();
}
if (!('mfa' in login)) {
const nonMfaLogin = login as {user_id: string; token: string};
const guilds = await createBuilder<Array<GuildResponse>>(harness, nonMfaLogin.token)
.get('/users/@me/guilds')
.execute();
expect(guilds.length).toBe(0);
}
});
});

View File

@@ -0,0 +1,77 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface DataExistsResponse {
user_exists: boolean;
pending_deletion_at: string | null;
has_self_deleted_flag: boolean;
has_deleted_flag: boolean;
}
describe('Auth login self deleted recovery', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('covers auto-recovery for self-deleted accounts with pending deletion', async () => {
const account = await createTestAccount(harness);
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/set-pending-deletion`)
.body({
pending_deletion_at: oneHourAgo.toISOString(),
set_self_deleted_flag: true,
})
.expect(200)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.expect(200)
.execute();
const payload = await createBuilder<DataExistsResponse>(harness, account.token)
.get(`/test/users/${account.userId}/data-exists`)
.execute();
expect(payload.pending_deletion_at).toBeNull();
expect(payload.has_self_deleted_flag).toBe(false);
expect(payload.has_deleted_flag).toBe(false);
});
});

View File

@@ -0,0 +1,563 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createTestAccount,
createTotpSecret,
generateTotpCode,
type TestAccount,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createAuthenticationResponse,
createRegistrationResponse,
createWebAuthnDevice,
type WebAuthnAuthenticationOptions,
type WebAuthnDevice,
type WebAuthnRegistrationOptions,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
interface BackupCodesResponse {
backup_codes: Array<{code: string}>;
}
interface LoginMfaResponse {
mfa: true;
ticket: string;
sms: boolean;
totp: boolean;
webauthn: boolean;
}
interface MfaMethodsResponse {
totp: boolean;
sms: boolean;
webauthn: boolean;
has_mfa: boolean;
}
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
expect(login.mfa).toBe(true);
const mfaLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
code: generateTotpCode(secret),
ticket: login.ticket,
})
.execute();
return {...account, token: mfaLogin.token};
}
async function setupWebAuthnOnlyUser(
harness: ApiTestHarness,
account: TestAccount,
): Promise<{account: TestAccount; device: WebAuthnDevice}> {
const device = createWebAuthnDevice();
const secret = createTotpSecret();
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
expect(login.mfa).toBe(true);
const mfaLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
code: backupCodes.backup_codes[0]!.code,
ticket: login.ticket,
})
.execute();
const updatedAccount = {...account, token: mfaLogin.token};
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, updatedAccount.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[1]!.code,
})
.execute();
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
await createBuilder(harness, updatedAccount.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: registrationOptions.challenge,
name: 'Test Passkey',
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[2]!.code,
})
.expect(204)
.execute();
await createBuilder(harness, updatedAccount.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCodes.backup_codes[3]!.code,
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[4]!.code,
})
.expect(204)
.execute();
const discoverableOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null)
.execute();
const discoverableAssertion = createAuthenticationResponse(device, discoverableOptions);
const passkeyLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/webauthn/authenticate')
.body({
response: discoverableAssertion,
challenge: discoverableOptions.challenge,
})
.execute();
return {account: {...updatedAccount, token: passkeyLogin.token}, device};
}
describe('MFA Consistency Tests', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
describe('WebAuthn sudo verification flow', () => {
test('WebAuthn user can complete sudo verification with passkey', async () => {
const account = await createTestAccount(harness);
const {account: webauthnAccount, device} = await setupWebAuthnOnlyUser(harness, account);
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, webauthnAccount.token)
.post('/users/@me/sudo/webauthn/authentication-options')
.body(null)
.execute();
const sudoAssertion = createAuthenticationResponse(device, sudoOptions);
await createBuilder(harness, webauthnAccount.token)
.post('/users/@me/disable')
.body({
mfa_method: 'webauthn',
webauthn_response: sudoAssertion,
webauthn_challenge: sudoOptions.challenge,
})
.expect(204)
.execute();
});
test('WebAuthn-only user cannot use password for sudo verification', async () => {
const account = await createTestAccount(harness);
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account);
const errorResp = await createBuilder<{code: string}>(harness, webauthnAccount.token)
.post('/users/@me/disable')
.body({
password: account.password,
})
.expect(403)
.execute();
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
});
test('WebAuthn sudo verification returns proper MFA methods', async () => {
const account = await createTestAccount(harness);
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account);
const mfaMethods = await createBuilder<MfaMethodsResponse>(harness, webauthnAccount.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(mfaMethods.has_mfa).toBe(true);
expect(mfaMethods.webauthn).toBe(true);
expect(mfaMethods.totp).toBe(false);
});
});
describe('Password-only sudo flow for non-MFA users', () => {
test('Non-MFA user can use password for sudo verification', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/disable')
.body({
password: account.password,
})
.expect(204)
.execute();
});
test('Non-MFA user cannot perform sudo operation without password', async () => {
const account = await createTestAccount(harness);
const errorResp = await createBuilder<{code: string}>(harness, account.token)
.post('/users/@me/disable')
.body({})
.expect(403)
.execute();
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
});
test('Non-MFA user rejected with wrong password', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/disable')
.body({
password: 'wrong-password-123!',
})
.expect(400)
.execute();
});
test('Non-MFA user MFA methods shows has_mfa as false', async () => {
const account = await createTestAccount(harness);
const mfaMethods = await createBuilder<MfaMethodsResponse>(harness, account.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(mfaMethods.has_mfa).toBe(false);
expect(mfaMethods.totp).toBe(false);
expect(mfaMethods.sms).toBe(false);
expect(mfaMethods.webauthn).toBe(false);
});
});
describe('TOTP sudo verification flow', () => {
test('TOTP user can complete sudo verification with TOTP code', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const loggedIn = await loginWithTotp(harness, account, secret);
await createBuilder(harness, loggedIn.token)
.post('/users/@me/disable')
.body({
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
});
test('TOTP user can use backup code for sudo verification', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const loggedIn = await loginWithTotp(harness, account, secret);
await createBuilder(harness, loggedIn.token)
.post('/users/@me/disable')
.body({
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[0]!.code,
})
.expect(204)
.execute();
});
test('TOTP user cannot use password for sudo verification', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const loggedIn = await loginWithTotp(harness, account, secret);
await createBuilder(harness, loggedIn.token)
.post('/users/@me/disable')
.body({
password: account.password,
})
.expect(403)
.execute();
});
test('TOTP user rejected with wrong TOTP code', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const loggedIn = await loginWithTotp(harness, account, secret);
await createBuilder(harness, loggedIn.token)
.post('/users/@me/disable')
.body({
mfa_method: 'totp',
mfa_code: '000000',
})
.expect(400)
.execute();
});
test('TOTP user MFA methods shows totp as enabled', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const loggedIn = await loginWithTotp(harness, account, secret);
const mfaMethods = await createBuilder<MfaMethodsResponse>(harness, loggedIn.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(mfaMethods.has_mfa).toBe(true);
expect(mfaMethods.totp).toBe(true);
});
});
describe('MFA requirement propagates to sensitive operations', () => {
test('Account disable requires sudo for all users', async () => {
const account = await createTestAccount(harness);
const errorResp = await createBuilder<{code: string}>(harness, account.token)
.post('/users/@me/disable')
.body({})
.expect(403)
.execute();
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
});
test('Account delete requires sudo for all users', async () => {
const account = await createTestAccount(harness);
const errorResp = await createBuilder<{code: string}>(harness, account.token)
.post('/users/@me/delete')
.body({})
.expect(403)
.execute();
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
});
test('Session logout requires sudo for non-MFA user', async () => {
const account = await createTestAccount(harness);
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
.get('/auth/sessions')
.execute();
await createBuilder(harness, account.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
})
.expect(403)
.execute();
await createBuilder(harness, account.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
password: account.password,
})
.expect(204)
.execute();
});
test('Session logout requires MFA for TOTP user', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const loggedIn = await loginWithTotp(harness, account, secret);
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, loggedIn.token)
.get('/auth/sessions')
.execute();
await createBuilder(harness, loggedIn.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
password: account.password,
})
.expect(403)
.execute();
await createBuilder(harness, loggedIn.token)
.post('/auth/sessions/logout')
.body({
session_id_hashes: [sessions[0]!.id_hash],
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
});
test('TOTP disable requires MFA for sudo verification', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const loggedIn = await loginWithTotp(harness, account, secret);
await createBuilder(harness, loggedIn.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCodes.backup_codes[0]!.code,
})
.expect(403)
.execute();
await createBuilder(harness, loggedIn.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCodes.backup_codes[0]!.code,
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
});
test('MFA method consistency between login and sudo', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
expect(login.mfa).toBe(true);
expect(login.totp).toBe(true);
const mfaLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
code: generateTotpCode(secret),
ticket: login.ticket,
})
.execute();
const mfaMethods = await createBuilder<MfaMethodsResponse>(harness, mfaLogin.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(mfaMethods.totp).toBe(login.totp);
expect(mfaMethods.has_mfa).toBe(true);
});
});
});

View File

@@ -0,0 +1,186 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
type BackupCodesResponse,
createAuthHarness,
createTestAccount,
createTotpSecret,
type PhoneVerifyResponse,
totpCodeNext,
totpCodeNow,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth MFA endpoints', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('handles TOTP enable, backup codes, login, and disable', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const enableData = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: totpCodeNow(secret), password: account.password})
.execute();
expect(enableData.backup_codes.length).toBeGreaterThan(0);
const fetched = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/backup-codes')
.body({
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
regenerate: false,
})
.execute();
expect(fetched.backup_codes.length).toBe(enableData.backup_codes.length);
const login = await createBuilderWithoutAuth<{mfa: boolean; ticket: string; totp: boolean}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect(login.mfa).toBe(true);
expect(login.ticket).toBeDefined();
expect(login.totp).toBe(true);
const backupResp = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
code: enableData.backup_codes[0]!.code,
ticket: login.ticket,
})
.execute();
expect(backupResp.token).toBeDefined();
account.token = backupResp.token;
const regenerated = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/backup-codes')
.body({
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
regenerate: true,
})
.execute();
expect(regenerated.backup_codes.length).toBeGreaterThan(0);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: regenerated.backup_codes[0]!.code,
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
});
it('handles SMS MFA enable, login, and disable', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: totpCodeNow(secret), password: account.password})
.execute();
const phone = `+1555${String(Date.now() % 10000000).padStart(7, '0')}`;
await createBuilder(harness, account.token)
.post('/users/@me/phone/send-verification')
.body({phone})
.expect(204)
.execute();
const phoneVerify = await createBuilder<PhoneVerifyResponse>(harness, account.token)
.post('/users/@me/phone/verify')
.body({phone, code: '123456'})
.execute();
expect(phoneVerify.phone_token).toBeDefined();
await createBuilder(harness, account.token)
.post('/users/@me/phone')
.body({
phone_token: phoneVerify.phone_token,
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/sms/enable')
.body({
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
const login = await createBuilderWithoutAuth<{mfa: boolean; sms: boolean; ticket: string}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect(login.mfa).toBe(true);
expect(login.sms).toBe(true);
expect(login.ticket).toBeDefined();
await createBuilderWithoutAuth(harness)
.post('/auth/login/mfa/sms/send')
.body({ticket: login.ticket})
.expect(204)
.execute();
const smsResp = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/sms')
.body({ticket: login.ticket, code: '123456'})
.execute();
account.token = smsResp.token;
await createBuilder(harness, account.token)
.post('/users/@me/mfa/sms/disable')
.body({
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.delete('/users/@me/phone')
.body({
mfa_method: 'totp',
mfa_code: totpCodeNext(secret),
})
.expect(204)
.execute();
});
});

View File

@@ -0,0 +1,127 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createTestAccount,
createTotpSecret,
type PhoneVerifyResponse,
type TestAccount,
totpCodeNow,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
async function attachPhone(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<void> {
const phone = `+1555${String(Date.now() % 10000000).padStart(7, '0')}`;
await createBuilder(harness, account.token)
.post('/users/@me/phone/send-verification')
.body({phone})
.expect(204)
.execute();
const phoneVerify = await createBuilder<PhoneVerifyResponse>(harness, account.token)
.post('/users/@me/phone/verify')
.body({phone, code: '123456'})
.execute();
expect(phoneVerify.phone_token).toBeDefined();
await createBuilder(harness, account.token)
.post('/users/@me/phone')
.body({
phone_token: phoneVerify.phone_token,
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
}
describe('Auth MFA SMS enable/disable', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('enables and disables SMS MFA', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: totpCodeNow(secret), password: account.password})
.execute();
await attachPhone(harness, account, secret);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/sms/enable')
.body({
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
const login = await createBuilderWithoutAuth<{mfa: boolean; sms: boolean; ticket: string}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect(login.mfa).toBe(true);
expect(login.sms).toBe(true);
expect(login.ticket).toBeDefined();
const mfaResp = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
code: totpCodeNow(secret),
ticket: login.ticket,
})
.execute();
account.token = mfaResp.token;
await createBuilder(harness, account.token)
.post('/users/@me/mfa/sms/disable')
.body({
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
const afterLogin = await createBuilderWithoutAuth<{sms: boolean; totp: boolean; mfa: boolean}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect(afterLogin.sms).toBe(false);
expect(afterLogin.totp).toBe(true);
expect(afterLogin.mfa).toBe(true);
});
});

View File

@@ -0,0 +1,128 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createTestAccount,
createTotpSecret,
type PhoneVerifyResponse,
type TestAccount,
totpCodeNow,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
async function setupPhoneAndSms(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<void> {
const phone = `+1555${String(Date.now() % 10000000).padStart(7, '0')}`;
await createBuilder(harness, account.token)
.post('/users/@me/phone/send-verification')
.body({phone})
.expect(204)
.execute();
const phoneVerify = await createBuilder<PhoneVerifyResponse>(harness, account.token)
.post('/users/@me/phone/verify')
.body({phone, code: '123456'})
.execute();
expect(phoneVerify.phone_token).toBeDefined();
await createBuilder(harness, account.token)
.post('/users/@me/phone')
.body({
phone_token: phoneVerify.phone_token,
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/sms/enable')
.body({
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
}
describe('Auth MFA SMS login flow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('completes SMS MFA login flow', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: totpCodeNow(secret), password: account.password})
.execute();
await setupPhoneAndSms(harness, account, secret);
const login = await createBuilderWithoutAuth<{mfa: boolean; sms: boolean; ticket: string}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect(login.mfa).toBe(true);
expect(login.sms).toBe(true);
expect(login.ticket).toBeDefined();
await createBuilderWithoutAuth(harness)
.post('/auth/login/mfa/sms/send')
.body({ticket: login.ticket})
.expect(204)
.execute();
const mfaResp = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/sms')
.body({ticket: login.ticket, code: '123456'})
.execute();
expect(mfaResp.token).toBeDefined();
const me = await createBuilder<{id: string; email: string}>(harness, mfaResp.token).get('/users/@me').execute();
expect(me.id).toBe(account.userId);
expect(me.email).toBe(account.email);
const secondLogin = await createBuilderWithoutAuth<{totp: boolean; ticket: string}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect(secondLogin.totp).toBe(true);
await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({code: totpCodeNow(secret), ticket: secondLogin.ticket})
.execute();
});
});

View File

@@ -0,0 +1,110 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createTestAccount,
createTotpSecret,
seedMfaTicket,
totpCodeNow,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS, TEST_LIMITS, TEST_TIMEOUTS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
describe('Auth MFA ticket expiry and reuse', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
afterEach(() => {
vi.useRealTimers();
});
it('rejects expired tickets and prevents ticket reuse', async () => {
vi.useFakeTimers();
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: totpCodeNow(secret),
password: account.password,
})
.expect(HTTP_STATUS.OK)
.execute();
const expiredTicket = `expired-${Date.now()}`;
await seedMfaTicket(harness, expiredTicket, account.userId, TEST_LIMITS.MFA_TICKET_SHORT_TTL);
vi.advanceTimersByTime(TEST_TIMEOUTS.TICKET_EXPIRY_GRACE);
const expiredJson = await createBuilderWithoutAuth<{
code: string;
errors?: {code?: {errors?: Array<{message: string}>}};
}>(harness)
.post('/auth/login/mfa/totp')
.body({
ticket: expiredTicket,
code: totpCodeNow(secret),
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(expiredJson.code).toBe('INVALID_FORM_BODY');
const validTicket = `valid-${Date.now()}`;
await seedMfaTicket(harness, validTicket, account.userId, TEST_LIMITS.MFA_TICKET_LONG_TTL);
await createBuilderWithoutAuth(harness)
.post('/auth/login/mfa/totp')
.body({
ticket: validTicket,
code: totpCodeNow(secret),
})
.expect(HTTP_STATUS.OK)
.execute();
const reuseJson = await createBuilderWithoutAuth<{
code: string;
errors?: {code?: {errors?: Array<{message: string}>}};
}>(harness)
.post('/auth/login/mfa/totp')
.body({
ticket: validTicket,
code: totpCodeNow(secret),
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
expect(reuseJson.code).toBe('INVALID_FORM_BODY');
});
});

View File

@@ -0,0 +1,163 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
type BackupCodesResponse,
createAuthHarness,
createTestAccount,
type PhoneVerifyResponse,
totpCodeNow,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth MFA TOTP flag matches authenticator types', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('WebAuthn-only reports TOTP false', async () => {
const account = await createTestAccount(harness);
const secret = 'JBSWY3DPEHPK3PXP';
const totpData = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: totpCodeNow(secret), password: account.password})
.execute();
expect(totpData.backup_codes.length).toBeGreaterThan(0);
const totpLogin = await createBuilderWithoutAuth<{ticket: string}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect(totpLogin.ticket).toBeDefined();
const totpResp = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({code: totpCodeNow(secret), ticket: totpLogin.ticket})
.execute();
account.token = totpResp.token;
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: totpData.backup_codes[0]!.code,
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
const loginResp = await createBuilderWithoutAuth<{
token: string;
user_id: string;
}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect('mfa' in loginResp).toBe(false);
expect(loginResp.token).toBeDefined();
});
it('SMS-only reports TOTP false when SMS is implicitly removed', async () => {
const account = await createTestAccount(harness);
const secret = 'JBSWY3DPEHPK3PXP';
const totpData = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: totpCodeNow(secret), password: account.password})
.execute();
expect(totpData.backup_codes.length).toBeGreaterThan(0);
const totpLogin = await createBuilderWithoutAuth<{ticket: string}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect(totpLogin.ticket).toBeDefined();
const totpResp = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({code: totpCodeNow(secret), ticket: totpLogin.ticket})
.execute();
account.token = totpResp.token;
const phone = `+1555${String(Date.now() % 10000000).padStart(7, '0')}`;
await createBuilder(harness, account.token)
.post('/users/@me/phone/send-verification')
.body({phone})
.expect(204)
.execute();
const phoneVerify = await createBuilder<PhoneVerifyResponse>(harness, account.token)
.post('/users/@me/phone/verify')
.body({phone, code: '123456'})
.execute();
expect(phoneVerify.phone_token).toBeDefined();
await createBuilder(harness, account.token)
.post('/users/@me/phone')
.body({
phone_token: phoneVerify.phone_token,
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/sms/enable')
.body({
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: totpData.backup_codes[0]!.code,
mfa_method: 'totp',
mfa_code: totpCodeNow(secret),
})
.expect(204)
.execute();
const loginResp = await createBuilderWithoutAuth<{
token: string;
user_id: string;
}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect('mfa' in loginResp).toBe(false);
expect(loginResp.token).toBeDefined();
});
});

View File

@@ -0,0 +1,135 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createTestAccount,
createTotpSecret,
generateTotpCode,
seedMfaTicket,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createRegistrationResponse,
createWebAuthnDevice,
type WebAuthnRegistrationOptions,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth MFA TOTP without secret', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('rejects TOTP login when secret is missing', async () => {
const account = await createTestAccount(harness);
const ticket = 'mfa-no-secret';
await seedMfaTicket(harness, ticket, account.userId, 300);
const login = await createBuilderWithoutAuth<{code: string}>(harness)
.post('/auth/login/mfa/totp')
.body({ticket, code: '123456'})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
expect(login.code).toBe('INVALID_FORM_BODY');
});
it('rejects TOTP login when only WebAuthn is enabled', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
const totpData = await createBuilder<{backup_codes: Array<{code: string}>}>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (regOptions.rp.id) {
device.rpId = regOptions.rp.id;
}
const registrationResponse = createRegistrationResponse(device, regOptions, 'Test Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: regOptions.challenge,
name: 'Test Passkey',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: totpData.backup_codes[0]!.code,
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const login = await createBuilderWithoutAuth<{
mfa: true;
ticket: string;
allowed_methods: Array<string>;
totp: boolean;
webauthn: boolean;
}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect(login.mfa).toBe(true);
expect(login.totp).toBe(false);
expect(login.webauthn).toBe(true);
expect(login.allowed_methods).toContain('webauthn');
expect(login.allowed_methods).not.toContain('totp');
const bypassAttempt = await createBuilderWithoutAuth<{code: string}>(harness)
.post('/auth/login/mfa/totp')
.body({ticket: login.ticket, code: '123456'})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
expect(bypassAttempt.code).toBe('INVALID_FORM_BODY');
});
});

View File

@@ -0,0 +1,134 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createTestAccount,
findLastTestEmail,
listTestEmails,
loginAccount,
loginUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface ValidationErrorResponse {
code: string;
errors?: Array<{
path?: string;
code?: string;
}>;
}
describe('Password change invalidates sessions', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
});
afterAll(async () => {
await harness?.shutdown();
});
it('invalidates all other sessions when password is changed', async () => {
const account = await createTestAccount(harness);
const session2 = await loginAccount(harness, account);
const session3 = await loginAccount(harness, account);
await createBuilder(harness, account.token).get('/users/@me').execute();
await createBuilder(harness, session2.token).get('/users/@me').execute();
await createBuilder(harness, session3.token).get('/users/@me').execute();
const newPassword = `new-password-${Date.now()}`;
await createBuilder(harness, account.token)
.patch('/users/@me')
.body({
password: account.password,
new_password: newPassword,
})
.execute();
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
await createBuilder(harness, session2.token).get('/users/@me').expect(401).execute();
await createBuilder(harness, session3.token).get('/users/@me').expect(401).execute();
const login = await loginUser(harness, {email: account.email, password: newPassword});
if ('mfa' in login && login.mfa) {
throw new Error('Expected non-MFA login');
}
const nonMfaLogin = login as {user_id: string; token: string};
expect(nonMfaLogin.token.length).toBeGreaterThan(0);
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.expect(400)
.execute();
});
it('requires current password when changing password', async () => {
const account = await createTestAccount(harness);
const response = await createBuilder<ValidationErrorResponse>(harness, account.token)
.patch('/users/@me')
.body({
new_password: `new-password-${Date.now()}`,
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
expect(response.errors?.some((error) => error.path === 'password' && error.code === 'PASSWORD_NOT_SET')).toBe(true);
});
it('invalidates all sessions after password reset', async () => {
const account = await createTestAccount(harness);
const session2 = await loginAccount(harness, account);
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email: account.email}).expect(204).execute();
const emails = await listTestEmails(harness, {recipient: account.email});
const resetEmail = findLastTestEmail(emails, 'password_reset');
expect(resetEmail?.metadata?.token).toBeDefined();
const token = resetEmail!.metadata!.token!;
const newPassword = `reset-password-${Date.now()}`;
await createBuilderWithoutAuth(harness).post('/auth/reset').body({token, password: newPassword}).execute();
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
await createBuilder(harness, session2.token).get('/users/@me').expect(401).execute();
const login = await loginUser(harness, {email: account.email, password: newPassword});
if ('mfa' in login && login.mfa) {
throw new Error('Expected non-MFA login');
}
const nonMfaLogin = login as {user_id: string; token: string};
expect(nonMfaLogin.token.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,104 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createTestAccount,
findLastTestEmail,
type LoginSuccessResponse,
listTestEmails,
loginUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {generateUniquePassword, HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Password reset flow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
});
afterAll(async () => {
await harness?.shutdown();
});
it('allows forgot and reset password flow with token reuse rejection and session invalidation', 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 resetEmail = findLastTestEmail(emails, 'password_reset');
expect(resetEmail?.metadata?.token).toBeDefined();
const token = resetEmail!.metadata!.token!;
const newPassword = generateUniquePassword();
const resetResp = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
.post('/auth/reset')
.body({token, password: newPassword})
.execute();
expect(resetResp.token.length).toBeGreaterThan(0);
const login = await loginUser(harness, {email: account.email, password: newPassword});
if ('mfa' in login && login.mfa) {
throw new Error('Expected non-MFA login');
}
const nonMfaLogin = login as {user_id: string; token: string};
expect(nonMfaLogin.token.length).toBeGreaterThan(0);
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
const anotherPassword = generateUniquePassword();
await createBuilderWithoutAuth(harness)
.post('/auth/reset')
.body({token, password: anotherPassword})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
it('rejects invalid reset token', async () => {
await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post('/auth/reset')
.body({token: 'invalid-reset-token', password: generateUniquePassword()})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});

View File

@@ -0,0 +1,115 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createTestAccount,
createTotpSecret,
totpCodeNow,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Phone verification flow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('completes full phone verification, attachment, and removal flow', async () => {
const account = await createTestAccount(harness);
const totpSecret = createTotpSecret();
const totpCode = totpCodeNow(totpSecret);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret: totpSecret,
code: totpCode,
password: account.password,
})
.execute();
const phone = `+1555${String(Date.now()).slice(-7)}`;
await createBuilder(harness, account.token)
.post('/users/@me/phone/send-verification')
.body({
phone,
})
.expect(204)
.execute();
const verifyPhoneJson = await createBuilder<{phone_token: string}>(harness, account.token)
.post('/users/@me/phone/verify')
.body({
phone,
code: '123456',
})
.execute();
expect(verifyPhoneJson.phone_token).toBeDefined();
expect(verifyPhoneJson.phone_token.length).toBeGreaterThan(0);
const phoneToken = verifyPhoneJson.phone_token;
const totpCode2 = totpCodeNow(totpSecret);
await createBuilder(harness, account.token)
.post('/users/@me/phone')
.body({
phone_token: phoneToken,
mfa_method: 'totp',
mfa_code: totpCode2,
})
.expect(204)
.execute();
const meJson = await createBuilder<{phone: string | null}>(harness, account.token).get('/users/@me').execute();
expect(meJson.phone).toBe(phone);
const totpCode3 = totpCodeNow(totpSecret);
await createBuilder(harness, account.token)
.delete('/users/@me/phone')
.body({
mfa_method: 'totp',
mfa_code: totpCode3,
})
.expect(204)
.execute();
const meAfterRemovalJson = await createBuilder<{phone: string | null}>(harness, account.token)
.get('/users/@me')
.execute();
expect(meAfterRemovalJson.phone).toBeNull();
});
});

View File

@@ -0,0 +1,283 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createUniqueEmail,
createUniqueUsername,
fetchMe,
type LoginSuccessResponse,
registerUser,
titleCaseEmail,
type UserMeResponse,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth registration', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('returns token and user_id', async () => {
const email = createUniqueEmail('register');
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('register'),
global_name: 'Register User',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
});
expect(reg.token.length).toBeGreaterThan(0);
expect(reg.user_id.length).toBeGreaterThan(0);
});
it('allows emoji global name', async () => {
const globalName = '🌻 Sunflower';
const reg = await registerUser(harness, {
email: createUniqueEmail('global-name-emoji'),
username: createUniqueUsername('globalnameemoji'),
global_name: globalName,
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
});
const me = (await fetchMe(harness, reg.token)).json as UserMeResponse;
expect(me.global_name).toBe(globalName);
});
it('derives username from display name when username is omitted', async () => {
const reg = await registerUser(harness, {
email: createUniqueEmail('derived-username'),
password: 'a-strong-password',
global_name: 'Magic Tester',
date_of_birth: '2000-01-01',
consent: true,
});
const me = (await fetchMe(harness, reg.token)).json as UserMeResponse;
expect(me.username).toBe('Magic_Tester');
});
it('rejects invalid registration payloads', async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body({
email: 'not-an-email',
username: 'itest',
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
})
.expect(400)
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body({
email: createUniqueEmail('weak-password'),
username: 'itest',
global_name: 'Test User',
password: 'weak',
date_of_birth: '2000-01-01',
consent: true,
})
.expect(400)
.execute();
await registerUser(harness, {
email: 'integration-duplicate-email@example.com',
username: createUniqueUsername('firstuser'),
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
});
const duplicateJson = await createBuilderWithoutAuth<{
code: string;
errors: Array<{path: string; message: string}>;
}>(harness)
.post('/auth/register')
.body({
email: 'integration-duplicate-email@example.com',
username: createUniqueUsername('seconduser'),
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
})
.expect(400)
.execute();
expect(duplicateJson.code).toBe('INVALID_FORM_BODY');
const emailError = duplicateJson.errors.find((e) => e.path === 'email');
expect(emailError?.message).toBe('Email already in use');
const missingFieldsCases: Array<{name: string; body: Record<string, unknown>}> = [
{
name: 'missing email',
body: {
email: '',
username: 'itest',
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
},
},
{
name: 'missing username',
body: {
email: 'integration-missing-username@example.com',
username: '',
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
},
},
{
name: 'missing password',
body: {
email: 'integration-missing-password@example.com',
username: 'itest',
global_name: 'Test User',
password: '',
date_of_birth: '2000-01-01',
consent: true,
},
},
{
name: 'missing date of birth',
body: {
email: 'integration-missing-dob@example.com',
username: 'itest',
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: '',
consent: true,
},
},
];
for (const testCase of missingFieldsCases) {
await createBuilderWithoutAuth(harness).post('/auth/register').body(testCase.body).expect(400).execute();
}
});
it('allows login after registration', async () => {
const email = createUniqueEmail('login');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('loginuser'),
global_name: 'Login User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const login = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
.post('/auth/login')
.body({email, password})
.execute();
expect('mfa' in login).toBe(false);
expect(login.token.length).toBeGreaterThan(0);
expect(login.user_id).toBe(reg.user_id);
});
it('treats email as case-insensitive across auth flows', async () => {
const baseEmail = 'Integration-Test-Case-Email@Example.COM';
const password = 'a-strong-password';
await registerUser(harness, {
email: baseEmail,
username: createUniqueUsername('caseuser'),
global_name: 'Test User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
const loginEmails = [baseEmail.toLowerCase(), baseEmail.toUpperCase(), titleCaseEmail(baseEmail)];
for (const email of loginEmails) {
const login = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
.post('/auth/login')
.body({email, password})
.execute();
expect(login.token.length).toBeGreaterThan(0);
}
const duplicateJson = await createBuilderWithoutAuth<{
code: string;
errors: Array<{path: string; message: string}>;
}>(harness)
.post('/auth/register')
.body({
email: baseEmail.toUpperCase(),
username: createUniqueUsername('caseuser2'),
global_name: 'Test User',
password: 'another-strong-password',
date_of_birth: '2000-01-01',
consent: true,
})
.expect(400)
.execute();
expect(duplicateJson.code).toBe('INVALID_FORM_BODY');
const emailError = duplicateJson.errors.find((e) => e.path === 'email');
expect(emailError?.message).toBe('Email already in use');
await createBuilderWithoutAuth(harness)
.post('/auth/forgot')
.body({email: baseEmail.toUpperCase()})
.expect(204)
.execute();
const caseEmailUser = await registerUser(harness, {
email: 'integration-case-store-email@example.com',
username: createUniqueUsername('caseemailstored'),
global_name: 'Stored Email',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
});
const me = (await fetchMe(harness, caseEmailUser.token)).json as UserMeResponse;
expect(me.email).toBe('integration-case-store-email@example.com');
});
});

View File

@@ -0,0 +1,170 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createTestAccount,
createUniqueEmail,
createUniqueUsername,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface ValidationErrorResponse {
code: string;
message: string;
errors?: Array<{path: string; code: string; message: string}>;
}
describe('Registration validation', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('rejects invalid email format', async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body({
email: 'not-an-email',
username: createUniqueUsername(),
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
})
.expect(400)
.execute();
});
it('rejects weak password', async () => {
const json = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail(),
username: createUniqueUsername(),
global_name: 'Test User',
password: 'weak',
date_of_birth: '2000-01-01',
consent: true,
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
const passwordError = json.errors?.find((e) => e.path === 'password');
expect(passwordError?.code).toBe('PASSWORD_LENGTH_INVALID');
expect(passwordError?.message).toBe('String length must be between 8 and 256 characters.');
});
it('includes bounds in username length validation message', async () => {
const json = await createBuilderWithoutAuth<ValidationErrorResponse>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail(),
username: 'a'.repeat(33),
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
const usernameError = json.errors?.find((e) => e.path === 'username');
expect(usernameError?.code).toBe('USERNAME_LENGTH_INVALID');
expect(usernameError?.message).toBe('Username must be between 1 and 32 characters.');
});
it('rejects duplicate email', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body({
email: account.email,
username: createUniqueUsername(),
global_name: 'Test User',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
})
.expect(400, 'INVALID_FORM_BODY')
.execute();
});
it('rejects missing date of birth', async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body({
email: createUniqueEmail(),
username: createUniqueUsername(),
global_name: 'Test User',
password: 'a-strong-password',
consent: true,
})
.expect(400)
.execute();
});
it('allows emoji in global name', async () => {
const globalName = '🌻 Sunflower';
const reg = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail('emoji'),
username: createUniqueUsername('emoji'),
global_name: globalName,
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
})
.execute();
const me = await createBuilder<{global_name: string}>(harness, reg.token).get('/users/@me').execute();
expect(me.global_name).toBe(globalName);
});
it('derives username from global name when username is not provided', async () => {
const reg = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail('derived'),
global_name: 'Magic Tester',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
})
.execute();
const me = await createBuilder<{username: string}>(harness, reg.token).get('/users/@me').execute();
expect(me.username).toBe('Magic_Tester');
});
});

View File

@@ -0,0 +1,119 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createTestAccount,
findLastTestEmail,
listTestEmails,
type TestEmailRecord,
totpCodeNow,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface MfaRequiredResponse {
mfa: true;
ticket: string;
allowed_methods: Array<string>;
sms_phone_hint: string | null;
sms: boolean;
totp: boolean;
webauthn: boolean;
}
async function waitForEmail(harness: ApiTestHarness, type: string, recipient: string): Promise<TestEmailRecord> {
const maxAttempts = 20;
for (let i = 0; i < maxAttempts; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
const emails = await listTestEmails(harness, {recipient});
const email = findLastTestEmail(emails, type);
if (email) {
return email;
}
}
throw new Error(`Email not found: type=${type}, recipient=${recipient}`);
}
describe('Auth reset password requires MFA', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('returns MFA ticket after password reset when MFA is enabled', async () => {
const account = await createTestAccount(harness);
await clearTestEmails(harness);
const secret = 'JBSWY3DPEHPK3PXP';
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: totpCodeNow(secret), password: account.password})
.execute();
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email: account.email}).expect(204).execute();
const email = await waitForEmail(harness, 'password_reset', account.email);
const token = email.metadata['token'];
expect(token).toBeDefined();
const newPassword = 'new-strong-password-123';
const resetResp = await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
.post('/auth/reset')
.body({token, password: newPassword})
.execute();
expect(resetResp.mfa).toBe(true);
expect(resetResp.ticket).toBeDefined();
expect(resetResp.totp).toBe(true);
expect(resetResp.sms).toBe(false);
expect(resetResp.webauthn).toBe(false);
expect(resetResp.allowed_methods).toEqual(['totp']);
expect(resetResp.sms_phone_hint).toBeNull();
const mfaResp = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
ticket: resetResp.ticket,
code: totpCodeNow(secret),
})
.execute();
expect(mfaResp.token).toBeDefined();
const login = await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
.post('/auth/login')
.body({email: account.email, password: newPassword})
.execute();
expect(login.mfa).toBe(true);
expect(login.ticket).toBeDefined();
expect(login.totp).toBe(true);
expect(login.sms).toBe(false);
expect(login.webauthn).toBe(false);
});
});

View File

@@ -0,0 +1,87 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
describe('Auth security flags - suspicious activity flag blocks login required routes', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('blocks access to login-required routes when suspicious activity flag is set', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/security-flags`)
.body({
suspicious_activity_flag_names: ['REQUIRE_VERIFIED_EMAIL'],
})
.execute();
await createBuilder(harness, account.token).get('/users/@me').expect(403).execute();
});
it('allows /auth/verify/resend even with suspicious activity flag', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/security-flags`)
.body({
suspicious_activity_flag_names: ['REQUIRE_VERIFIED_EMAIL'],
})
.execute();
await createBuilder(harness, account.token).post('/auth/verify/resend').body({}).expect(204).execute();
});
it('allows access after clearing suspicious activity flag', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/security-flags`)
.body({
suspicious_activity_flag_names: ['REQUIRE_VERIFIED_EMAIL'],
})
.execute();
await createBuilder(harness, account.token).get('/users/@me').expect(403).execute();
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/security-flags`)
.body({
suspicious_activity_flags: 0,
})
.execute();
await createBuilder(harness, account.token).get('/users/@me').execute();
});
});

View File

@@ -0,0 +1,66 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createTestAccount,
createUniqueEmail,
createUniqueUsername,
fetchSettings,
registerUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('User settings defaults', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('defaults incoming calls to friends-only (adult and minor)', async () => {
const incomingCallFriendsOnly = 8;
const adult = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
const adultSettings = await fetchSettings(harness, adult.token);
expect(adultSettings.response.status).toBe(200);
expect((adultSettings.json as {incoming_call_flags: number}).incoming_call_flags).toBe(incomingCallFriendsOnly);
const minorReg = await registerUser(harness, {
email: createUniqueEmail(),
username: createUniqueUsername(),
global_name: 'Minor Settings',
password: 'a-strong-password',
date_of_birth: '2012-01-01',
consent: true,
});
const minorSettings = await fetchSettings(harness, minorReg.token);
expect(minorSettings.response.status).toBe(200);
expect((minorSettings.json as {incoming_call_flags: number}).incoming_call_flags).toBe(incomingCallFriendsOnly);
});
});

View File

@@ -0,0 +1,545 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createTestAccount,
disableSso,
enableSso,
setUserACLs,
type TestAccount,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface SsoStartResponse {
authorization_url: string;
state: string;
redirect_uri: string;
}
interface SsoCompleteResponse {
token: string;
user_id: string;
redirect_to: string;
}
interface SsoStatusResponse {
enabled: boolean;
display_name?: string;
}
describe('Auth SSO flow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
describe('local auth blocking', () => {
let admin: TestAccount;
beforeEach(async () => {
admin = await createTestAccount(harness);
admin = await setUserACLs(harness, admin, [
'admin:authenticate',
'instance:config:update',
'instance:config:view',
]);
await enableSso(harness, admin.token);
});
afterEach(async () => {
await disableSso(harness, admin.token);
});
it('blocks local auth when SSO is enforced', async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({
email: 'someone@example.com',
password: 'password123',
})
.expect(403)
.execute();
});
});
describe('complete SSO flow', () => {
let admin: TestAccount;
beforeEach(async () => {
admin = await createTestAccount(harness);
admin = await setUserACLs(harness, admin, [
'admin:authenticate',
'instance:config:update',
'instance:config:view',
]);
await enableSso(harness, admin.token);
});
afterEach(async () => {
await disableSso(harness, admin.token);
});
it('creates session through full SSO flow', async () => {
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({redirect_to: '/me'})
.execute();
expect(startData.state).toBeTruthy();
expect(startData.authorization_url).toBeTruthy();
const authUrlString = startData.authorization_url;
expect(authUrlString).toContain(`state=${startData.state}`);
expect(authUrlString).toContain('code_challenge_method=S256');
expect(authUrlString).toContain('code_challenge=');
expect(authUrlString).toContain('nonce=');
if (authUrlString.startsWith('http://') || authUrlString.startsWith('https://')) {
const authUrl = new URL(authUrlString);
const stateParam = authUrl.searchParams.get('state');
expect(stateParam).toBe(startData.state);
const codeChallengeMethod = authUrl.searchParams.get('code_challenge_method');
expect(codeChallengeMethod).toBe('S256');
const codeChallenge = authUrl.searchParams.get('code_challenge');
expect(codeChallenge).toBeTruthy();
const nonce = authUrl.searchParams.get('nonce');
expect(nonce).toBeTruthy();
}
const email = `sso-user-${Date.now()}@example.com`;
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
.body({
code: email,
state: startData.state,
})
.execute();
expect(completeData.token).toBeTruthy();
expect(completeData.user_id).toBeTruthy();
const meData = await createBuilder<{email: string | null}>(harness, `Bearer ${completeData.token}`)
.get('/users/@me')
.execute();
expect(meData.email).toBe(email);
});
});
describe('redirect validation', () => {
let admin: TestAccount;
beforeEach(async () => {
admin = await createTestAccount(harness);
admin = await setUserACLs(harness, admin, [
'admin:authenticate',
'instance:config:update',
'instance:config:view',
]);
await enableSso(harness, admin.token);
});
afterEach(async () => {
await disableSso(harness, admin.token);
});
it('rejects open redirect URLs', async () => {
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({redirect_to: 'https://evil.example/phish'})
.execute();
const email = `sso-open-redirect-${Date.now()}@example.com`;
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
.body({
code: email,
state: startData.state,
})
.execute();
expect(completeData.redirect_to).toBe('');
});
it('rejects protocol-relative redirects', async () => {
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({redirect_to: '//evil.example/phish'})
.execute();
const email = `sso-protocol-relative-${Date.now()}@example.com`;
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
.body({
code: email,
state: startData.state,
})
.execute();
expect(completeData.redirect_to).toBe('');
});
it('rejects redirects with newlines', async () => {
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({redirect_to: '/dashboard\r\nSet-Cookie: evil=true'})
.execute();
const email = `sso-newline-redirect-${Date.now()}@example.com`;
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
.body({
code: email,
state: startData.state,
})
.execute();
expect(completeData.redirect_to).toBe('');
});
it('rejects too long redirects', async () => {
const longRedirect = `/${'a'.repeat(2100)}`;
await createBuilderWithoutAuth(harness)
.post('/auth/sso/start')
.body({redirect_to: longRedirect})
.expect(400, 'INVALID_FORM_BODY')
.execute();
});
it('uses default redirect when missing', async () => {
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({})
.execute();
const email = `sso-default-redirect-${Date.now()}@example.com`;
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
.body({
code: email,
state: startData.state,
})
.execute();
expect(completeData.redirect_to.trim()).toBe('');
});
});
describe('state validation', () => {
let admin: TestAccount;
beforeEach(async () => {
admin = await createTestAccount(harness);
admin = await setUserACLs(harness, admin, [
'admin:authenticate',
'instance:config:update',
'instance:config:view',
]);
await enableSso(harness, admin.token);
});
afterEach(async () => {
await disableSso(harness, admin.token);
});
it('ensures state is single-use', async () => {
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({redirect_to: '/me'})
.execute();
const email1 = `sso-singleuse-${Date.now()}@example.com`;
await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
.body({
code: email1,
state: startData.state,
})
.execute();
const email2 = `sso-singleuse-2-${Date.now()}@example.com`;
await createBuilderWithoutAuth(harness)
.post('/auth/sso/complete')
.body({
code: email2,
state: startData.state,
})
.expect(400)
.execute();
});
it('rejects invalid state', async () => {
const email = `sso-invalid-state-${Date.now()}@example.com`;
await createBuilderWithoutAuth(harness)
.post('/auth/sso/complete')
.body({
code: email,
state: 'invalid-state-value-12345',
})
.expect(400)
.execute();
});
it('rejects missing state', async () => {
const email = `sso-missing-state-${Date.now()}@example.com`;
await createBuilderWithoutAuth(harness)
.post('/auth/sso/complete')
.body({
code: email,
})
.expect(400)
.execute();
});
it('rejects empty code', async () => {
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({})
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/sso/complete')
.body({
code: '',
state: startData.state,
})
.expect(400)
.execute();
});
it('generates unique states', async () => {
const states = new Set<string>();
for (let i = 0; i < 10; i++) {
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({})
.execute();
expect(states.has(startData.state)).toBe(false);
states.add(startData.state);
}
expect(states.size).toBe(10);
});
});
describe('domain validation', () => {
let admin: TestAccount;
beforeEach(async () => {
admin = await createTestAccount(harness);
admin = await setUserACLs(harness, admin, [
'admin:authenticate',
'instance:config:update',
'instance:config:view',
]);
});
afterEach(async () => {
await disableSso(harness, admin.token);
});
it('enforces allowed domains', async () => {
await enableSso(harness, admin.token, {
allowed_domains: ['example.com'],
});
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({redirect_to: '/me'})
.execute();
const email = `sso-bad-domain-${Date.now()}@notexample.com`;
await createBuilderWithoutAuth(harness)
.post('/auth/sso/complete')
.body({
code: email,
state: startData.state,
})
.expect(400)
.execute();
});
it('handles allowed domains case-insensitively', async () => {
await enableSso(harness, admin.token, {
allowed_domains: ['EXAMPLE.COM'],
});
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({})
.execute();
const email = `sso-case-insensitive-${Date.now()}@example.com`;
await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
.body({
code: email,
state: startData.state,
})
.execute();
});
it('allows any domain when allowed_domains is empty', async () => {
await enableSso(harness, admin.token, {
allowed_domains: [],
});
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({})
.execute();
const email = `sso-any-domain-${Date.now()}@anydomain.org`;
await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
.body({
code: email,
state: startData.state,
})
.execute();
});
});
describe('auto-provision', () => {
let admin: TestAccount;
beforeEach(async () => {
admin = await createTestAccount(harness);
admin = await setUserACLs(harness, admin, [
'admin:authenticate',
'instance:config:update',
'instance:config:view',
]);
});
afterEach(async () => {
await disableSso(harness, admin.token);
});
it('respects auto_provision flag', async () => {
await enableSso(harness, admin.token, {
auto_provision: false,
});
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({redirect_to: '/me'})
.execute();
const email = `sso-noprovision-${Date.now()}@example.com`;
await createBuilderWithoutAuth(harness)
.post('/auth/sso/complete')
.body({
code: email,
state: startData.state,
})
.expect(403)
.execute();
});
});
describe('existing user login', () => {
let admin: TestAccount;
beforeEach(async () => {
admin = await createTestAccount(harness);
admin = await setUserACLs(harness, admin, [
'admin:authenticate',
'instance:config:update',
'instance:config:view',
]);
await enableSso(harness, admin.token);
});
afterEach(async () => {
await disableSso(harness, admin.token);
});
it('logs in existing user via SSO', async () => {
const email = `sso-existing-user-${Date.now()}@example.com`;
const startData1 = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({})
.execute();
const firstLogin = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
.body({
code: email,
state: startData1.state,
})
.execute();
const startData2 = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({})
.execute();
const secondLogin = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
.body({
code: email,
state: startData2.state,
})
.execute();
expect(secondLogin.user_id).toBe(firstLogin.user_id);
});
});
describe('status endpoint', () => {
let admin: TestAccount;
beforeEach(async () => {
admin = await createTestAccount(harness);
admin = await setUserACLs(harness, admin, [
'admin:authenticate',
'instance:config:update',
'instance:config:view',
]);
});
it('returns SSO status', async () => {
const status1 = await createBuilderWithoutAuth<SsoStatusResponse>(harness).get('/auth/sso/status').execute();
expect(status1.enabled).toBe(false);
await enableSso(harness, admin.token, {
display_name: 'Test SSO Provider',
});
const status2 = await createBuilderWithoutAuth<SsoStatusResponse>(harness).get('/auth/sso/status').execute();
expect(status2.enabled).toBe(true);
expect(status2.display_name).toBe('Test SSO Provider');
await disableSso(harness, admin.token);
});
});
});

View File

@@ -0,0 +1,349 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomBytes} from 'node:crypto';
import {
createAuthHarness,
createTestAccount,
loginUser,
type TestAccount,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {TotpGenerator} from '@fluxer/api/src/utils/TotpGenerator';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const SUDO_MODE_HEADER = 'X-Fluxer-Sudo-Mode-JWT';
interface ErrorResponse {
code: string;
message: string;
}
interface BackupCodesResponse {
backup_codes: Array<{code: string; consumed: boolean}>;
}
function generateTotpSecret(): string {
const buffer = randomBytes(20);
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let result = '';
for (let i = 0; i < buffer.length; i += 5) {
const bytes = [buffer[i]!, buffer[i + 1]!, buffer[i + 2]!, buffer[i + 3]!, buffer[i + 4]!];
const n = (bytes[0]! << 24) | (bytes[1]! << 16) | (bytes[2]! << 8) | bytes[3]!;
const indices = [
(n >> 3) & 0x1f,
((n >> 11) | ((bytes[4]! << 4) & 0xf)) & 0x1f,
((n >> 19) | ((bytes[4]! << 2) & 0x3c)) & 0x1f,
(bytes[4]! >> 1) & 0x1f,
];
result += base32Chars[indices[0]!];
result += base32Chars[indices[1]!];
result += base32Chars[indices[2]!];
result += base32Chars[indices[3]!];
}
return result;
}
async function generateTotpCode(secret: string): Promise<string> {
const totp = new TotpGenerator(secret);
const codes = await totp.generateTotp();
return codes[0]!;
}
async function enableTotpForAccount(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<void> {
const code = await generateTotpCode(secret);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code,
password: account.password,
})
.execute();
}
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<TestAccount> {
const login = await loginUser(harness, {email: account.email, password: account.password});
if (!('mfa' in login)) {
throw new Error('Expected MFA login');
}
const mfaLoginResp = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
ticket: login.ticket,
code: await generateTotpCode(secret),
})
.execute();
return {...account, token: mfaLoginResp.token};
}
async function getSudoTokenViaMfa(harness: ApiTestHarness, token: string, secret: string): Promise<string> {
const {response} = await createBuilder<BackupCodesResponse>(harness, token)
.post('/users/@me/mfa/backup-codes')
.body({
mfa_method: 'totp',
mfa_code: await generateTotpCode(secret),
regenerate: false,
})
.executeWithResponse();
const sudoToken = response.headers.get(SUDO_MODE_HEADER);
if (!sudoToken) {
throw new Error('No sudo token returned');
}
return sudoToken;
}
describe('Sudo mode negative cases', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
describe('invalid sudo token rejected', () => {
it('rejects malformed sudo token', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
await enableTotpForAccount(harness, account, secret);
const loggedIn = await loginWithTotp(harness, account, secret);
const invalidTokens = [
'invalid-token',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoibm90LXN1ZG8ifQ.fakesignature',
];
for (const invalidToken of invalidTokens) {
const {json: errResp} = await createBuilder<ErrorResponse>(harness, loggedIn.token)
.post('/users/@me/disable')
.header(SUDO_MODE_HEADER, invalidToken)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.executeWithResponse();
expect(errResp.code).toBe('SUDO_MODE_REQUIRED');
}
});
it('rejects empty sudo token header', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
await enableTotpForAccount(harness, account, secret);
const loggedIn = await loginWithTotp(harness, account, secret);
const {json: errResp} = await createBuilder<ErrorResponse>(harness, loggedIn.token)
.post('/users/@me/disable')
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.executeWithResponse();
expect(errResp.code).toBe('SUDO_MODE_REQUIRED');
});
});
describe('wrong password rejected when verifying sudo', () => {
it('rejects wrong password for password-only user', async () => {
const account = await createTestAccount(harness);
const wrongPasswords = ['wrong-password', `${account.password}extra`, 'password123'];
for (const wrongPassword of wrongPasswords) {
await createBuilder(harness, account.token)
.post('/users/@me/disable')
.body({
password: wrongPassword,
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
}
});
it('requires password for password-only user - returns 403 without password', async () => {
const account = await createTestAccount(harness);
const {json: errResp} = await createBuilder<ErrorResponse>(harness, account.token)
.post('/users/@me/disable')
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.executeWithResponse();
expect(errResp.code).toBe('SUDO_MODE_REQUIRED');
});
});
describe('wrong MFA code rejected', () => {
it('rejects incorrect TOTP codes', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
await enableTotpForAccount(harness, account, secret);
const loggedIn = await loginWithTotp(harness, account, secret);
const wrongCodes = ['000000', '123456', '999999', '12345', '1234567', 'abcdef'];
for (const wrongCode of wrongCodes) {
await createBuilder(harness, loggedIn.token)
.post('/users/@me/disable')
.body({
mfa_method: 'totp',
mfa_code: wrongCode,
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
}
});
});
describe('sudo token for wrong user rejected', () => {
it('rejects sudo token from different user', async () => {
const account1 = await createTestAccount(harness);
const secret1 = generateTotpSecret();
await enableTotpForAccount(harness, account1, secret1);
const loggedIn1 = await loginWithTotp(harness, account1, secret1);
const user1SudoToken = await getSudoTokenViaMfa(harness, loggedIn1.token, secret1);
const account2 = await createTestAccount(harness);
const secret2 = generateTotpSecret();
await enableTotpForAccount(harness, account2, secret2);
const loggedIn2 = await loginWithTotp(harness, account2, secret2);
const {json: errResp} = await createBuilder<ErrorResponse>(harness, loggedIn2.token)
.post('/users/@me/disable')
.header(SUDO_MODE_HEADER, user1SudoToken)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.executeWithResponse();
expect(errResp.code).toBe('SUDO_MODE_REQUIRED');
});
});
describe('password user requires password each time', () => {
it('password user does not receive sudo token', async () => {
const account = await createTestAccount(harness);
const {response} = await createBuilder(harness, account.token)
.post('/users/@me/disable')
.body({
password: account.password,
})
.expect(HTTP_STATUS.NO_CONTENT)
.executeWithResponse();
const sudoToken = response.headers.get(SUDO_MODE_HEADER);
expect(sudoToken).toBeFalsy();
});
it('password user must provide password for subsequent sensitive operations', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/disable')
.body({
password: account.password,
})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const login = await loginUser(harness, {email: account.email, password: account.password});
if ('mfa' in login && login.mfa) {
throw new Error('Expected non-MFA login');
}
const nonMfaLogin = login as {user_id: string; token: string};
const {json: errResp} = await createBuilder<ErrorResponse>(harness, nonMfaLogin.token)
.post('/users/@me/disable')
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.executeWithResponse();
expect(errResp.code).toBe('SUDO_MODE_REQUIRED');
});
});
describe('MFA registration without token fails', () => {
it('WebAuthn registration options do not issue sudo token', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
await enableTotpForAccount(harness, account, secret);
const loggedIn = await loginWithTotp(harness, account, secret);
const sudoToken = await getSudoTokenViaMfa(harness, loggedIn.token, secret);
const {response} = await createBuilder(harness, loggedIn.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.header(SUDO_MODE_HEADER, sudoToken)
.body({})
.executeWithResponse();
const newSudoToken = response.headers.get(SUDO_MODE_HEADER);
expect(newSudoToken).toBeFalsy();
});
});
describe('existing MFA token allows skipping MFA', () => {
it('valid sudo token allows skipping MFA for subsequent requests', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
await enableTotpForAccount(harness, account, secret);
const loggedIn = await loginWithTotp(harness, account, secret);
const {response: firstResp} = await createBuilder<BackupCodesResponse>(harness, loggedIn.token)
.post('/users/@me/mfa/backup-codes')
.body({
mfa_method: 'totp',
mfa_code: await generateTotpCode(secret),
regenerate: false,
})
.executeWithResponse();
const sudoToken = firstResp.headers.get(SUDO_MODE_HEADER);
expect(sudoToken).toBeTruthy();
const backupCodes = await createBuilder<BackupCodesResponse>(harness, loggedIn.token)
.post('/users/@me/mfa/backup-codes')
.header(SUDO_MODE_HEADER, sudoToken!)
.body({
regenerate: true,
})
.execute();
expect(backupCodes.backup_codes.length).toBeGreaterThan(0);
});
it('sudo token from MFA allows skipping MFA verification on sensitive endpoint', async () => {
const account = await createTestAccount(harness);
const secret = generateTotpSecret();
await enableTotpForAccount(harness, account, secret);
const loggedIn = await loginWithTotp(harness, account, secret);
const sudoToken = await getSudoTokenViaMfa(harness, loggedIn.token, secret);
const backupCodes = await createBuilder<BackupCodesResponse>(harness, loggedIn.token)
.post('/users/@me/mfa/backup-codes')
.header(SUDO_MODE_HEADER, sudoToken)
.body({
regenerate: false,
})
.execute();
expect(backupCodes.backup_codes.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,89 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createFakeAuthToken,
createTestAccount,
type UserMeResponse,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Auth token validation', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('malformed token returns unauthorized', async () => {
const malformedTokens = ['', 'not-a-token', 'Bearer invalid', 'invalid.token.format', 'ey123.ey456.sig789'];
for (const token of malformedTokens) {
await createBuilder(harness, token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
}
});
it('non-existent token returns unauthorized', async () => {
const fakeToken = createFakeAuthToken();
await createBuilder(harness, fakeToken).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
it('revoked token returns unauthorized', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.OK).execute();
await createBuilder(harness, account.token).post('/auth/logout').expect(HTTP_STATUS.NO_CONTENT).execute();
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
it('valid token allows access', async () => {
const account = await createTestAccount(harness);
const user = await createBuilder<UserMeResponse>(harness, account.token).get('/users/@me').execute();
expect(user.id).toBe(account.userId);
});
it('token with wrong signature returns unauthorized', async () => {
const account = await createTestAccount(harness);
const tamperedToken = `${account.token.slice(0, Math.max(0, account.token.length - 10))}0123456789`;
await createBuilder(harness, tamperedToken).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
it('missing authorization header returns unauthorized', async () => {
await createBuilderWithoutAuth(harness).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
});

View File

@@ -0,0 +1,314 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createTestAccount, unclaimAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
acceptInvite,
createChannelInvite,
createDMChannel,
createFriendship,
createGuild,
sendMessage,
} from '@fluxer/api/src/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('Unclaimed Account Restrictions', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('unclaimed account cannot add reactions', async () => {
const owner = await createTestAccount(harness);
const unclaimed = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const channelId = guild.system_channel_id!;
const invite = await createChannelInvite(harness, owner.token, channelId);
await acceptInvite(harness, unclaimed.token, invite.code);
const message = await sendMessage(harness, owner.token, channelId, 'React to this');
await unclaimAccount(harness, unclaimed.userId);
const {json: error} = await createBuilder<{code: string}>(harness, unclaimed.token)
.put(`/channels/${channelId}/messages/${message.id}/reactions/%F0%9F%91%8D/@me`)
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
expect(error.code).toBe('UNCLAIMED_ACCOUNT_CANNOT_ADD_REACTIONS');
});
test('unclaimed account cannot create group DM', async () => {
const unclaimed = await createTestAccount(harness);
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await unclaimAccount(harness, unclaimed.userId);
await createBuilder(harness, unclaimed.token)
.post('/users/@me/channels')
.body({recipients: [user1.userId, user2.userId]})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('unclaimed account cannot join group DM via invite', async () => {
const owner = await createTestAccount(harness);
const friend1 = await createTestAccount(harness);
const friend2 = await createTestAccount(harness);
await createFriendship(harness, owner, friend1);
await createFriendship(harness, owner, friend2);
const groupDm = await createBuilder<{id: string}>(harness, owner.token)
.post('/users/@me/channels')
.body({recipients: [friend1.userId]})
.execute();
const invite = await createBuilder<{code: string}>(harness, owner.token)
.post(`/channels/${groupDm.id}/invites`)
.body({})
.execute();
await unclaimAccount(harness, friend2.userId);
const {json: error} = await createBuilder<{code: string}>(harness, friend2.token)
.post(`/invites/${invite.code}`)
.body({})
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
expect(error.code).toBe('UNCLAIMED_ACCOUNT_CANNOT_JOIN_GROUP_DMS');
});
test('unclaimed account cannot send DM', async () => {
const sender = await createTestAccount(harness);
const receiver = await createTestAccount(harness);
await createFriendship(harness, sender, receiver);
const dmChannel = await createDMChannel(harness, sender.token, receiver.userId);
await unclaimAccount(harness, sender.userId);
await createBuilder(harness, sender.token)
.post(`/channels/${dmChannel.id}/messages`)
.body({content: 'Hello from unclaimed'})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('unclaimed account can receive DM', async () => {
const sender = await createTestAccount(harness);
const receiver = await createTestAccount(harness);
await createFriendship(harness, sender, receiver);
const dmChannel = await createDMChannel(harness, sender.token, receiver.userId);
await unclaimAccount(harness, receiver.userId);
const message = await sendMessage(harness, sender.token, dmChannel.id, 'Hello to unclaimed');
expect(message.id).toBeTruthy();
expect(message.content).toBe('Hello to unclaimed');
});
test('unclaimed account can join guild by invite', async () => {
const owner = await createTestAccount(harness);
const unclaimed = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const channelId = guild.system_channel_id!;
const invite = await createChannelInvite(harness, owner.token, channelId);
await unclaimAccount(harness, unclaimed.userId);
await createBuilder(harness, unclaimed.token).post(`/invites/${invite.code}`).body({}).execute();
});
test('unclaimed account cannot send guild messages', async () => {
const owner = await createTestAccount(harness);
const unclaimed = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const channelId = guild.system_channel_id!;
const invite = await createChannelInvite(harness, owner.token, channelId);
await acceptInvite(harness, unclaimed.token, invite.code);
await unclaimAccount(harness, unclaimed.userId);
const {json: error} = await createBuilder<{code: string}>(harness, unclaimed.token)
.post(`/channels/${channelId}/messages`)
.body({content: 'Hello world'})
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
expect(error.code).toBe('UNCLAIMED_ACCOUNT_CANNOT_SEND_MESSAGES');
});
test('unclaimed account cannot send friend request', async () => {
const sender = await createTestAccount(harness);
const receiver = await createTestAccount(harness);
await unclaimAccount(harness, sender.userId);
await createBuilder(harness, sender.token)
.put(`/users/@me/relationships/${receiver.userId}`)
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('unclaimed account cannot accept friend request', async () => {
const sender = await createTestAccount(harness);
const receiver = await createTestAccount(harness);
await createBuilder(harness, sender.token).post(`/users/@me/relationships/${receiver.userId}`).body({}).execute();
await unclaimAccount(harness, receiver.userId);
await createBuilder(harness, receiver.token)
.put(`/users/@me/relationships/${sender.userId}`)
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('unclaimed account can view their own profile', async () => {
const unclaimed = await createTestAccount(harness);
await unclaimAccount(harness, unclaimed.userId);
await createBuilder(harness, unclaimed.token).get('/users/@me').execute();
});
test('unclaimed account can view guild they are member of', async () => {
const owner = await createTestAccount(harness);
const unclaimed = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const channelId = guild.system_channel_id!;
const invite = await createChannelInvite(harness, owner.token, channelId);
await acceptInvite(harness, unclaimed.token, invite.code);
await unclaimAccount(harness, unclaimed.userId);
await createBuilder(harness, unclaimed.token).get(`/guilds/${guild.id}`).execute();
});
test('unclaimed account can read messages in guild', async () => {
const owner = await createTestAccount(harness);
const unclaimed = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const channelId = guild.system_channel_id!;
const invite = await createChannelInvite(harness, owner.token, channelId);
await acceptInvite(harness, unclaimed.token, invite.code);
await sendMessage(harness, owner.token, channelId, 'Test message');
await unclaimAccount(harness, unclaimed.userId);
await createBuilder(harness, unclaimed.token).get(`/channels/${channelId}/messages`).execute();
});
test('unclaimed account can create guild with INVITES_DISABLED feature', async () => {
const unclaimed = await createTestAccount(harness);
await unclaimAccount(harness, unclaimed.userId);
const guild = await createBuilder<GuildResponse>(harness, unclaimed.token)
.post('/guilds')
.body({name: 'Unclaimed Guild'})
.execute();
expect(guild.id).toBeTruthy();
expect(guild.features).toContain('INVITES_DISABLED');
});
test('unclaimed account cannot open DM with another user', async () => {
const unclaimed = await createTestAccount(harness);
const target = await createTestAccount(harness);
const guild = await createGuild(harness, unclaimed.token, 'Test Guild');
const channelId = guild.system_channel_id!;
const invite = await createChannelInvite(harness, unclaimed.token, channelId);
await acceptInvite(harness, target.token, invite.code);
await unclaimAccount(harness, unclaimed.userId);
const {json: error} = await createBuilder<{code: string}>(harness, unclaimed.token)
.post('/users/@me/channels')
.body({recipient_id: target.userId})
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
expect(error.code).toBe('UNCLAIMED_ACCOUNT_CANNOT_SEND_DIRECT_MESSAGES');
});
test('unclaimed account can use personal notes', async () => {
const unclaimed = await createTestAccount(harness);
const target = await createTestAccount(harness);
await unclaimAccount(harness, unclaimed.userId);
await createBuilder(harness, unclaimed.token)
.put(`/users/@me/notes/${target.userId}`)
.body({note: 'This is a personal note'})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
test('unclaimed account can delete without password', async () => {
const unclaimed = await createTestAccount(harness);
await unclaimAccount(harness, unclaimed.userId);
await createBuilder(harness, unclaimed.token)
.post('/users/@me/delete')
.body({})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
test('unclaimed account owner cannot disable INVITES_DISABLED feature', async () => {
const unclaimed = await createTestAccount(harness);
await unclaimAccount(harness, unclaimed.userId);
const guild = await createBuilder<GuildResponse>(harness, unclaimed.token)
.post('/guilds')
.body({name: 'Preview Guild'})
.execute();
expect(guild.features).toContain('INVITES_DISABLED');
const updatedGuild = await createBuilder<GuildResponse>(harness, unclaimed.token)
.patch(`/guilds/${guild.id}`)
.body({features: []})
.execute();
expect(updatedGuild.features).toContain('INVITES_DISABLED');
});
});

View File

@@ -0,0 +1,153 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createAuthHarness,
createTestAccount,
createUniqueEmail,
findLastTestEmail,
listTestEmails,
unclaimAccount,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface EmailChangeStartResponse {
ticket: string;
require_original: boolean;
original_proof?: string;
original_code_expires_at?: string;
resend_available_at?: string;
}
interface EmailChangeVerifyNewResponse {
email_token: string;
}
interface UserPrivateResponse {
id: string;
email: string;
phone?: string | null;
username: string;
discriminator: string;
global_name: string;
bio: string;
verified: boolean;
mfa_enabled: boolean;
authenticator_types: Array<number>;
password_last_changed_at?: string;
}
interface UserPatchResponse {
email: string;
password_last_changed_at?: string;
}
describe('Auth unclaimed claim flow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await clearTestEmails(harness);
});
afterAll(async () => {
await harness?.shutdown();
});
it('unclaimed users can change username before claim', async () => {
const account = await createTestAccount(harness);
await clearTestEmails(harness);
await unclaimAccount(harness, account.userId);
const newUsername = `forbidden${Date.now()}`;
const updatedUser = await createBuilder<UserPrivateResponse>(harness, account.token)
.patch('/users/@me')
.body({
username: newUsername,
})
.execute();
expect(updatedUser.username).toBe(newUsername);
});
it('unclaimed users claim via email code and password', async () => {
const account = await createTestAccount(harness);
await clearTestEmails(harness);
await unclaimAccount(harness, account.userId);
const start = await createBuilder<EmailChangeStartResponse>(harness, account.token)
.post('/users/@me/email-change/start')
.body({})
.execute();
expect(start.require_original).toBe(false);
expect(start.original_proof).toBeDefined();
expect(start.original_proof!.length).toBeGreaterThan(0);
const originalProof = start.original_proof!;
const newEmail = createUniqueEmail('integration-claim');
await createBuilder(harness, account.token)
.post('/users/@me/email-change/request-new')
.body({
ticket: start.ticket,
new_email: newEmail,
original_proof: originalProof,
})
.execute();
const newEmails = await listTestEmails(harness, {recipient: newEmail});
const newEmailData = findLastTestEmail(newEmails, 'email_change_new');
expect(newEmailData?.metadata?.code).toBeDefined();
const newCode = newEmailData!.metadata!.code!;
const verify = await createBuilder<EmailChangeVerifyNewResponse>(harness, account.token)
.post('/users/@me/email-change/verify-new')
.body({
ticket: start.ticket,
code: newCode,
original_proof: originalProof,
})
.execute();
const newPassword = `test-password-${Date.now()}`;
const updated = await createBuilder<UserPatchResponse>(harness, account.token)
.patch('/users/@me')
.body({
email_token: verify.email_token,
new_password: newPassword,
})
.execute();
expect(updated.email).toBe(newEmail);
expect(updated.password_last_changed_at).toBeDefined();
expect(updated.password_last_changed_at!.length).toBeGreaterThan(0);
const me = await createBuilder<UserPrivateResponse>(harness, account.token).get('/users/@me').execute();
expect(me.email).toBe(newEmail);
expect(me.verified).toBe(true);
});
});

View File

@@ -0,0 +1,113 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createAuthenticationResponse,
createRegistrationResponse,
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
type WebAuthnAuthenticationOptions,
type WebAuthnRegistrationOptions,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('WebAuthn authentication replay', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('covers WebAuthn authentication challenge reuse rejection for passwordless flow', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (regOptions.rp.id) {
device.rpId = regOptions.rp.id;
}
const registrationResponse = createRegistrationResponse(device, regOptions, 'ReplayTest');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: regOptions.challenge,
name: 'ReplayTest',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const authOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null)
.execute();
expect(authOptions.challenge).toBeTruthy();
expect(authOptions.rpId).toBeTruthy();
if (authOptions.rpId) {
device.rpId = authOptions.rpId;
}
const assertion = createAuthenticationResponse(device, authOptions);
await createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.body({
response: assertion,
challenge: authOptions.challenge,
})
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.body({
response: assertion,
challenge: authOptions.challenge,
})
.expect(401)
.execute();
});
});

View File

@@ -0,0 +1,103 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createAuthenticationResponse,
createRegistrationResponse,
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
type WebAuthnAuthenticationOptions,
type WebAuthnRegistrationOptions,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
describe('WebAuthn authentication wrong challenge', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('ensures WebAuthn auth fails when challenge is tampered', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (regOptions.rp.id) {
device.rpId = regOptions.rp.id;
}
const registrationResponse = createRegistrationResponse(device, regOptions, 'WrongChallenge');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: regOptions.challenge,
name: 'WrongChallenge',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const authOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null)
.execute();
if (authOptions.rpId) {
device.rpId = authOptions.rpId;
}
const assertion = createAuthenticationResponse(device, authOptions);
const badChallenge = `${authOptions.challenge}-tampered`;
await createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.body({
response: assertion,
challenge: badChallenge,
})
.expect(401, 'PASSKEY_AUTHENTICATION_FAILED')
.execute();
});
});

View File

@@ -0,0 +1,127 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createAuthenticationResponse,
createRegistrationResponse,
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
type WebAuthnAuthenticationOptions,
type WebAuthnCredentialMetadata,
type WebAuthnRegistrationOptions,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('WebAuthn credential delete', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('validates deleting a WebAuthn credential including sudo verification with WebAuthn', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (regOptions.rp.id) {
device.rpId = regOptions.rp.id;
}
const registrationResponse = createRegistrationResponse(device, regOptions, 'Passkey To Delete');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: regOptions.challenge,
name: 'Passkey To Delete',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const credentials1 = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
expect(credentials1).toHaveLength(1);
const credentialId = credentials1[0].id;
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: generateTotpCode(secret),
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, account.token)
.post('/users/@me/sudo/webauthn/authentication-options')
.body({})
.execute();
if (sudoOptions.rpId) {
device.rpId = sudoOptions.rpId;
}
const sudoAssertion = createAuthenticationResponse(device, sudoOptions);
await createBuilder(harness, account.token)
.delete(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
.body({
mfa_method: 'webauthn',
webauthn_response: sudoAssertion,
webauthn_challenge: sudoOptions.challenge,
})
.expect(204)
.execute();
const credentials2 = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
expect(credentials2).toHaveLength(0);
});
});

View File

@@ -0,0 +1,127 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createRegistrationResponse,
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
type WebAuthnCredentialMetadata,
type WebAuthnRegistrationOptions,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('WebAuthn credential list', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('validates listing all WebAuthn credentials for a user', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
const emptyCredentials = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
expect(emptyCredentials).toHaveLength(0);
const device1 = createWebAuthnDevice();
const regOptions1 = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (regOptions1.rp.id) {
device1.rpId = regOptions1.rp.id;
}
const registrationResponse1 = createRegistrationResponse(device1, regOptions1, 'First Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse1,
challenge: regOptions1.challenge,
name: 'First Passkey',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const device2 = createWebAuthnDevice();
const regOptions2 = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (regOptions2.rp.id) {
device2.rpId = regOptions2.rp.id;
}
const registrationResponse2 = createRegistrationResponse(device2, regOptions2, 'Second Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse2,
challenge: regOptions2.challenge,
name: 'Second Passkey',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const credentials = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
expect(credentials).toHaveLength(2);
const firstCred = credentials.find(
(c) => c.name === 'First Passkey' && c.id === device1.credentialId.toString('base64url'),
);
expect(firstCred).toBeDefined();
const secondCred = credentials.find(
(c) => c.name === 'Second Passkey' && c.id === device2.credentialId.toString('base64url'),
);
expect(secondCred).toBeDefined();
});
});

View File

@@ -0,0 +1,93 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createRegistrationResponse,
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
type WebAuthnCredentialMetadata,
type WebAuthnRegistrationOptions,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('WebAuthn credential registration', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('validates the WebAuthn credential registration flow', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
expect(regOptions.challenge).toBeTruthy();
expect(regOptions.rp.id).toBeTruthy();
expect(regOptions.user.id).toBeTruthy();
if (regOptions.rp.id) {
device.rpId = regOptions.rp.id;
}
const registrationResponse = createRegistrationResponse(device, regOptions, 'Test Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: regOptions.challenge,
name: 'Test Passkey',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const credentials = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
expect(credentials).toHaveLength(1);
expect(credentials[0].name).toBe('Test Passkey');
expect(credentials[0].id).toBe(device.credentialId.toString('base64url'));
});
});

View File

@@ -0,0 +1,108 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createRegistrationResponse,
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
type WebAuthnCredentialMetadata,
type WebAuthnRegistrationOptions,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('WebAuthn credential rename', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('validates renaming a WebAuthn credential', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (regOptions.rp.id) {
device.rpId = regOptions.rp.id;
}
const registrationResponse = createRegistrationResponse(device, regOptions, 'Original Name');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: regOptions.challenge,
name: 'Original Name',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const credentials1 = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
expect(credentials1).toHaveLength(1);
expect(credentials1[0].name).toBe('Original Name');
const credentialId = credentials1[0].id;
await createBuilder(harness, account.token)
.patch(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
.body({
name: 'Renamed Passkey',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const credentials2 = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
expect(credentials2).toHaveLength(1);
expect(credentials2[0].name).toBe('Renamed Passkey');
expect(credentials2[0].id).toBe(credentialId);
});
});

View File

@@ -0,0 +1,85 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createRegistrationResponse,
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
type WebAuthnRegistrationOptions,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('WebAuthn error localization', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('ensures WebAuthn registration errors return localized messages', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.expect(200)
.execute();
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (regOptions.rp.id) {
device.rpId = regOptions.rp.id;
}
const badChallenge = `${regOptions.challenge}-tampered`;
const registrationResponse = createRegistrationResponse(device, regOptions, 'Localized error');
const errResp = await createBuilder<{code: string; message: string}>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: badChallenge,
name: 'Localized error',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(400)
.execute();
expect(errResp.code).toBe('INVALID_WEBAUTHN_CREDENTIAL');
expect(errResp.message).toBe('Failed to verify WebAuthn credential.');
});
});

View File

@@ -0,0 +1,425 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createTestAccount, createTotpSecret, generateTotpCode} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createAuthenticationResponse,
createRegistrationResponse,
createWebAuthnDevice,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {beforeEach, describe, expect, test} from 'vitest';
interface BackupCodesResponse {
backup_codes: Array<{code: string}>;
}
interface LoginMfaResponse {
mfa: true;
ticket: string;
sms: boolean;
totp: boolean;
webauthn: boolean;
}
interface WebAuthnRegistrationOptions {
challenge: string;
rp: {
id: string;
name: string;
};
user: {
id: string;
name: string;
displayName: string;
};
}
interface WebAuthnAuthenticationOptions {
challenge: string;
rpId: string;
allowCredentials?: Array<{
id: string;
type: string;
}>;
userVerification: string;
}
describe('WebAuthn MFA Consistency Tests', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('WebAuthn-only user cannot use password for sudo - password rejected with 403', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
expect(login.mfa).toBe(true);
const mfaLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
code: backupCodes.backup_codes[0]!.code,
ticket: login.ticket,
})
.execute();
account.token = mfaLogin.token;
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[1]!.code,
})
.execute();
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: registrationOptions.challenge,
name: 'Test Passkey',
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[2]!.code,
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCodes.backup_codes[3]!.code,
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[4]!.code,
})
.expect(204)
.execute();
const discoverableOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null)
.execute();
const discoverableAssertion = createAuthenticationResponse(device, discoverableOptions);
const passkeyLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/webauthn/authenticate')
.body({
response: discoverableAssertion,
challenge: discoverableOptions.challenge,
})
.execute();
account.token = passkeyLogin.token;
const {json: errorResp} = await createBuilder<{code: string}>(harness, account.token)
.post('/users/@me/disable')
.body({
password: account.password,
})
.expect(403)
.executeWithResponse();
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
});
test('WebAuthn-only user can use WebAuthn for sudo verification', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
expect(login.mfa).toBe(true);
const mfaLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
code: backupCodes.backup_codes[0]!.code,
ticket: login.ticket,
})
.execute();
account.token = mfaLogin.token;
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[1]!.code,
})
.execute();
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: registrationOptions.challenge,
name: 'Test Passkey',
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[2]!.code,
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCodes.backup_codes[3]!.code,
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[4]!.code,
})
.expect(204)
.execute();
const discoverableOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null)
.execute();
const discoverableAssertion = createAuthenticationResponse(device, discoverableOptions);
const passkeyLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/webauthn/authenticate')
.body({
response: discoverableAssertion,
challenge: discoverableOptions.challenge,
})
.execute();
account.token = passkeyLogin.token;
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, account.token)
.post('/users/@me/sudo/webauthn/authentication-options')
.body(null)
.execute();
const sudoAssertion = createAuthenticationResponse(device, sudoOptions);
const {response: disableResp2} = await createBuilder(harness, account.token)
.post('/users/@me/disable')
.body({
mfa_method: 'webauthn',
webauthn_response: sudoAssertion,
webauthn_challenge: sudoOptions.challenge,
})
.expect(204)
.executeWithResponse();
const sudoToken = disableResp2.headers.get('x-sudo-mode-token');
expect(sudoToken).toBeNull();
});
test('WebAuthn-only user requires MFA when logging in with password', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
expect(login.mfa).toBe(true);
const mfaLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
code: backupCodes.backup_codes[0]!.code,
ticket: login.ticket,
})
.execute();
account.token = mfaLogin.token;
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[1]!.code,
})
.execute();
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: registrationOptions.challenge,
name: 'Test Passkey',
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[2]!.code,
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCodes.backup_codes[3]!.code,
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[4]!.code,
})
.expect(204)
.execute();
const login2 = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
expect(login2.mfa).toBe(true);
expect(login2.ticket).toBeTruthy();
expect(login2.webauthn).toBe(true);
});
test('WebAuthn-only user reports has_mfa=true on mfa-methods endpoint', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({
secret,
code: generateTotpCode(secret),
password: account.password,
})
.execute();
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
.post('/auth/login')
.body({
email: account.email,
password: account.password,
})
.execute();
expect(login.mfa).toBe(true);
const mfaLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/totp')
.body({
code: backupCodes.backup_codes[0]!.code,
ticket: login.ticket,
})
.execute();
account.token = mfaLogin.token;
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[1]!.code,
})
.execute();
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: registrationOptions.challenge,
name: 'Test Passkey',
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[2]!.code,
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCodes.backup_codes[3]!.code,
mfa_method: 'totp',
mfa_code: backupCodes.backup_codes[4]!.code,
})
.expect(204)
.execute();
const discoverableOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null)
.execute();
const discoverableAssertion = createAuthenticationResponse(device, discoverableOptions);
const passkeyLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/webauthn/authenticate')
.body({
response: discoverableAssertion,
challenge: discoverableOptions.challenge,
})
.execute();
account.token = passkeyLogin.token;
const mfaMethods = await createBuilder<{
totp: boolean;
sms: boolean;
webauthn: boolean;
has_mfa: boolean;
}>(harness, account.token)
.get('/users/@me/sudo/mfa-methods')
.execute();
expect(mfaMethods.has_mfa).toBe(true);
expect(mfaMethods.totp).toBe(false);
expect(mfaMethods.webauthn).toBe(true);
});
});

View File

@@ -0,0 +1,126 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createAuthHarness,
createTestAccount,
type LoginMfaResponse,
loginUser,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createAuthenticationResponse,
createRegistrationResponse,
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
type WebAuthnAuthenticationOptions,
type WebAuthnRegistrationOptions,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('WebAuthn MFA login', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('validates the WebAuthn MFA login flow', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (regOptions.rp.id) {
device.rpId = regOptions.rp.id;
}
const registrationResponse = createRegistrationResponse(device, regOptions, 'MFA Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: regOptions.challenge,
name: 'MFA Passkey',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const loginResp = await loginUser(harness, {email: account.email, password: account.password});
expect('mfa' in loginResp && loginResp.mfa).toBe(true);
const loginMfaResp = loginResp as LoginMfaResponse;
expect(loginMfaResp.ticket).toBeTruthy();
expect(loginMfaResp.allowed_methods).toContain('webauthn');
expect('token' in loginMfaResp).toBe(false);
const mfaOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/login/mfa/webauthn/authentication-options')
.body({ticket: loginMfaResp.ticket})
.execute();
expect(mfaOptions.challenge).toBeTruthy();
expect(mfaOptions.rpId).toBeTruthy();
expect(mfaOptions.userVerification).toBe('required');
expect(mfaOptions.allowCredentials).toBeTruthy();
expect(mfaOptions.allowCredentials!.length).toBeGreaterThan(0);
if (mfaOptions.rpId) {
device.rpId = mfaOptions.rpId;
}
const mfaAssertion = createAuthenticationResponse(device, mfaOptions);
const webauthnMfaLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/login/mfa/webauthn')
.body({
response: mfaAssertion,
challenge: mfaOptions.challenge,
ticket: (loginResp as LoginMfaResponse).ticket,
})
.execute();
expect(webauthnMfaLogin.token).toBeTruthy();
const userInfo = await createBuilder<{id: string}>(harness, webauthnMfaLogin.token).get('/users/@me').execute();
expect(userInfo.id).toBe(account.userId);
});
});

View File

@@ -0,0 +1,121 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createAuthenticationResponse,
createRegistrationResponse,
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
type WebAuthnAuthenticationOptions,
type WebAuthnRegistrationOptions,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('WebAuthn passwordless login', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('validates passwordless login using WebAuthn with discoverable credentials', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (regOptions.rp.id) {
device.rpId = regOptions.rp.id;
}
const registrationResponse = createRegistrationResponse(device, regOptions, 'Passwordless Passkey');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: regOptions.challenge,
name: 'Passwordless Passkey',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: generateTotpCode(secret),
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const discoverableOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null)
.execute();
expect(discoverableOptions.challenge).toBeTruthy();
expect(discoverableOptions.rpId).toBeTruthy();
expect(discoverableOptions.userVerification).toBe('required');
if (discoverableOptions.rpId) {
device.rpId = discoverableOptions.rpId;
}
const discoverableAssertion = createAuthenticationResponse(device, discoverableOptions);
const passkeyLogin = await createBuilderWithoutAuth<{token: string}>(harness)
.post('/auth/webauthn/authenticate')
.body({
response: discoverableAssertion,
challenge: discoverableOptions.challenge,
})
.execute();
expect(passkeyLogin.token).toBeTruthy();
const userInfo = await createBuilder<{id: string}>(harness, passkeyLogin.token).get('/users/@me').execute();
expect(userInfo.id).toBe(account.userId);
});
});

View File

@@ -0,0 +1,64 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createTotpSecret,
decodeBase64URL,
generateTotpCode,
type WebAuthnRegistrationOptions,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('WebAuthn registration user handle', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('ensures registration options carry the stable user identifier', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.expect(200)
.execute();
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
const handle = decodeBase64URL(regOptions.user.id).toString();
expect(handle).toBe(account.userId);
});
});

View File

@@ -0,0 +1,456 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
createHash,
createHmac,
createPrivateKey,
createPublicKey,
createSign,
generateKeyPairSync,
randomBytes,
} from 'node:crypto';
import {decode as base32Decode, encode as base32Encode} from 'hi-base32';
export interface WebAuthnDevice {
privateKey: string;
publicKey: string;
credentialId: Buffer;
userHandle: Buffer;
rpId: string;
origin: string;
signCount: number;
}
export interface WebAuthnRegistrationOptions {
challenge: string;
rp: {
id: string;
name: string;
};
user: {
id: string;
name: string;
displayName: string;
};
}
export interface WebAuthnAuthenticationOptions {
challenge: string;
rpId: string;
allowCredentials?: Array<{
id: string;
type: string;
}>;
userVerification: string;
}
export interface WebAuthnCredentialMetadata {
id: string;
name: string;
}
export interface AuthenticatorAttestationResponse {
clientDataJSON: string;
attestationObject: string;
transports: Array<string>;
}
export interface AuthenticatorAssertionResponse {
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle: string;
}
export interface WebAuthnRegistrationResponse {
id: string;
rawId: string;
type: string;
clientExtensionResults: Record<string, unknown>;
response: AuthenticatorAttestationResponse;
}
export interface WebAuthnAuthenticationResponse {
id: string;
rawId: string;
type: string;
clientExtensionResults: Record<string, unknown>;
response: AuthenticatorAssertionResponse;
}
export function createTotpSecret(): string {
const buf = randomBytes(20);
return base32Encode(buf).replace(/=/g, '');
}
export function generateTotpCode(secret: string, time = Date.now()): string {
const key = Buffer.from(base32Decode.asBytes(secret.toUpperCase()));
const epoch = Math.floor(time / 1000);
const counter = Math.floor(epoch / 30);
const counterBuf = Buffer.alloc(8);
counterBuf.writeBigUInt64BE(BigInt(counter));
const hmac = createHmac('sha1', key);
hmac.update(counterBuf);
const hash = hmac.digest();
const offset = hash[hash.length - 1] & 0x0f;
const binary =
((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
const otp = binary % 1_000_000;
return otp.toString().padStart(6, '0');
}
export function resolveWebAuthnOrigin(): {rpId: string; origin: string} {
const origin = process.env.FLUXER_WEBAPP_ORIGIN || 'http://localhost:8088';
try {
const url = new URL(origin);
return {rpId: url.hostname, origin};
} catch {
return {rpId: 'localhost', origin};
}
}
export function createWebAuthnDevice(): WebAuthnDevice {
const {privateKey, publicKey} = generateKeyPairSync('ec', {
namedCurve: 'P-256',
});
const credentialId = randomBytes(32);
const {rpId, origin} = resolveWebAuthnOrigin();
return {
privateKey: privateKey.export({type: 'pkcs8', format: 'der'}).toString('base64'),
publicKey: publicKey.export({type: 'spki', format: 'der'}).toString('base64'),
credentialId,
userHandle: Buffer.alloc(0),
rpId,
origin,
signCount: 0,
};
}
export function encodeBase64URL(data: Buffer): string {
return data.toString('base64url');
}
export function decodeBase64URL(value: string): Buffer {
try {
return Buffer.from(value, 'base64url');
} catch {
return Buffer.from(value, 'base64');
}
}
function padCoordinate(bytes: Buffer): Buffer {
if (bytes.length === 32) return bytes;
const padded = Buffer.alloc(32);
const offset = 32 - bytes.length;
bytes.copy(padded, offset);
return padded;
}
function encodeCBOR(value: unknown): Uint8Array {
if (value instanceof Map) {
const items: Array<Uint8Array> = [];
for (const [k, v] of value.entries()) {
items.push(encodeCBOR(k));
items.push(encodeCBOR(v));
}
return encodeCBORMap(items.length, Buffer.concat(items));
}
if (typeof value === 'number') {
if (value >= 0 && value <= 23) {
return new Uint8Array([value]);
}
if (value >= 24 && value <= 255) {
return new Uint8Array([0x18, value]);
}
if (value >= 256 && value <= 65535) {
const buf = Buffer.alloc(2);
buf.writeUInt16BE(value);
return new Uint8Array([0x19, ...buf]);
}
if (value < 0 && value >= -24) {
return new Uint8Array([0x20 + Math.abs(value) - 1]);
}
}
if (typeof value === 'string') {
const buf = Buffer.from(value);
if (buf.length <= 23) {
return new Uint8Array([0x60 + buf.length, ...buf]);
}
if (buf.length <= 255) {
return new Uint8Array([0x78, buf.length, ...buf]);
}
}
if (Array.isArray(value)) {
const items: Array<Uint8Array> = value.map(encodeCBOR);
return encodeCBORArray(items.length, Buffer.concat(items));
}
if (Buffer.isBuffer(value)) {
if (value.length <= 23) {
return new Uint8Array([0x40 + value.length, ...value]);
}
if (value.length <= 255) {
return new Uint8Array([0x58, value.length, ...value]);
}
}
if (value === null) {
return new Uint8Array([0xf6]);
}
if (typeof value === 'object' && value !== null) {
return new Uint8Array([0xa0]);
}
throw new Error(`Unsupported CBOR value: ${typeof value}`);
}
function encodeCBORMap(length: number, data: Buffer): Uint8Array {
if (length <= 23) {
return new Uint8Array([0xa0 + length, ...data]);
}
if (length <= 255) {
return new Uint8Array([0xb8, length, ...data]);
}
throw new Error('Map too large');
}
function encodeCBORArray(length: number, data: Buffer): Uint8Array {
if (length <= 23) {
return new Uint8Array([0x80 + length, ...data]);
}
if (length <= 255) {
return new Uint8Array([0x98, length, ...data]);
}
throw new Error('Array too large');
}
function buildAttestationObject(authData: Buffer): Buffer {
const payload = new Map<number, unknown>([
[3, 'none'],
[2, new Map()],
[1, authData],
]);
const cbor = encodeCBOR(payload);
return Buffer.from(cbor);
}
function buildRegistrationAuthData(device: WebAuthnDevice): Buffer {
const rpHash = createHash('sha256').update(device.rpId).digest();
const flags = 0x01 | 0x04 | 0x40;
const privateKeyObj = createPrivateKey({
key: Buffer.from(device.privateKey, 'base64'),
format: 'der',
type: 'pkcs8',
});
const publicKeyObj = createPublicKey(privateKeyObj);
const pubKeyDer = publicKeyObj.export({type: 'spki', format: 'der'});
const pubKeyBuf = Buffer.from(pubKeyDer);
let x: Buffer, y: Buffer;
const asn1Offset = pubKeyBuf.indexOf(Buffer.from([0x30, 0x59, 0x30, 0x13]));
if (asn1Offset > 0 && pubKeyBuf.length >= asn1Offset + 68) {
x = pubKeyBuf.slice(asn1Offset + 4 + 3, asn1Offset + 4 + 35);
y = pubKeyBuf.slice(asn1Offset + 4 + 36, asn1Offset + 4 + 68);
} else {
x = randomBytes(32);
y = randomBytes(32);
}
const paddedX = padCoordinate(x);
const paddedY = padCoordinate(y);
const key = new Map<number, unknown>([
[1, 2],
[3, -7],
[-1, 1],
[-2, Array.from(paddedX)],
[-3, Array.from(paddedY)],
]);
const coseKey = encodeCBOR(key);
const buf = Buffer.concat([
rpHash,
Buffer.from([flags]),
Buffer.from([
(device.signCount >> 24) & 0xff,
(device.signCount >> 16) & 0xff,
(device.signCount >> 8) & 0xff,
device.signCount & 0xff,
]),
Buffer.alloc(16),
Buffer.from([(device.credentialId.length >> 8) & 0xff, device.credentialId.length & 0xff]),
device.credentialId,
Buffer.from(coseKey),
]);
return buf;
}
function buildAssertionAuthData(device: WebAuthnDevice, includeUV = true): Buffer {
const rpHash = createHash('sha256').update(device.rpId).digest();
const flags = includeUV ? 0x01 | 0x04 : 0x01;
device.signCount++;
const buf = Buffer.concat([
rpHash,
Buffer.from([flags]),
Buffer.from([
(device.signCount >> 24) & 0xff,
(device.signCount >> 16) & 0xff,
(device.signCount >> 8) & 0xff,
device.signCount & 0xff,
]),
]);
return buf;
}
function signWithPrivateKey(device: WebAuthnDevice, data: Buffer): Buffer {
const sign = createSign('SHA256');
sign.update(data);
sign.end();
const privateKeyObj = createPrivateKey({
key: Buffer.from(device.privateKey, 'base64'),
format: 'der',
type: 'pkcs8',
});
return Buffer.from(sign.sign(privateKeyObj));
}
export function createRegistrationResponse(
device: WebAuthnDevice,
options: WebAuthnRegistrationOptions,
_name: string,
): WebAuthnRegistrationResponse {
const challenge = decodeBase64URL(options.challenge);
device.userHandle = decodeBase64URL(options.user.id);
if (options.rp.id) {
device.rpId = options.rp.id;
}
const clientData = {
type: 'webauthn.create',
challenge: encodeBase64URL(challenge),
origin: device.origin,
crossOrigin: false,
};
const clientDataJSON = Buffer.from(JSON.stringify(clientData));
const authData = buildRegistrationAuthData(device);
const attestationObject = buildAttestationObject(authData);
return {
id: encodeBase64URL(device.credentialId),
rawId: encodeBase64URL(device.credentialId),
type: 'public-key',
clientExtensionResults: {},
response: {
clientDataJSON: encodeBase64URL(clientDataJSON),
attestationObject: encodeBase64URL(attestationObject),
transports: ['internal'],
},
};
}
export function createAuthenticationResponse(
device: WebAuthnDevice,
options: WebAuthnAuthenticationOptions,
): WebAuthnAuthenticationResponse {
const challenge = decodeBase64URL(options.challenge);
if (options.rpId) {
device.rpId = options.rpId;
}
const clientData = {
type: 'webauthn.get',
challenge: encodeBase64URL(challenge),
origin: device.origin,
crossOrigin: false,
};
const clientDataJSON = Buffer.from(JSON.stringify(clientData));
const authData = buildAssertionAuthData(device, true);
const clientDataHash = createHash('sha256').update(clientDataJSON).digest();
const sigInput = Buffer.concat([authData, clientDataHash]);
const signature = signWithPrivateKey(device, sigInput);
return {
id: encodeBase64URL(device.credentialId),
rawId: encodeBase64URL(device.credentialId),
type: 'public-key',
clientExtensionResults: {},
response: {
clientDataJSON: encodeBase64URL(clientDataJSON),
authenticatorData: encodeBase64URL(authData),
signature: encodeBase64URL(signature),
userHandle: encodeBase64URL(device.userHandle),
},
};
}
export function createAuthenticationResponseWithoutUV(
device: WebAuthnDevice,
options: WebAuthnAuthenticationOptions,
): WebAuthnAuthenticationResponse {
const challenge = decodeBase64URL(options.challenge);
if (options.rpId) {
device.rpId = options.rpId;
}
const clientData = {
type: 'webauthn.get',
challenge: encodeBase64URL(challenge),
origin: device.origin,
crossOrigin: false,
};
const clientDataJSON = Buffer.from(JSON.stringify(clientData));
const authData = buildAssertionAuthData(device, false);
const clientDataHash = createHash('sha256').update(clientDataJSON).digest();
const sigInput = Buffer.concat([authData, clientDataHash]);
const signature = signWithPrivateKey(device, sigInput);
return {
id: encodeBase64URL(device.credentialId),
rawId: encodeBase64URL(device.credentialId),
type: 'public-key',
clientExtensionResults: {},
response: {
clientDataJSON: encodeBase64URL(clientDataJSON),
authenticatorData: encodeBase64URL(authData),
signature: encodeBase64URL(signature),
userHandle: encodeBase64URL(device.userHandle),
},
};
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createAuthHarness, createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createAuthenticationResponseWithoutUV,
createRegistrationResponse,
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
type WebAuthnAuthenticationOptions,
type WebAuthnRegistrationOptions,
} from '@fluxer/api/src/auth/tests/WebAuthnTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('WebAuthn user verification required', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('ensures WebAuthn authentication requires user verification (UV flag set)', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
.execute();
if (regOptions.rp.id) {
device.rpId = regOptions.rp.id;
}
const registrationResponse = createRegistrationResponse(device, regOptions, 'UV required');
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: registrationResponse,
challenge: regOptions.challenge,
name: 'UV required',
mfa_method: 'totp',
mfa_code: generateTotpCode(secret),
})
.expect(204)
.execute();
const authOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null)
.execute();
expect(authOptions.userVerification).toBe('required');
if (authOptions.rpId) {
device.rpId = authOptions.rpId;
}
const assertion = createAuthenticationResponseWithoutUV(device, authOptions);
await createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.body({
response: assertion,
challenge: authOptions.challenge,
})
.execute();
expect(authOptions.userVerification).toBe('required');
});
});