fix(api): identify session clients correctly and attribute handoff sessions (#1727)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-19 00:41:29 +02:00
1 parent 5660972c71
commit 9b0b703c9d
27 files changed
+589 -269

No files matched your search

@@ -6,7 +6,6 @@ import {THE_OTHER_PLATFORM} from '@fluxer/constants/src/ExternalPlatformConstant
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME, PRODUCT_NAME} from './ProductConstants';
export {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME, PRODUCT_NAME};
export const DESKTOP_PRODUCT_NAME = `${PRODUCT_NAME} Desktop`;
export const PRODUCT_API_NAME = `${PRODUCT_NAME} API`;
export const PRODUCT_HQ_COMMUNITY_NAME = `${PRODUCT_NAME} HQ`;
export const CANARY_RELEASE_CHANNEL_NAME = `${PRODUCT_NAME} Canary`;
@@ -9,7 +9,6 @@ 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} from '@app/features/platform/utils/ResponseInspection';
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import UserSettings from '@app/features/user/state/UserSettings';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
@@ -24,9 +23,7 @@ import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSche
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
const logger = new Logger('AuthService');
const getPlatformHeaderValue = (): 'web' | 'desktop' | 'mobile' => (isDesktop() ? 'desktop' : 'web');
const withPlatformHeader = (headers?: Record<string, string>): Record<string, string> => ({
'X-Fluxer-Platform': getPlatformHeaderValue(),
const withAuthLocaleHeader = (headers?: Record<string, string>): Record<string, string> => ({
'Accept-Language': UserSettings.getLocale(),
...(headers ?? {}),
});
@@ -247,7 +244,7 @@ export async function login({
try {
const response = await http.post<LoginResponse>(Endpoints.AUTH_LOGIN, {
body: loginBody({email, password, inviteCode}),
headers: withPlatformHeader(captchaHeaders({captchaToken, captchaType})),
headers: withAuthLocaleHeader(captchaHeaders({captchaToken, captchaType})),
});
logger.debug('Login successful', {mfa: response.body?.mfa});
return response.body;
@@ -268,7 +265,7 @@ export async function loginMfaTotp(code: string, ticket: string, inviteCode?: st
try {
const response = await http.post<TokenResponse>(Endpoints.AUTH_LOGIN_MFA_TOTP, {
body: mfaTotpBody(code, ticket, inviteCode),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('MFA TOTP authentication successful');
@@ -288,7 +285,7 @@ export async function loginMfaWebAuthn(
try {
const httpResponse = await http.post<TokenResponse>(Endpoints.AUTH_LOGIN_MFA_WEBAUTHN, {
body: mfaWebAuthnBody(response, challenge, ticket, inviteCode),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = httpResponse.body;
logger.debug('MFA WebAuthn authentication successful');
@@ -303,7 +300,7 @@ export async function getWebAuthnMfaOptions(ticket: string): Promise<PublicKeyCr
try {
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.AUTH_LOGIN_MFA_WEBAUTHN_OPTIONS, {
body: ticketBody(ticket),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('WebAuthn MFA options retrieved');
@@ -317,7 +314,7 @@ export async function getWebAuthnMfaOptions(ticket: string): Promise<PublicKeyCr
export async function getWebAuthnAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
try {
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.AUTH_WEBAUTHN_OPTIONS, {
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('WebAuthn authentication options retrieved');
@@ -336,7 +333,7 @@ export async function authenticateWithWebAuthn(
try {
const httpResponse = await http.post<TokenResponse>(Endpoints.AUTH_WEBAUTHN_AUTHENTICATE, {
body: webAuthnBody(response, challenge, inviteCode),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = httpResponse.body;
logger.debug('WebAuthn authentication successful');
@@ -351,7 +348,7 @@ export async function register(data: RegisterData): Promise<RegisterResponse> {
try {
const response = await http.post<RegisterResponse>(Endpoints.AUTH_REGISTER, {
body: registerBody(data),
headers: withPlatformHeader(captchaHeaders(data)),
headers: withAuthLocaleHeader(captchaHeaders(data)),
});
const responseBody = response.body;
logger.info('Registration successful');
@@ -370,7 +367,7 @@ export async function getUsernameSuggestions(globalName: string): Promise<Array<
try {
const response = await http.post<UsernameSuggestionsResponse>(Endpoints.AUTH_USERNAME_SUGGESTIONS, {
body: {global_name: globalName},
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('Username suggestions retrieved', {count: responseBody?.suggestions?.length || 0});
@@ -389,7 +386,7 @@ export async function forgotPassword(
try {
await http.post(Endpoints.AUTH_FORGOT_PASSWORD, {
body: {email},
headers: withPlatformHeader(captchaHeaders({captchaToken, captchaType})),
headers: withAuthLocaleHeader(captchaHeaders({captchaToken, captchaType})),
});
logger.debug('Password reset email sent');
} catch (error) {
@@ -402,7 +399,7 @@ export async function validateResetPasswordToken(token: string): Promise<boolean
const response = await http.get<{
valid: boolean;
}>(Endpoints.AUTH_VALIDATE_RESET_PASSWORD_TOKEN(token), {
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
return response.body.valid;
} catch (error) {
@@ -415,7 +412,7 @@ export async function resetPassword(token: string, password: string): Promise<Re
try {
const response = await http.post<ResetPasswordResponse>(Endpoints.AUTH_RESET_PASSWORD, {
body: {token, password},
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.info('Password reset successful');
@@ -430,7 +427,7 @@ export async function revertEmailChange(token: string, password: string): Promis
try {
const response = await http.post<TokenResponse>(Endpoints.AUTH_EMAIL_REVERT, {
body: {token, password},
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.info('Email revert successful');
@@ -445,7 +442,7 @@ export async function verifyEmail(token: string): Promise<VerificationResult> {
try {
await http.post(Endpoints.AUTH_VERIFY_EMAIL, {
body: tokenBody(token),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
logger.info('Email verification successful');
return VerificationResult.SUCCESS;
@@ -463,7 +460,7 @@ export async function verifyEmail(token: string): Promise<VerificationResult> {
export async function resendVerificationEmail(): Promise<VerificationResult> {
try {
await http.post(Endpoints.AUTH_RESEND_VERIFICATION, {
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
logger.info('Verification email resent');
return VerificationResult.SUCCESS;
@@ -486,7 +483,7 @@ export async function authorizeIp(token: string): Promise<VerificationResult> {
try {
await http.post(Endpoints.AUTH_AUTHORIZE_IP, {
body: tokenBody(token),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
logger.info('IP authorization successful');
return VerificationResult.SUCCESS;
@@ -504,7 +501,7 @@ export async function authorizeIp(token: string): Promise<VerificationResult> {
export async function resendIpAuthorization(ticket: string): Promise<void> {
await http.post(Endpoints.AUTH_IP_AUTHORIZATION_RESEND, {
body: ticketBody(ticket),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
}
@@ -517,7 +514,7 @@ export interface IpAuthorizationPollResult {
export async function pollIpAuthorization(ticket: string): Promise<IpAuthorizationPollResult> {
const response = await http.get<IpAuthorizationPollResult>(Endpoints.AUTH_IP_AUTHORIZATION_POLL(ticket), {
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
return response.body;
}
@@ -547,7 +544,7 @@ export async function completeDesktopHandoff({
}): Promise<void> {
await http.post(Endpoints.AUTH_HANDOFF_COMPLETE, {
body: {code, user_id: userId},
headers: withPlatformHeader({Authorization: token}),
headers: withAuthLocaleHeader({Authorization: token}),
auth: 'none',
});
}
@@ -643,7 +640,7 @@ export async function startSso({
};
const response = await http.post<SsoStartResponse>(Endpoints.AUTH_SSO_START, {
body,
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
return response.body;
}
@@ -651,7 +648,7 @@ export async function startSso({
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: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
return response.body;
}
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import type {DesktopHandoffInfoResponse} from '@app/features/auth/commands/AuthenticationCommands';
import type {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
import styles from '@app/features/auth/flow/HandoffApprovalFlow.module.css';
@@ -18,11 +17,6 @@ const SIGN_IN_CODE_DESCRIPTOR = msg({
message: 'Sign-in code',
comment: 'Short label in the authentication handoff approval flow. Keep the tone plain and specific.',
});
const PRODUCT_DESKTOP_DESCRIPTOR = msg({
message: '{productName} Desktop',
comment:
'Display name for the product desktop client in the authentication handoff approval flow when the raw client name is Electron. Preserve {productName}; it is inserted by code.',
});
const CODE_LENGTH = 12;
const VALID_CODE_PATTERN = /^[A-Za-z0-9]{12}$/;
@@ -53,10 +47,6 @@ function formatLocation(location: {
return parts.length > 0 ? parts.join(', ') : null;
}
function isElectronClientLabel(label: string): boolean {
return label.trim().toLowerCase() === 'electron';
}
interface HandoffApprovalFlowProps {
mode: DesktopHandoffMode;
error: string | null;
@@ -164,11 +154,6 @@ export function HandoffApprovalFlow({
const os = clientInfo?.os ?? null;
const location = clientInfo?.location ? formatLocation(clientInfo.location) : null;
const hasAnyDeviceInfo = Boolean(platform || os || location);
const platformLabel = platform
? isElectronClientLabel(platform)
? i18n._(PRODUCT_DESKTOP_DESCRIPTOR, {productName: PRODUCT_NAME})
: platform
: null;
return (
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--4">
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--3">
@@ -183,13 +168,13 @@ export function HandoffApprovalFlow({
</p>
{hasAnyDeviceInfo ? (
<div className={styles.deviceCard} data-flx="auth.flow.handoff-approval-flow.device-card">
{platformLabel ? (
{platform ? (
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row">
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label">
<Trans>Platform</Trans>
</span>
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value">
{platformLabel}
{platform}
</span>
</div>
) : null}
@@ -7,6 +7,8 @@ export class AuthSession {
readonly approxLastUsedAt: Date | null;
readonly clientOs: string | null;
readonly clientPlatform: string | null;
readonly clientBrowser: string | null;
readonly clientDevice: 'mobile' | 'desktop';
readonly clientLocation: string | null;
readonly maskedIp: string | null;
readonly isCurrent: boolean;
@@ -18,6 +20,8 @@ export class AuthSession {
this.clientInfo = data.client_info ?? null;
this.clientOs = this.clientInfo?.os ?? null;
this.clientPlatform = this.clientInfo?.platform ?? null;
this.clientBrowser = this.clientInfo?.browser ?? null;
this.clientDevice = this.clientInfo?.device ?? 'desktop';
this.clientLocation = getLocationLabel(this.clientInfo?.location ?? null);
this.maskedIp = data.masked_ip ?? null;
this.isCurrent = data.current;
@@ -8,7 +8,6 @@ import {
SettingsTabContent,
SettingsTabSection,
} from '@app/features/app/components/dialogs/shared/SettingsTabLayout';
import {DESKTOP_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {useElementOverflow} from '@app/features/app/hooks/useTextOverflow';
import * as AuthSessionCommands from '@app/features/auth/commands/AuthSessionCommands';
import {DeviceRevokeModal} from '@app/features/auth/components/modals/DeviceRevokeModal';
@@ -75,7 +74,6 @@ const SELECTED_DEVICES_FOR_LOGOUT_DESCRIPTOR = msg({
message: '{deviceCount, plural, one {# device selected for sign out} other {# devices selected for sign out}}',
comment: 'Unsaved-changes banner text in the devices tab. Counts selected devices that will be signed out.',
});
const MOBILE_DEVICE_REGEX = /iOS|Android|Windows Phone|BlackBerry|Mobile/i;
const StatusDot = observer(() => (
<div aria-hidden={true} className={styles.statusDot} data-flx="user.devices-tab.status-dot.status-dot" />
@@ -127,8 +125,7 @@ const DeviceDetailRow = ({
const DeviceDetailsModal = observer(({authSession, isCurrent}: {authSession: AuthSession; isCurrent: boolean}) => {
const {i18n} = useLingui();
const clientOs = authSession.clientOs ?? i18n._(UNKNOWN_DEVICE_DESCRIPTOR);
const clientPlatform = authSession.clientPlatform ?? i18n._(UNKNOWN_DESCRIPTOR);
const platformLabel = authSession.clientPlatform === DESKTOP_PRODUCT_NAME ? DESKTOP_PRODUCT_NAME : clientPlatform;
const platformLabel = authSession.clientPlatform ?? i18n._(UNKNOWN_DESCRIPTOR);
return (
<Modal.Root size="small" centered data-flx="user.devices-tab.device-details-modal.modal-root">
<Modal.Header title={<Trans>Device details</Trans>} data-flx="user.devices-tab.device-details-modal.header" />
@@ -196,8 +193,8 @@ const AuthSessionItem: React.FC<AuthSessionProps> = observer(
({authSession, isCurrent = false, isSelected, onSelect, index, selectionMode}) => {
const {i18n} = useLingui();
const clientOs = authSession.clientOs ?? i18n._(UNKNOWN_DEVICE_DESCRIPTOR);
const clientPlatform = authSession.clientPlatform ?? i18n._(UNKNOWN_DESCRIPTOR);
const isMobile = MOBILE_DEVICE_REGEX.test(authSession.clientOs ?? '');
const platformLabel = authSession.clientPlatform ?? i18n._(UNKNOWN_DESCRIPTOR);
const isMobile = authSession.clientDevice === 'mobile';
const isSelectionInteractive = Boolean(selectionMode && !isCurrent && onSelect && index !== undefined);
const openRevokeModal = () => {
ModalCommands.push(
@@ -234,7 +231,6 @@ const AuthSessionItem: React.FC<AuthSessionProps> = observer(
selected: isSelected,
onSelect,
});
const platformLabel = authSession.clientPlatform === DESKTOP_PRODUCT_NAME ? DESKTOP_PRODUCT_NAME : clientPlatform;
const metadataLine = [authSession.clientLocation, isCurrent ? null : formatAuthSessionLastUsed(authSession, i18n)]
.filter((value): value is string => Boolean(value))
.join(' · ');
@@ -254,9 +250,15 @@ const AuthSessionItem: React.FC<AuthSessionProps> = observer(
</div>
<div className={styles.authSessionInfo} data-flx="user.devices-tab.auth-session-item.auth-session-info">
<span className={styles.authSessionTitle} data-flx="user.devices-tab.auth-session-item.auth-session-title">
{clientOs}
<StatusDot data-flx="user.devices-tab.auth-session-item.status-dot" />
{platformLabel}
{authSession.clientBrowser === null ? (
platformLabel
) : (
<>
{clientOs}
<StatusDot data-flx="user.devices-tab.auth-session-item.status-dot" />
{platformLabel}
</>
)}
</span>
{metadataLine && (
<div