refactor progress
This commit is contained in:
670
packages/api/src/auth/AuthController.tsx
Normal file
670
packages/api/src/auth/AuthController.tsx
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
93
packages/api/src/auth/AuthModel.tsx
Normal file
93
packages/api/src/auth/AuthModel.tsx
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
321
packages/api/src/auth/AuthRequestService.tsx
Normal file
321
packages/api/src/auth/AuthRequestService.tsx
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
602
packages/api/src/auth/AuthService.tsx
Normal file
602
packages/api/src/auth/AuthService.tsx
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
152
packages/api/src/auth/services/AuthEmailRevertService.tsx
Normal file
152
packages/api/src/auth/services/AuthEmailRevertService.tsx
Normal 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};
|
||||
}
|
||||
}
|
||||
136
packages/api/src/auth/services/AuthEmailService.tsx
Normal file
136
packages/api/src/auth/services/AuthEmailService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
600
packages/api/src/auth/services/AuthLoginService.tsx
Normal file
600
packages/api/src/auth/services/AuthLoginService.tsx
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
715
packages/api/src/auth/services/AuthMfaService.tsx
Normal file
715
packages/api/src/auth/services/AuthMfaService.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
311
packages/api/src/auth/services/AuthPasswordService.tsx
Normal file
311
packages/api/src/auth/services/AuthPasswordService.tsx
Normal 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};
|
||||
}
|
||||
}
|
||||
206
packages/api/src/auth/services/AuthPhoneService.tsx
Normal file
206
packages/api/src/auth/services/AuthPhoneService.tsx
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
717
packages/api/src/auth/services/AuthRegistrationService.tsx
Normal file
717
packages/api/src/auth/services/AuthRegistrationService.tsx
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
181
packages/api/src/auth/services/AuthSessionService.tsx
Normal file
181
packages/api/src/auth/services/AuthSessionService.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
171
packages/api/src/auth/services/AuthUtilityService.tsx
Normal file
171
packages/api/src/auth/services/AuthUtilityService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
142
packages/api/src/auth/services/DesktopHandoffService.tsx
Normal file
142
packages/api/src/auth/services/DesktopHandoffService.tsx
Normal 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}`);
|
||||
}
|
||||
}
|
||||
626
packages/api/src/auth/services/SsoService.tsx
Normal file
626
packages/api/src/auth/services/SsoService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
114
packages/api/src/auth/services/SsoUtils.tsx
Normal file
114
packages/api/src/auth/services/SsoUtils.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
75
packages/api/src/auth/services/SudoModeService.tsx
Normal file
75
packages/api/src/auth/services/SudoModeService.tsx
Normal 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;
|
||||
}
|
||||
158
packages/api/src/auth/services/SudoVerificationService.tsx
Normal file
158
packages/api/src/auth/services/SudoVerificationService.tsx
Normal 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;
|
||||
}
|
||||
84
packages/api/src/auth/tests/AppStoreReviewerBypass.test.tsx
Normal file
84
packages/api/src/auth/tests/AppStoreReviewerBypass.test.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
147
packages/api/src/auth/tests/AuthSudoMFAMethods.test.tsx
Normal file
147
packages/api/src/auth/tests/AuthSudoMFAMethods.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
309
packages/api/src/auth/tests/AuthSudoRequiredOperations.test.tsx
Normal file
309
packages/api/src/auth/tests/AuthSudoRequiredOperations.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
240
packages/api/src/auth/tests/AuthSudoTOTPVerification.test.tsx
Normal file
240
packages/api/src/auth/tests/AuthSudoTOTPVerification.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
426
packages/api/src/auth/tests/AuthTestUtils.tsx
Normal file
426
packages/api/src/auth/tests/AuthTestUtils.tsx
Normal 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();
|
||||
}
|
||||
123
packages/api/src/auth/tests/BouncedEmailRecoveryFlow.test.tsx
Normal file
123
packages/api/src/auth/tests/BouncedEmailRecoveryFlow.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
188
packages/api/src/auth/tests/CaseInsensitiveEmail.test.tsx
Normal file
188
packages/api/src/auth/tests/CaseInsensitiveEmail.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
132
packages/api/src/auth/tests/ConcurrentSessions.test.tsx
Normal file
132
packages/api/src/auth/tests/ConcurrentSessions.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
124
packages/api/src/auth/tests/DesktopHandoffFlow.test.tsx
Normal file
124
packages/api/src/auth/tests/DesktopHandoffFlow.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
67
packages/api/src/auth/tests/DesktopHandoffNegative.test.tsx
Normal file
67
packages/api/src/auth/tests/DesktopHandoffNegative.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
89
packages/api/src/auth/tests/DesktopHandoffUsage.test.tsx
Normal file
89
packages/api/src/auth/tests/DesktopHandoffUsage.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
342
packages/api/src/auth/tests/EmailChangeFlow.test.tsx
Normal file
342
packages/api/src/auth/tests/EmailChangeFlow.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
161
packages/api/src/auth/tests/EmailChangeResendCooldown.test.tsx
Normal file
161
packages/api/src/auth/tests/EmailChangeResendCooldown.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
246
packages/api/src/auth/tests/EmailRevertFlow.test.tsx
Normal file
246
packages/api/src/auth/tests/EmailRevertFlow.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
63
packages/api/src/auth/tests/EmailVerificationFlow.test.tsx
Normal file
63
packages/api/src/auth/tests/EmailVerificationFlow.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
246
packages/api/src/auth/tests/IPAuthBypassFlags.test.tsx
Normal file
246
packages/api/src/auth/tests/IPAuthBypassFlags.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
134
packages/api/src/auth/tests/IPAuthFlow.test.tsx
Normal file
134
packages/api/src/auth/tests/IPAuthFlow.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
123
packages/api/src/auth/tests/IPAuthMultipleIPs.test.tsx
Normal file
123
packages/api/src/auth/tests/IPAuthMultipleIPs.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
205
packages/api/src/auth/tests/IPAuthPoll.test.tsx
Normal file
205
packages/api/src/auth/tests/IPAuthPoll.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
379
packages/api/src/auth/tests/IPAuthResend.test.tsx
Normal file
379
packages/api/src/auth/tests/IPAuthResend.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
145
packages/api/src/auth/tests/IPAuthStream.test.tsx
Normal file
145
packages/api/src/auth/tests/IPAuthStream.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
125
packages/api/src/auth/tests/IPAuthTicket.test.tsx
Normal file
125
packages/api/src/auth/tests/IPAuthTicket.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
193
packages/api/src/auth/tests/IPAuthTokenValidation.test.tsx
Normal file
193
packages/api/src/auth/tests/IPAuthTokenValidation.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
173
packages/api/src/auth/tests/LoginAndSessions.test.tsx
Normal file
173
packages/api/src/auth/tests/LoginAndSessions.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
96
packages/api/src/auth/tests/LoginInvalidCredentials.test.tsx
Normal file
96
packages/api/src/auth/tests/LoginInvalidCredentials.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
101
packages/api/src/auth/tests/LoginInviteAutoJoin.test.tsx
Normal file
101
packages/api/src/auth/tests/LoginInviteAutoJoin.test.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
65
packages/api/src/auth/tests/LoginInviteInvalidCode.test.tsx
Normal file
65
packages/api/src/auth/tests/LoginInviteInvalidCode.test.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
563
packages/api/src/auth/tests/MfaConsistency.test.tsx
Normal file
563
packages/api/src/auth/tests/MfaConsistency.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
186
packages/api/src/auth/tests/MfaEndpoints.test.tsx
Normal file
186
packages/api/src/auth/tests/MfaEndpoints.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
127
packages/api/src/auth/tests/MfaSmsEnableDisable.test.tsx
Normal file
127
packages/api/src/auth/tests/MfaSmsEnableDisable.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
128
packages/api/src/auth/tests/MfaSmsLoginFlow.test.tsx
Normal file
128
packages/api/src/auth/tests/MfaSmsLoginFlow.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
110
packages/api/src/auth/tests/MfaTicketExpiryAndReuse.test.tsx
Normal file
110
packages/api/src/auth/tests/MfaTicketExpiryAndReuse.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
163
packages/api/src/auth/tests/MfaTotpFlag.test.tsx
Normal file
163
packages/api/src/auth/tests/MfaTotpFlag.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
135
packages/api/src/auth/tests/MfaTotpWithoutSecret.test.tsx
Normal file
135
packages/api/src/auth/tests/MfaTotpWithoutSecret.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
134
packages/api/src/auth/tests/PasswordChange.test.tsx
Normal file
134
packages/api/src/auth/tests/PasswordChange.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
104
packages/api/src/auth/tests/PasswordReset.test.tsx
Normal file
104
packages/api/src/auth/tests/PasswordReset.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
115
packages/api/src/auth/tests/PhoneVerificationFlow.test.tsx
Normal file
115
packages/api/src/auth/tests/PhoneVerificationFlow.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
283
packages/api/src/auth/tests/Registration.test.tsx
Normal file
283
packages/api/src/auth/tests/Registration.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
170
packages/api/src/auth/tests/RegistrationValidation.test.tsx
Normal file
170
packages/api/src/auth/tests/RegistrationValidation.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
119
packages/api/src/auth/tests/ResetPasswordRequiresMfa.test.tsx
Normal file
119
packages/api/src/auth/tests/ResetPasswordRequiresMfa.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
66
packages/api/src/auth/tests/SettingsDefaults.test.tsx
Normal file
66
packages/api/src/auth/tests/SettingsDefaults.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
545
packages/api/src/auth/tests/SsoFlow.test.tsx
Normal file
545
packages/api/src/auth/tests/SsoFlow.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
349
packages/api/src/auth/tests/SudoModeNegativeCases.test.tsx
Normal file
349
packages/api/src/auth/tests/SudoModeNegativeCases.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
89
packages/api/src/auth/tests/TokenValidation.test.tsx
Normal file
89
packages/api/src/auth/tests/TokenValidation.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
153
packages/api/src/auth/tests/UnclaimedClaimFlow.test.tsx
Normal file
153
packages/api/src/auth/tests/UnclaimedClaimFlow.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
127
packages/api/src/auth/tests/WebAuthnCredentialDelete.test.tsx
Normal file
127
packages/api/src/auth/tests/WebAuthnCredentialDelete.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
127
packages/api/src/auth/tests/WebAuthnCredentialList.test.tsx
Normal file
127
packages/api/src/auth/tests/WebAuthnCredentialList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
108
packages/api/src/auth/tests/WebAuthnCredentialRename.test.tsx
Normal file
108
packages/api/src/auth/tests/WebAuthnCredentialRename.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
425
packages/api/src/auth/tests/WebAuthnMfaConsistency.test.tsx
Normal file
425
packages/api/src/auth/tests/WebAuthnMfaConsistency.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
126
packages/api/src/auth/tests/WebAuthnMfaLogin.test.tsx
Normal file
126
packages/api/src/auth/tests/WebAuthnMfaLogin.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
121
packages/api/src/auth/tests/WebAuthnPasswordlessLogin.test.tsx
Normal file
121
packages/api/src/auth/tests/WebAuthnPasswordlessLogin.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
456
packages/api/src/auth/tests/WebAuthnTestUtils.tsx
Normal file
456
packages/api/src/auth/tests/WebAuthnTestUtils.tsx
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user