Files
fluxer/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts
T

693 lines
19 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import AccountManager from '@app/features/auth/state/AccountManager';
import type {UserData} from '@app/features/auth/state/AccountStorage';
import Authentication from '@app/features/auth/state/Authentication';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {failureCode, ipAuthorizationRequiredResponseFromError} from '@app/features/platform/utils/ResponseInspection';
import UserSettings from '@app/features/user/state/UserSettings';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
import type {
AuthRegistrationPendingApprovalResponse,
RegisterRequest,
SsoCompleteResponse,
SsoStartRequest,
SsoStartResponse,
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
const logger = new Logger('AuthService');
const withAuthLocaleHeader = (headers?: Record<string, string>): Record<string, string> => ({
'Accept-Language': UserSettings.getLocale(),
...(headers ?? {}),
});
export const VerificationResult = {
SUCCESS: 'SUCCESS',
EXPIRED_TOKEN: 'EXPIRED_TOKEN',
RATE_LIMITED: 'RATE_LIMITED',
SERVER_ERROR: 'SERVER_ERROR',
} as const;
export type VerificationResult = ValueOf<typeof VerificationResult>;
type CaptchaType = 'turnstile' | 'hcaptcha';
type RegisterData = RegisterRequest & {
captchaToken?: string;
captchaType?: CaptchaType;
};
export type AuthResponseUser = UserPartial & {
email?: string | null;
};
export interface AuthTokenResponse {
user_id: string;
token: string;
theme?: string;
redirect_to?: string;
user?: AuthResponseUser | null;
}
interface StandardLoginResponse extends AuthTokenResponse {
mfa?: false;
}
interface MfaLoginResponse {
mfa: true;
ticket: string;
totp: boolean;
webauthn: boolean;
allowed_methods?: Array<string>;
}
type LoginResponse = StandardLoginResponse | MfaLoginResponse;
export interface IpAuthorizationRequiredResponse {
ip_authorization_required: true;
ticket: string;
email: string;
resend_available_in: number;
}
export function authResponseUserToUserData(user?: AuthResponseUser | null): UserData | undefined {
if (!user) {
return undefined;
}
const userData: UserData = {
username: user.username,
discriminator: user.discriminator,
globalName: user.global_name,
avatar: user.avatar,
};
if (user.email !== undefined) {
userData.email = user.email;
}
return userData;
}
export type TokenResponse = AuthTokenResponse;
export type RegistrationPendingApprovalResponse = AuthRegistrationPendingApprovalResponse;
export type RegisterResponse = TokenResponse | RegistrationPendingApprovalResponse;
export function isIpAuthorizationRequiredResponse(
response: LoginResponse | IpAuthorizationRequiredResponse,
): response is IpAuthorizationRequiredResponse {
return (response as IpAuthorizationRequiredResponse).ip_authorization_required === true;
}
export function isRegistrationPendingApprovalResponse(
response: RegisterResponse,
): response is RegistrationPendingApprovalResponse {
return 'registration_pending_approval' in response && response.registration_pending_approval === true;
}
export type ResetPasswordResponse = AuthTokenResponse | MfaLoginResponse;
interface DesktopHandoffInitiateResponse {
code: string;
expires_at: string;
poll_secret?: string;
}
interface DesktopHandoffStatusResponse {
status: 'pending' | 'completed' | 'expired';
token?: string;
user_id?: string;
user?: AuthResponseUser | null;
}
interface DesktopHandoffInfoClientInfo {
platform?: string | null;
os?: string | null;
location?: {
city?: string | null;
region?: string | null;
country?: string | null;
} | null;
}
export interface DesktopHandoffInfoResponse {
status: 'pending' | 'expired';
client_info?: DesktopHandoffInfoClientInfo | null;
}
interface LoginParams {
email: string;
password: string;
captchaToken?: string;
inviteCode?: string;
captchaType?: CaptchaType;
}
interface CaptchaParams {
captchaToken?: string;
captchaType?: CaptchaType;
}
function captchaHeaders({captchaToken, captchaType}: CaptchaParams): Record<string, string> {
if (!captchaToken) {
return {};
}
return {
'X-Captcha-Token': captchaToken,
'X-Captcha-Type': captchaType || 'hcaptcha',
};
}
function withInviteCode<T extends object>(body: T, inviteCode?: string): T & {invite_code?: string} {
return inviteCode ? {...body, invite_code: inviteCode} : body;
}
function loginBody({email, password, inviteCode}: Pick<LoginParams, 'email' | 'password' | 'inviteCode'>): {
email: string;
password: string;
invite_code?: string;
} {
return withInviteCode({email, password}, inviteCode);
}
function mfaTotpBody(
code: string,
ticket: string,
inviteCode?: string,
): {code: string; ticket: string; invite_code?: string} {
return withInviteCode({code, ticket}, inviteCode);
}
function mfaWebAuthnBody(
response: AuthenticationResponseJSON,
challenge: string,
ticket: string,
inviteCode?: string,
): {response: AuthenticationResponseJSON; challenge: string; ticket: string; invite_code?: string} {
return withInviteCode({response, challenge, ticket}, inviteCode);
}
function webAuthnBody(
response: AuthenticationResponseJSON,
challenge: string,
inviteCode?: string,
): {response: AuthenticationResponseJSON; challenge: string; invite_code?: string} {
return withInviteCode({response, challenge}, inviteCode);
}
function registerBody(data: RegisterData): RegisterRequest {
const {captchaToken: _, captchaType: __, ...bodyData} = data;
return bodyData;
}
function ticketBody(ticket: string): {ticket: string} {
return {ticket};
}
function tokenBody(token: string): {token: string} {
return {token};
}
export class MalformedIpAuthorizationChallengeError extends HttpError {
constructor(error: HttpError) {
super({
method: error.method,
path: error.path,
status: error.status,
body: error.body,
responseHeaders: error.responseHeaders,
});
this.name = 'MalformedIpAuthorizationChallengeError';
}
}
function loginIpAuthorizationResponse(error: HttpError): IpAuthorizationRequiredResponse | null {
if (error.status !== 403 || failureCode(error) !== APIErrorCodes.IP_AUTHORIZATION_REQUIRED) {
return null;
}
const challenge = ipAuthorizationRequiredResponseFromError(error);
if (challenge === null) {
throw new MalformedIpAuthorizationChallengeError(error);
}
return challenge;
}
function verificationResultFromError(
error: unknown,
invalidResult: VerificationResult,
invalidStatus: number,
): VerificationResult {
const responseErr = error as {
status?: number;
};
return responseErr.status === invalidStatus ? invalidResult : VerificationResult.SERVER_ERROR;
}
export async function login({
email,
password,
captchaToken,
inviteCode,
captchaType,
}: LoginParams): Promise<LoginResponse | IpAuthorizationRequiredResponse> {
try {
const response = await http.post<LoginResponse>(Endpoints.AUTH_LOGIN, {
body: loginBody({email, password, inviteCode}),
headers: withAuthLocaleHeader(captchaHeaders({captchaToken, captchaType})),
});
logger.debug('Login successful', {mfa: response.body?.mfa});
return response.body;
} catch (error) {
if (error instanceof HttpError) {
const ipAuthorization = loginIpAuthorizationResponse(error);
if (ipAuthorization) {
logger.info('Login requires IP authorization', {email});
return ipAuthorization;
}
}
logger.error('Login failed', error);
throw error;
}
}
export async function loginMfaTotp(code: string, ticket: string, inviteCode?: string): Promise<TokenResponse> {
try {
const response = await http.post<TokenResponse>(Endpoints.AUTH_LOGIN_MFA_TOTP, {
body: mfaTotpBody(code, ticket, inviteCode),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('MFA TOTP authentication successful');
return responseBody;
} catch (error) {
logger.error('MFA TOTP authentication failed', error);
throw error;
}
}
export async function loginMfaWebAuthn(
response: AuthenticationResponseJSON,
challenge: string,
ticket: string,
inviteCode?: string,
): Promise<TokenResponse> {
try {
const httpResponse = await http.post<TokenResponse>(Endpoints.AUTH_LOGIN_MFA_WEBAUTHN, {
body: mfaWebAuthnBody(response, challenge, ticket, inviteCode),
headers: withAuthLocaleHeader(),
});
const responseBody = httpResponse.body;
logger.debug('MFA WebAuthn authentication successful');
return responseBody;
} catch (error) {
logger.error('MFA WebAuthn authentication failed', error);
throw error;
}
}
export async function getWebAuthnMfaOptions(ticket: string): Promise<PublicKeyCredentialRequestOptionsJSON> {
try {
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.AUTH_LOGIN_MFA_WEBAUTHN_OPTIONS, {
body: ticketBody(ticket),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('WebAuthn MFA options retrieved');
return responseBody;
} catch (error) {
logger.error('Failed to get WebAuthn MFA options', error);
throw error;
}
}
export async function getWebAuthnAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
try {
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.AUTH_WEBAUTHN_OPTIONS, {
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('WebAuthn authentication options retrieved');
return responseBody;
} catch (error) {
logger.error('Failed to get WebAuthn authentication options', error);
throw error;
}
}
export async function authenticateWithWebAuthn(
response: AuthenticationResponseJSON,
challenge: string,
inviteCode?: string,
): Promise<TokenResponse> {
try {
const httpResponse = await http.post<TokenResponse>(Endpoints.AUTH_WEBAUTHN_AUTHENTICATE, {
body: webAuthnBody(response, challenge, inviteCode),
headers: withAuthLocaleHeader(),
});
const responseBody = httpResponse.body;
logger.debug('WebAuthn authentication successful');
return responseBody;
} catch (error) {
logger.error('WebAuthn authentication failed', error);
throw error;
}
}
export async function register(data: RegisterData): Promise<RegisterResponse> {
try {
const response = await http.post<RegisterResponse>(Endpoints.AUTH_REGISTER, {
body: registerBody(data),
headers: withAuthLocaleHeader(captchaHeaders(data)),
});
const responseBody = response.body;
logger.info('Registration successful');
return responseBody;
} catch (error) {
logger.error('Registration failed', error);
throw error;
}
}
interface UsernameSuggestionsResponse {
suggestions: Array<string>;
}
export async function getUsernameSuggestions(globalName: string): Promise<Array<string>> {
try {
const response = await http.post<UsernameSuggestionsResponse>(Endpoints.AUTH_USERNAME_SUGGESTIONS, {
body: {global_name: globalName},
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('Username suggestions retrieved', {count: responseBody?.suggestions?.length || 0});
return responseBody?.suggestions ?? [];
} catch (error) {
logger.error('Failed to fetch username suggestions', error);
throw error;
}
}
export async function forgotPassword(
email: string,
captchaToken?: string,
captchaType?: 'turnstile' | 'hcaptcha',
): Promise<void> {
try {
await http.post(Endpoints.AUTH_FORGOT_PASSWORD, {
body: {email},
headers: withAuthLocaleHeader(captchaHeaders({captchaToken, captchaType})),
});
logger.debug('Password reset email sent');
} catch (error) {
logger.warn('Password reset request failed, but returning success to user', error);
}
}
export async function validateResetPasswordToken(token: string): Promise<boolean> {
try {
const response = await http.get<{
valid: boolean;
}>(Endpoints.AUTH_VALIDATE_RESET_PASSWORD_TOKEN(token), {
headers: withAuthLocaleHeader(),
});
return response.body.valid;
} catch (error) {
logger.error('Password reset token validation failed', error);
throw error;
}
}
export async function resetPassword(token: string, password: string): Promise<ResetPasswordResponse> {
try {
const response = await http.post<ResetPasswordResponse>(Endpoints.AUTH_RESET_PASSWORD, {
body: {token, password},
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.info('Password reset successful');
return responseBody;
} catch (error) {
logger.error('Password reset failed', error);
throw error;
}
}
export async function revertEmailChange(token: string, password: string): Promise<TokenResponse> {
try {
const response = await http.post<TokenResponse>(Endpoints.AUTH_EMAIL_REVERT, {
body: {token, password},
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.info('Email revert successful');
return responseBody;
} catch (error) {
logger.error('Email revert failed', error);
throw error;
}
}
export async function verifyEmail(token: string): Promise<VerificationResult> {
try {
await http.post(Endpoints.AUTH_VERIFY_EMAIL, {
body: tokenBody(token),
headers: withAuthLocaleHeader(),
});
logger.info('Email verification successful');
return VerificationResult.SUCCESS;
} catch (error) {
const result = verificationResultFromError(error, VerificationResult.EXPIRED_TOKEN, 400);
if (result === VerificationResult.EXPIRED_TOKEN) {
logger.warn('Email verification failed - expired or invalid token');
return result;
}
logger.error('Email verification failed - server error', error);
return result;
}
}
export async function resendVerificationEmail(): Promise<VerificationResult> {
try {
await http.post(Endpoints.AUTH_RESEND_VERIFICATION, {
headers: withAuthLocaleHeader(),
});
logger.info('Verification email resent');
return VerificationResult.SUCCESS;
} catch (error) {
const result = verificationResultFromError(error, VerificationResult.RATE_LIMITED, 429);
if (result === VerificationResult.RATE_LIMITED) {
logger.warn('Rate limited when resending verification email');
return result;
}
logger.error('Failed to resend verification email - server error', error);
return result;
}
}
export async function logout(): Promise<void> {
await AccountManager.logout();
}
export async function authorizeIp(token: string): Promise<VerificationResult> {
try {
await http.post(Endpoints.AUTH_AUTHORIZE_IP, {
body: tokenBody(token),
headers: withAuthLocaleHeader(),
});
logger.info('IP authorization successful');
return VerificationResult.SUCCESS;
} catch (error) {
const result = verificationResultFromError(error, VerificationResult.EXPIRED_TOKEN, 400);
if (result === VerificationResult.EXPIRED_TOKEN) {
logger.warn('IP authorization failed - expired or invalid token');
return result;
}
logger.error('IP authorization failed - server error', error);
return result;
}
}
export async function resendIpAuthorization(ticket: string): Promise<void> {
await http.post(Endpoints.AUTH_IP_AUTHORIZATION_RESEND, {
body: ticketBody(ticket),
headers: withAuthLocaleHeader(),
});
}
export interface IpAuthorizationPollResult {
completed: boolean;
token?: string;
user_id?: string;
user?: AuthResponseUser | null;
}
export async function pollIpAuthorization(ticket: string): Promise<IpAuthorizationPollResult> {
const response = await http.get<IpAuthorizationPollResult>(Endpoints.AUTH_IP_AUTHORIZATION_POLL(ticket), {
headers: withAuthLocaleHeader(),
});
return response.body;
}
export async function initiateDesktopHandoff(): Promise<DesktopHandoffInitiateResponse> {
const response = await http.post<DesktopHandoffInitiateResponse>(Endpoints.AUTH_HANDOFF_INITIATE, {
auth: 'none',
});
return response.body;
}
export async function pollDesktopHandoffStatus(
code: string,
pollSecret?: string | null,
): Promise<DesktopHandoffStatusResponse> {
if (pollSecret == null || pollSecret.length === 0) {
const response = await http.get<DesktopHandoffStatusResponse>(Endpoints.AUTH_HANDOFF_STATUS(code), {
auth: 'none',
});
return response.body;
}
const response = await http.post<DesktopHandoffStatusResponse>(Endpoints.AUTH_HANDOFF_STATUS(code), {
auth: 'none',
body: {poll_secret: pollSecret},
});
return response.body;
}
export async function completeDesktopHandoff({
code,
token,
userId,
}: {
code: string;
token: string;
userId: string;
}): Promise<void> {
await http.post(Endpoints.AUTH_HANDOFF_COMPLETE, {
body: {code, user_id: userId},
headers: withAuthLocaleHeader({Authorization: token}),
auth: 'none',
});
}
export async function fetchDesktopHandoffInfo(code: string): Promise<DesktopHandoffInfoResponse> {
const response = await http.get<DesktopHandoffInfoResponse>(Endpoints.AUTH_HANDOFF_INFO(code), {
auth: 'none',
});
return response.body;
}
export function startSession(
token: string,
options: {
startGateway?: boolean;
} = {},
): void {
const {startGateway = true} = options;
logger.info('Starting new session');
Authentication.handleSessionStart({token});
if (!startGateway) {
return;
}
GatewayConnection.startSession(token);
}
let sessionStartInProgress = false;
export async function ensureSessionStarted(): Promise<void> {
if (sessionStartInProgress) {
return;
}
if (AccountManager.isSwitching) {
return;
}
if (!Authentication.isAuthenticated) {
return;
}
if (GatewayConnection.isConnected || GatewayConnection.isConnecting) {
return;
}
if (GatewayConnection.socket) {
return;
}
sessionStartInProgress = true;
try {
logger.info('Ensuring session is started');
const token = Authentication.authToken;
if (token) {
GatewayConnection.startSession(token);
}
} finally {
setTimeout(() => {
sessionStartInProgress = false;
}, 100);
}
}
export interface CompleteLoginOptions {
redirectPath?: string | null;
}
export async function completeLogin(
{
token,
userId,
userData,
}: {
token: string;
userId: string;
userData?: UserData;
},
options: CompleteLoginOptions = {},
): Promise<void> {
logger.info('Completing login process');
if (userId && token) {
await AccountManager.switchToNewAccount(userId, token, userData, options.redirectPath);
} else {
startSession(token, {startGateway: true});
}
}
export async function startSso({
redirectTo,
redirectUri,
}: {
redirectTo?: string;
redirectUri?: string;
} = {}): Promise<SsoStartResponse> {
const body: SsoStartRequest = {
redirect_to: redirectTo,
redirect_uri: redirectUri,
};
const response = await http.post<SsoStartResponse>(Endpoints.AUTH_SSO_START, {
body,
headers: withAuthLocaleHeader(),
});
return response.body;
}
export async function completeSso({code, state}: {code: string; state: string}): Promise<SsoCompleteResponse> {
const response = await http.post<SsoCompleteResponse>(Endpoints.AUTH_SSO_COMPLETE, {
body: {code, state},
headers: withAuthLocaleHeader(),
});
return response.body;
}
interface SetMfaTicketPayload {
ticket: string;
totp: boolean;
webauthn: boolean;
}
export function setMfaTicket({ticket, totp, webauthn}: SetMfaTicketPayload): void {
logger.debug('Setting MFA ticket');
Authentication.handleMfaTicketSet({ticket, totp, webauthn});
}
export function clearMfaTicket(): void {
logger.debug('Clearing MFA ticket');
Authentication.handleMfaTicketClear();
}