diff --git a/fluxer_api/src/api/admin/services/AdminUserSecurityService.ts b/fluxer_api/src/api/admin/services/AdminUserSecurityService.ts index a3563a853..67cbcc1e3 100644 --- a/fluxer_api/src/api/admin/services/AdminUserSecurityService.ts +++ b/fluxer_api/src/api/admin/services/AdminUserSecurityService.ts @@ -41,10 +41,11 @@ import * as AuthUtility from '../../auth/AuthUtility'; import {createPasswordResetToken, createUserID, type UserID} from '../../BrandedTypes'; import type {UserRow} from '../../database/types/UserTypes'; import {Logger} from '../../Logger'; +import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons'; import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository'; import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes'; import {getIpAddressReverse, getLocationLabelFromIp} from '../../utils/IpUtils'; -import {resolveSessionClientInfo} from '../../utils/UserAgentUtils'; +import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity'; import {mapUserToAdminResponse} from '../models/UserTypes'; import type {AdminAuditService} from './AdminAuditService'; import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator'; @@ -749,7 +750,7 @@ export class AdminUserSecurityService { approximateLastUsedAt: Date; clientIp: string; clientUserAgent: string | null; - clientIsDesktop: boolean | null; + clientOs: string | null; deletedAt: Date | null; }> = [ ...activeSessions.map((s) => ({ @@ -758,7 +759,7 @@ export class AdminUserSecurityService { approximateLastUsedAt: s.approximateLastUsedAt, clientIp: s.clientIp, clientUserAgent: s.clientUserAgent, - clientIsDesktop: s.clientIsDesktop, + clientOs: s.clientOs ?? null, deletedAt: null as Date | null, })), ...tombstones.map((t) => ({ @@ -767,7 +768,7 @@ export class AdminUserSecurityService { approximateLastUsedAt: t.approximateLastUsedAt, clientIp: t.clientIp, clientUserAgent: t.clientUserAgent, - clientIsDesktop: t.clientIsDesktop, + clientOs: t.clientOs ?? null, deletedAt: t.deletedAt, })), ]; @@ -776,6 +777,8 @@ export class AdminUserSecurityService { if (a.deletedAt !== null && b.deletedAt === null) return 1; return b.createdAt.getTime() - a.createdAt.getTime(); }); + const {branding} = await getInstanceConfigRepository().getAppPublicConfig(); + const productName = branding.product_name; const canViewIp = acls.has(AdminACLs.USER_VIEW_IP) || acls.has(AdminACLs.WILDCARD); if (!canViewIp) { await auditService.createAuditLog({ @@ -788,9 +791,10 @@ export class AdminUserSecurityService { }); return { sessions: entries.map((entry) => { - const {clientOs, clientPlatform} = resolveSessionClientInfo({ + const clientInfo = resolveSessionClientInfo({ userAgent: entry.clientUserAgent, - isDesktopClient: entry.clientIsDesktop, + reportedOs: entry.clientOs, + productName, }); return { session_id_hash: entry.sessionIdHash.toString('base64url'), @@ -798,8 +802,8 @@ export class AdminUserSecurityService { approx_last_used_at: entry.approximateLastUsedAt.toISOString(), client_ip: '[redacted]', client_ip_reverse: null, - client_os: clientOs, - client_platform: clientPlatform, + client_os: clientInfo.os, + client_platform: clientInfo.platform, client_location: null, deleted_at: entry.deletedAt?.toISOString() ?? null, }; @@ -836,9 +840,10 @@ export class AdminUserSecurityService { const clientLocation = locationResult.status === 'fulfilled' ? locationResult.value : null; const reverseDnsResult = reverseDnsResults[index]; const clientIpReverse = reverseDnsResult?.status === 'fulfilled' ? reverseDnsResult.value : null; - const {clientOs, clientPlatform} = resolveSessionClientInfo({ + const clientInfo = resolveSessionClientInfo({ userAgent: entry.clientUserAgent, - isDesktopClient: entry.clientIsDesktop, + reportedOs: entry.clientOs, + productName, }); return { session_id_hash: entry.sessionIdHash.toString('base64url'), @@ -846,8 +851,8 @@ export class AdminUserSecurityService { approx_last_used_at: entry.approximateLastUsedAt.toISOString(), client_ip: entry.clientIp, client_ip_reverse: clientIpReverse, - client_os: clientOs, - client_platform: clientPlatform, + client_os: clientInfo.os, + client_platform: clientInfo.platform, client_location: clientLocation, deleted_at: entry.deletedAt?.toISOString() ?? null, }; diff --git a/fluxer_api/src/api/auth/AuthController.ts b/fluxer_api/src/api/auth/AuthController.ts index b3117b36a..3cff71596 100644 --- a/fluxer_api/src/api/auth/AuthController.ts +++ b/fluxer_api/src/api/auth/AuthController.ts @@ -551,18 +551,7 @@ export function AuthController(app: HonoApp) { 'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.', }), async (ctx) => { - const clientIp = requireClientIp(ctx.req.raw, { - trustClientIpHeader: Config.proxy.trust_client_ip_header, - clientIpHeaderName: Config.proxy.client_ip_header, - }); - const clientPlatform = ctx.req.header('x-fluxer-platform')?.trim().toLowerCase() ?? undefined; - return ctx.json( - await ctx.get('authRequestService').initiateHandoff({ - userAgent: ctx.req.header('User-Agent'), - clientIp, - clientPlatform, - }), - ); + return ctx.json(await ctx.get('authRequestService').initiateHandoff({request: ctx.req.raw})); }, ); app.get( @@ -611,7 +600,6 @@ export function AuthController(app: HonoApp) { }); await ctx.get('authRequestService').completeHandoff({ data: ctx.req.valid('json'), - request: ctx.req.raw, clientIp, authToken: ctx.get('authToken') ?? undefined, }); diff --git a/fluxer_api/src/api/auth/AuthEmailRevert.ts b/fluxer_api/src/api/auth/AuthEmailRevert.ts index 8b6c3efb0..9db018413 100644 --- a/fluxer_api/src/api/auth/AuthEmailRevert.ts +++ b/fluxer_api/src/api/auth/AuthEmailRevert.ts @@ -100,7 +100,10 @@ export async function revertEmailChange( event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser), }); - const [authToken] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request}); + const [authToken] = await AuthSession.createAuthSession(ctx, { + user: updatedUser, + origin: AuthSession.resolveSessionOrigin(ctx, request), + }); await contactChangeLog.recordDiff({ oldUser: user, newUser: updatedUser, diff --git a/fluxer_api/src/api/auth/AuthLogin.ts b/fluxer_api/src/api/auth/AuthLogin.ts index ca993857d..eedd354b8 100644 --- a/fluxer_api/src/api/auth/AuthLogin.ts +++ b/fluxer_api/src/api/auth/AuthLogin.ts @@ -105,9 +105,7 @@ export interface IpAuthorizationTicketCache { userId: string; email: string; username: string; - clientIp: string; - userAgent: string; - platform: string | null; + origin: AuthSession.SessionOrigin; authToken: string; clientLocation: string; inviteCode?: string | null; @@ -115,8 +113,8 @@ export interface IpAuthorizationTicketCache { createdAt: number; } -function getTicketCacheKey(ticket: string): string { - return `ip-auth-ticket:${ticket}`; +export function getTicketCacheKey(ticket: string): string { + return `ip-auth-ticket-v2:${ticket}`; } function getTokenCacheKey(token: string): string { @@ -148,7 +146,7 @@ export async function resendIpAuthorization( payload.email, payload.username, payload.authToken, - payload.clientIp, + payload.origin.ip, payload.clientLocation, null, ); @@ -172,7 +170,7 @@ export async function completeIpAuthorization( user_id: string; ticket: string; }> { - const {users, cache, config} = ctx.services; + const {users, cache} = ctx.services; const tokenMapping = await cache.get<{ ticket: string; }>(getTokenCacheKey(token)); @@ -193,19 +191,8 @@ export async function completeIpAuthorization( throw new UnknownUserError(); } AuthUtility.assertNonBotUser(ctx, user); - await users.createAuthorizedIp(user.id, payload.clientIp); - const headers: Record = { - [config.proxy.client_ip_header]: 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 AuthSession.createAuthSession(ctx, {user, request: syntheticRequest}); + await users.createAuthorizedIp(user.id, payload.origin.ip); + const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, origin: payload.origin}); await cache.delete(cacheKey); await cache.delete(getTokenCacheKey(token)); return {token: sessionToken, user_id: user.id.toString(), ticket: tokenMapping.ticket}; @@ -313,15 +300,11 @@ export async function login( const authToken = createIpAuthorizationToken(await AuthUtility.generateSecureToken(ctx)); 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, + origin: AuthSession.resolveSessionOrigin(ctx, request), authToken, clientLocation, inviteCode: data.invite_code ?? null, @@ -329,7 +312,7 @@ export async function login( createdAt: Date.now(), }; const ttlSeconds = seconds('15 minutes'); - await cache.set(`ip-auth-ticket:${ticket}`, cachePayload, ttlSeconds); + await cache.set(getTicketCacheKey(ticket), cachePayload, ttlSeconds); await cache.set<{ ticket: string; }>(`ip-auth-token:${authToken}`, {ticket}, ttlSeconds); @@ -364,7 +347,10 @@ export async function login( Logger.warn({inviteCode: data.invite_code, error}, 'Failed to auto-join invite on login'); } } - const [token] = await AuthSession.createAuthSession(ctx, {user: currentUser, request}); + const [token] = await AuthSession.createAuthSession(ctx, { + user: currentUser, + origin: AuthSession.resolveSessionOrigin(ctx, request), + }); return { user_id: currentUser.id.toString(), token, @@ -418,7 +404,10 @@ export async function loginMfaTotp( await cache.delete(`mfa-ticket:${ticket}`); await cache.delete(attemptsKey); await cache.delete(userAttemptsKey); - const [token] = await AuthSession.createAuthSession(ctx, {user, request}); + const [token] = await AuthSession.createAuthSession(ctx, { + user, + origin: AuthSession.resolveSessionOrigin(ctx, request), + }); return {user_id: user.id.toString(), token}; } @@ -438,7 +427,10 @@ export async function loginMfaWebAuthn( AuthUtility.assertNonBotUser(ctx, user); await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket); await cache.delete(`mfa-ticket:${ticket}`); - const [token] = await AuthSession.createAuthSession(ctx, {user, request}); + const [token] = await AuthSession.createAuthSession(ctx, { + user, + origin: AuthSession.resolveSessionOrigin(ctx, request), + }); return {user_id: user.id.toString(), token}; } diff --git a/fluxer_api/src/api/auth/AuthModel.ts b/fluxer_api/src/api/auth/AuthModel.ts index 931f9919a..bf6b9458c 100644 --- a/fluxer_api/src/api/auth/AuthModel.ts +++ b/fluxer_api/src/api/auth/AuthModel.ts @@ -5,9 +5,10 @@ import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSche import {uint8ArrayToBase64} from 'uint8array-extras'; import {Config} from '../Config'; import {Logger} from '../Logger'; +import {getInstanceConfigRepository} from '../middleware/ServiceSingletons'; import type {AuthSession} from '../models/AuthSession'; import {getLocationLabelFromIp} from '../utils/IpUtils'; -import {resolveSessionClientInfo} from '../utils/UserAgentUtils'; +import {resolveSessionClientInfo} from '../utils/SessionClientIdentity'; const DEV_FALLBACK_AUTH_SESSION_LOCATION = 'Stockholm, Stockholm County, Sweden'; @@ -40,30 +41,24 @@ export async function mapAuthSessionsToResponse({ const locationResults = await Promise.allSettled( sortedSessions.map((session) => resolveAuthSessionLocation(session)), ); + const {branding} = await getInstanceConfigRepository().getAppPublicConfig(); 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 clientInfo = resolveSessionClientInfo({ + userAgent: authSession.clientUserAgent, + reportedOs: authSession.clientOs ?? null, + productName: branding.product_name, + }); 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, + platform: clientInfo.platform, + os: clientInfo.os, + browser: clientInfo.browser, + device: clientInfo.device, location: clientLocation ? { city: clientLocation.split(',').at(0)?.trim() || null, diff --git a/fluxer_api/src/api/auth/AuthPassword.ts b/fluxer_api/src/api/auth/AuthPassword.ts index a4f70cf52..64054b252 100644 --- a/fluxer_api/src/api/auth/AuthPassword.ts +++ b/fluxer_api/src/api/auth/AuthPassword.ts @@ -275,7 +275,10 @@ export async function resetPassword( if (hasMfa) { return await createMfaTicketResponse(ctx, updatedUser); } - const [token] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request}); + const [token] = await AuthSession.createAuthSession(ctx, { + user: updatedUser, + origin: AuthSession.resolveSessionOrigin(ctx, request), + }); return {user_id: updatedUser.id.toString(), token}; } diff --git a/fluxer_api/src/api/auth/AuthRegistration.ts b/fluxer_api/src/api/auth/AuthRegistration.ts index b0ca2b30c..a5109298f 100644 --- a/fluxer_api/src/api/auth/AuthRegistration.ts +++ b/fluxer_api/src/api/auth/AuthRegistration.ts @@ -444,7 +444,10 @@ export async function register( ); } await singleCommunityService.joinStockCommunity(userId, requestCache); - const [token] = await AuthSession.createAuthSession(ctx, {user, request}); + const [token] = await AuthSession.createAuthSession(ctx, { + user, + origin: AuthSession.resolveSessionOrigin(ctx, request), + }); if (grantBootstrapAdmin) { await instanceConfigRepository.markAdminBootstrapped(); } diff --git a/fluxer_api/src/api/auth/AuthRequestService.ts b/fluxer_api/src/api/auth/AuthRequestService.ts index 0a06b696a..734ed717c 100644 --- a/fluxer_api/src/api/auth/AuthRequestService.ts +++ b/fluxer_api/src/api/auth/AuthRequestService.ts @@ -33,11 +33,12 @@ import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResp import type {ApiContext} from '../ApiContext'; import {createUserID, type UserID} from '../BrandedTypes'; import type {RequestCache} from '../middleware/RequestCacheMiddleware'; +import {getInstanceConfigRepository} from '../middleware/ServiceSingletons'; import type {User} from '../models/User'; import {mapUserToPartialResponse} from '../user/UserMappers'; import {lookupGeoip} from '../utils/IpUtils'; import {parseJsonRecord} from '../utils/JsonBoundaryUtils'; -import {resolveSessionClientInfo} from '../utils/UserAgentUtils'; +import {resolveSessionClientInfo} from '../utils/SessionClientIdentity'; import {generateUsernameSuggestions} from '../utils/UsernameSuggestionUtils'; import * as AuthEmail from './AuthEmail'; import * as AuthEmailRevert from './AuthEmailRevert'; @@ -89,7 +90,6 @@ interface AuthLogoutRequest { interface AuthHandoffCompleteRequest { data: HandoffCompleteRequest; - request: Request; clientIp: string; authToken?: string; } @@ -122,9 +122,7 @@ interface AuthLogoutAuthSessionsRequest { } interface AuthHandoffInitiateRequest { - userAgent?: string; - clientIp: string; - clientPlatform?: string; + request: Request; } interface AuthHandoffInfoRequest { @@ -263,7 +261,7 @@ export class AuthRequestService { user: await this.getUserPartial(parsed.user_id), }; } - const ticketPayload = await cache.get(`ip-auth-ticket:${ticket}`); + const ticketPayload = await cache.get(AuthLogin.getTicketCacheKey(ticket)); if (!ticketPayload) { throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_AUTHORIZATION_TICKET); } @@ -276,7 +274,10 @@ export class AuthRequestService { async authenticateWebAuthnDiscoverable({data, request}: AuthWebAuthnAuthenticateRequest) { const user = await AuthMfa.verifyWebAuthnAuthenticationDiscoverable(this.apiContext, data.response, data.challenge); - const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request}); + const [token] = await AuthSession.createAuthSession(this.apiContext, { + user, + origin: AuthSession.resolveSessionOrigin(this.apiContext, request), + }); return {token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)}; } @@ -298,12 +299,9 @@ export class AuthRequestService { return {suggestions: generateUsernameSuggestions(globalName)}; } - async initiateHandoff({ - userAgent, - clientIp, - clientPlatform, - }: AuthHandoffInitiateRequest): Promise { - const result = await this.desktopHandoffService.initiateHandoff({userAgent, clientIp, clientPlatform}); + async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise { + const origin = AuthSession.resolveSessionOrigin(this.apiContext, request); + const result = await this.desktopHandoffService.initiateHandoff({origin}); return { code: result.code, expires_at: result.expiresAt.toISOString(), @@ -312,19 +310,22 @@ export class AuthRequestService { async getHandoffInfo({code, clientIp}: AuthHandoffInfoRequest): Promise { const info = await this.desktopHandoffService.getHandoffInfo(code, clientIp); - if (info.status === 'expired' || !info.clientIp) { + if (info.status === 'expired' || !info.origin) { return {status: info.status, client_info: null}; } - const geo = await lookupGeoip(info.clientIp); - const {clientOs, clientPlatform} = resolveSessionClientInfo({ - userAgent: info.userAgent ?? null, - isDesktopClient: info.clientPlatform === 'desktop', + const geo = await lookupGeoip(info.origin.ip); + const {branding} = await getInstanceConfigRepository().getAppPublicConfig(); + const resolved = resolveSessionClientInfo({ + userAgent: info.origin.userAgent, + reportedOs: info.origin.clientOs, + productName: branding.product_name, }); return { status: 'pending', client_info: { - platform: clientPlatform, - os: clientOs, + platform: resolved.platform, + os: resolved.os, + device: resolved.device, location: { city: geo.city, region: geo.region, @@ -334,18 +335,18 @@ export class AuthRequestService { }; } - async completeHandoff({data, request, clientIp, authToken}: AuthHandoffCompleteRequest): Promise { + async completeHandoff({data, clientIp, authToken}: AuthHandoffCompleteRequest): Promise { const sessionToken = data.token ?? authToken; if (!sessionToken) { throw new UnauthorizedError(); } await this.desktopHandoffService.completeHandoff( data.code, - () => + (origin) => AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, { token: sessionToken, expectedUserId: data.user_id, - request, + origin, }), clientIp, ); diff --git a/fluxer_api/src/api/auth/AuthSession.ts b/fluxer_api/src/api/auth/AuthSession.ts index 45f88df8d..d2e8b2c6c 100644 --- a/fluxer_api/src/api/auth/AuthSession.ts +++ b/fluxer_api/src/api/auth/AuthSession.ts @@ -15,12 +15,19 @@ import {Logger} from '../Logger'; import type {AuthSession} from '../models/AuthSession'; import type {User} from '../models/User'; import {lookupGeoip} from '../utils/IpUtils'; +import {isFluxerNativeUserAgent, parseReportedClientOs} from '../utils/SessionClientIdentity'; import {mapAuthSessionsToResponse} from './AuthModel'; import * as AuthUtility from './AuthUtility'; +export interface SessionOrigin { + ip: string; + userAgent: string | null; + clientOs: string | null; +} + interface CreateAuthSessionParams { user: User; - request: Request; + origin: SessionOrigin; } interface LogoutAuthSessionsParams { @@ -60,29 +67,35 @@ interface ReplaceCurrentAuthSessionResult { interface CreateAdditionalAuthSessionFromTokenParams { token: string; expectedUserId?: string; - request: Request; + origin: SessionOrigin; +} + +export function resolveSessionOrigin(ctx: ApiContext, request: Request): SessionOrigin { + const {config} = ctx.services; + const ip = requireClientIp(request, { + trustClientIpHeader: config.proxy.trust_client_ip_header, + clientIpHeaderName: config.proxy.client_ip_header, + }); + const userAgent = request.headers.get('user-agent')?.trim() || null; + const clientOs = isFluxerNativeUserAgent(userAgent) + ? parseReportedClientOs(request.headers.get('x-fluxer-client-properties')) + : null; + return {ip, userAgent, clientOs}; } export async function createAuthSession( ctx: ApiContext, - {user, request}: CreateAuthSessionParams, + {user, origin}: CreateAuthSessionParams, ): Promise<[token: string, AuthSession]> { - const {users, config} = ctx.services; + const {users} = ctx.services; if (user.isBot) throw new BotUserAuthSessionCreationDeniedError(); if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError(); if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError(); const now = new Date(); const token = await AuthUtility.generateAuthToken(ctx); - const ip = requireClientIp(request, { - trustClientIpHeader: config.proxy.trust_client_ip_header, - clientIpHeaderName: config.proxy.client_ip_header, - }); - const platformHeader = request.headers.get('x-fluxer-platform')?.trim().toLowerCase() ?? null; - const uaRaw = request.headers.get('user-agent') ?? ''; - const isDesktopClient = platformHeader === 'desktop'; let clientCountry: string | null = null; try { - const geoip = await lookupGeoip(ip); + const geoip = await lookupGeoip(origin.ip); clientCountry = geoip.countryCode ? geoip.countryCode.toUpperCase() : null; } catch (error) { Logger.warn({userId: user.id.toString(), error}, 'GeoIP lookup failed at session creation'); @@ -92,11 +105,9 @@ export async function createAuthSession( session_id_hash: Buffer.from(AuthUtility.getTokenIdHash(ctx, 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, + client_ip: origin.ip, + client_user_agent: origin.userAgent, + client_os: origin.clientOs, client_country: clientCountry, version: 1, }); @@ -105,7 +116,7 @@ export async function createAuthSession( export async function createAdditionalAuthSessionFromToken( ctx: ApiContext, - {token, expectedUserId, request}: CreateAdditionalAuthSessionFromTokenParams, + {token, expectedUserId, origin}: CreateAdditionalAuthSessionFromTokenParams, ): Promise<{ token: string; userId: string; @@ -122,7 +133,7 @@ export async function createAdditionalAuthSessionFromToken( if (expectedUserId && user.id.toString() !== expectedUserId) { throw new SessionTokenMismatchError(); } - const [newToken] = await createAuthSession(ctx, {user, request}); + const [newToken] = await createAuthSession(ctx, {user, origin}); return {token: newToken, userId: user.id.toString()}; } @@ -205,7 +216,7 @@ export async function replaceCurrentAuthSession( (authSession) => !authSession.sessionIdHash.equals(currentAuthSession.sessionIdHash), ); await deleteAndTerminateAuthSessions(ctx, user.id, otherAuthSessions); - const [newToken, newAuthSession] = await createAuthSession(ctx, {user, request}); + const [newToken, newAuthSession] = await createAuthSession(ctx, {user, origin: resolveSessionOrigin(ctx, request)}); const newAuthSessionIdHash = encodeSessionIdHash(newAuthSession.sessionIdHash); await dispatchAuthSessionChange(ctx, { userId: user.id, diff --git a/fluxer_api/src/api/auth/services/DesktopHandoffService.ts b/fluxer_api/src/api/auth/services/DesktopHandoffService.ts index c7cccd577..8d4f5d155 100644 --- a/fluxer_api/src/api/auth/services/DesktopHandoffService.ts +++ b/fluxer_api/src/api/auth/services/DesktopHandoffService.ts @@ -5,8 +5,9 @@ import {HandoffCodeExpiredError} from '@fluxer/errors/src/domains/auth/HandoffCo import {InvalidHandoffCodeError} from '@fluxer/errors/src/domains/auth/InvalidHandoffCodeError'; import {ms, seconds} from 'itty-time'; import type {ApiContext} from '../../ApiContext'; +import type {SessionOrigin} from '../AuthSession'; -const HANDOFF_CODE_PREFIX = 'desktop-handoff:'; +const HANDOFF_CODE_PREFIX = 'desktop-handoff-v2:'; const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:'; const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; const CODE_LENGTH = 12; @@ -19,9 +20,7 @@ const MAX_INFO_LOOKUPS = 3; interface HandoffData { createdAt: number; - userAgent?: string; - clientIp: string; - clientPlatform?: string; + origin: SessionOrigin; infoLookupCount: number; } @@ -61,7 +60,7 @@ function assertValidHandoffCode(code: string): void { export class DesktopHandoffService { constructor(private readonly apiContext: ApiContext) {} - async initiateHandoff(args: {userAgent?: string; clientIp: string; clientPlatform?: string}): Promise<{ + async initiateHandoff(args: {origin: SessionOrigin}): Promise<{ code: string; expiresAt: Date; }> { @@ -70,9 +69,7 @@ export class DesktopHandoffService { const normalizedCode = normalizeHandoffCode(code); const handoffData: HandoffData = { createdAt: Date.now(), - userAgent: args.userAgent, - clientIp: args.clientIp, - clientPlatform: args.clientPlatform, + origin: args.origin, infoLookupCount: 0, }; const expirySeconds = seconds('5 minutes'); @@ -83,7 +80,7 @@ export class DesktopHandoffService { async completeHandoff( code: string, - createTokenData: () => Promise<{token: string; userId: string}>, + createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>, approverIp: string, ): Promise { const {cache} = this.apiContext.services; @@ -107,7 +104,7 @@ export class DesktopHandoffService { if (remainingSeconds <= 0) { throw new HandoffCodeExpiredError(); } - const {token, userId} = await createTokenData(); + const {token, userId} = await createTokenData(handoffData.origin); const tokenData: HandoffTokenData = { token, userId, @@ -122,9 +119,7 @@ export class DesktopHandoffService { approverIp: string, ): Promise<{ status: 'pending' | 'expired'; - userAgent?: string; - clientIp?: string; - clientPlatform?: string; + origin?: SessionOrigin; }> { const {cache} = this.apiContext.services; const normalizedCode = normalizeHandoffCode(code); @@ -149,12 +144,7 @@ export class DesktopHandoffService { {approvedAt: Date.now()}, remainingTtl > 0 ? remainingTtl : seconds('5 minutes'), ); - return { - status: 'pending', - userAgent: handoffData.userAgent, - clientIp: handoffData.clientIp, - clientPlatform: handoffData.clientPlatform, - }; + return {status: 'pending', origin: handoffData.origin}; } async getHandoffStatus( diff --git a/fluxer_api/src/api/auth/services/SsoService.ts b/fluxer_api/src/api/auth/services/SsoService.ts index 4e971d753..cea8a79e4 100644 --- a/fluxer_api/src/api/auth/services/SsoService.ts +++ b/fluxer_api/src/api/auth/services/SsoService.ts @@ -332,7 +332,10 @@ export class SsoService { }); const claims = await this.resolveClaims(tokenResponse, config, statePayload.nonce); const user = await this.resolveUserFromClaims(claims, config); - const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request}); + const [token] = await AuthSession.createAuthSession(this.apiContext, { + user, + origin: AuthSession.resolveSessionOrigin(this.apiContext, request), + }); return {token, user_id: user.id.toString(), redirect_to: statePayload.redirectTo ?? ''}; } diff --git a/fluxer_api/src/api/auth/tests/DesktopHandoffFlow.test.ts b/fluxer_api/src/api/auth/tests/DesktopHandoffFlow.test.ts index e64964150..422220e8a 100644 --- a/fluxer_api/src/api/auth/tests/DesktopHandoffFlow.test.ts +++ b/fluxer_api/src/api/auth/tests/DesktopHandoffFlow.test.ts @@ -1,10 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {maskIpForDisplay} from '@fluxer/ip_utils/src/IpAddress'; import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; import type {ApiTestHarness} from '../../test/ApiTestHarness'; import {HTTP_STATUS} from '../../test/TestConstants'; -import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder'; +import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder'; + import {createAuthHarness, createTestAccount, fetchMe, loginAccount} from './AuthTestUtils'; interface HandoffInitiateResponse { @@ -17,6 +19,7 @@ interface HandoffInfoResponse { client_info?: { platform?: string | null; os?: string | null; + device?: 'mobile' | 'desktop'; location?: { city?: string | null; region?: string | null; @@ -25,6 +28,16 @@ interface HandoffInfoResponse { } | null; } +interface AuthSessionsResponseItem { + masked_ip?: string | null; + client_info?: { + platform?: string | null; + os?: string | null; + browser?: string | null; + device?: 'mobile' | 'desktop'; + } | null; +} + interface HandoffStatusResponse { status: 'pending' | 'completed' | 'expired'; token?: string; @@ -53,6 +66,46 @@ describe('Auth desktop handoff flow', () => { afterAll(async () => { await harness?.shutdown(); }); + it('attributes the handed-off session to the initiating desktop, not the approving browser', async () => { + const DESKTOP_IP = '203.0.113.77'; + const desktopUserAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) FluxerStable/1.4.0 Chrome/128.0.0.0 Electron/32.0.0 Safari/537.36'; + const browserUserAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'; + const account = await createTestAccount(harness); + const login = await loginAccount(harness, account); + const initResp = await createBuilderWithoutAuth(harness) + .post('/auth/handoff/initiate') + .header('User-Agent', desktopUserAgent) + .header('x-forwarded-for', DESKTOP_IP) + .body(null) + .execute(); + const info = await createBuilderWithoutAuth(harness) + .get(`/auth/handoff/${initResp.code}/info`) + .header('User-Agent', browserUserAgent) + .execute(); + expect(info.client_info?.platform).toBe('Fluxer macOS'); + expect(info.client_info?.device).toBe('desktop'); + await createBuilderWithoutAuth(harness) + .post('/auth/handoff/complete') + .header('User-Agent', browserUserAgent) + .body({code: initResp.code, token: login.token, user_id: login.userId}) + .expect(204) + .execute(); + const completed = await createBuilderWithoutAuth(harness) + .get(`/auth/handoff/${initResp.code}/status`) + .execute(); + const sessions = await createBuilder>(harness, completed.token!) + .get('/auth/sessions') + .execute(); + const handedOff = sessions.filter((session) => session.client_info?.platform === 'Fluxer macOS'); + expect(handedOff).toHaveLength(1); + expect(handedOff[0]?.client_info?.os).toBe('macOS'); + expect(handedOff[0]?.client_info?.browser).toBeNull(); + expect(handedOff[0]?.client_info?.device).toBe('desktop'); + expect(sessions.some((session) => session.client_info?.browser === 'Chrome')).toBe(false); + expect(handedOff[0]?.masked_ip).toBe(maskIpForDisplay(DESKTOP_IP)); + }); it('completes full handoff flow: initiate โ†’ info โ†’ complete โ†’ status', async () => { const account = await createTestAccount(harness); const login = await loginAccount(harness, account); diff --git a/fluxer_api/src/api/database/types/AuthTypes.ts b/fluxer_api/src/api/database/types/AuthTypes.ts index ab599f578..e43b9f99c 100644 --- a/fluxer_api/src/api/database/types/AuthTypes.ts +++ b/fluxer_api/src/api/database/types/AuthTypes.ts @@ -18,9 +18,7 @@ export interface AuthSessionRow { approx_last_used_at: Date; client_ip: string; client_user_agent: Nullish; - client_is_desktop: Nullish; - client_os?: Nullish; - client_platform?: Nullish; + client_os: Nullish; client_country: Nullish; version: number; } @@ -32,9 +30,7 @@ export interface AuthSessionTombstoneRow { approx_last_used_at: Date; client_ip: string; client_user_agent: Nullish; - client_is_desktop: Nullish; client_os: Nullish; - client_platform: Nullish; client_country: Nullish; deleted_at: Date; version: number; @@ -140,9 +136,7 @@ export const AUTH_SESSION_COLUMNS = [ 'approx_last_used_at', 'client_ip', 'client_user_agent', - 'client_is_desktop', 'client_os', - 'client_platform', 'client_country', 'version', ] as const satisfies ReadonlyArray; @@ -153,9 +147,7 @@ export const AUTH_SESSION_TOMBSTONE_COLUMNS = [ 'approx_last_used_at', 'client_ip', 'client_user_agent', - 'client_is_desktop', 'client_os', - 'client_platform', 'client_country', 'deleted_at', 'version', diff --git a/fluxer_api/src/api/models/AuthSession.ts b/fluxer_api/src/api/models/AuthSession.ts index 84ba66519..1ccd97e7c 100644 --- a/fluxer_api/src/api/models/AuthSession.ts +++ b/fluxer_api/src/api/models/AuthSession.ts @@ -10,9 +10,7 @@ export class AuthSession { readonly approximateLastUsedAt: Date; readonly clientIp: string; readonly clientUserAgent: string | null; - readonly clientIsDesktop: boolean | null; - readonly clientOs?: string | null; - readonly clientPlatform?: string | null; + readonly clientOs: string | null; readonly clientCountry: string | null; readonly version: number; @@ -23,9 +21,7 @@ export class AuthSession { this.approximateLastUsedAt = row.approx_last_used_at; this.clientIp = row.client_ip; this.clientUserAgent = row.client_user_agent ?? null; - this.clientIsDesktop = row.client_is_desktop ?? null; this.clientOs = row.client_os ?? null; - this.clientPlatform = row.client_platform ?? null; this.clientCountry = row.client_country ?? null; this.version = row.version; } @@ -38,9 +34,7 @@ export class AuthSession { approx_last_used_at: this.approximateLastUsedAt, client_ip: this.clientIp, client_user_agent: this.clientUserAgent, - client_is_desktop: this.clientIsDesktop, client_os: this.clientOs, - client_platform: this.clientPlatform, client_country: this.clientCountry, version: this.version, }; @@ -54,9 +48,7 @@ export class AuthSessionTombstone { readonly approximateLastUsedAt: Date; readonly clientIp: string; readonly clientUserAgent: string | null; - readonly clientIsDesktop: boolean | null; - readonly clientOs?: string | null; - readonly clientPlatform?: string | null; + readonly clientOs: string | null; readonly clientCountry: string | null; readonly deletedAt: Date; readonly version: number; @@ -68,9 +60,7 @@ export class AuthSessionTombstone { this.approximateLastUsedAt = row.approx_last_used_at; this.clientIp = row.client_ip; this.clientUserAgent = row.client_user_agent ?? null; - this.clientIsDesktop = row.client_is_desktop ?? null; this.clientOs = row.client_os ?? null; - this.clientPlatform = row.client_platform ?? null; this.clientCountry = row.client_country ?? null; this.deletedAt = row.deleted_at; this.version = row.version; diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 769ebf5a4..f08c3e25e 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -26040,11 +26040,18 @@ "anyOf": [{"type": "string"}, {"type": "null"}], "description": "The operating system of the requesting device" }, + "device": { + "enum": ["mobile", "desktop"], + "type": "string", + "x-enumNames": ["mobile", "desktop"], + "description": "Device class of the requesting device, decided by the server" + }, "location": { "anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}], "description": "The approximate location of the requesting device" } - } + }, + "required": ["device"] }, {"type": "null"} ], @@ -26288,11 +26295,18 @@ "anyOf": [{"type": "string"}, {"type": "null"}], "description": "The browser reported by the client" }, + "device": { + "enum": ["mobile", "desktop"], + "type": "string", + "x-enumNames": ["mobile", "desktop"], + "description": "Device class of the session, decided by the server" + }, "location": { "anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}], "description": "The geolocation data sent by the client" } - } + }, + "required": ["device"] }, {"type": "null"} ], diff --git a/fluxer_api/src/api/test/TestHarnessController.ts b/fluxer_api/src/api/test/TestHarnessController.ts index 67e5cbfd3..26de3b795 100644 --- a/fluxer_api/src/api/test/TestHarnessController.ts +++ b/fluxer_api/src/api/test/TestHarnessController.ts @@ -35,6 +35,7 @@ import type {Context} from 'hono'; import {seconds} from 'itty-time'; import {AttachmentDecayRepository} from '../attachment/AttachmentDecayRepository'; import type {IpAuthorizationTicketCache} from '../auth/AuthLogin'; +import {getTicketCacheKey} from '../auth/AuthLogin'; import { type ChannelID, createApplicationID, @@ -86,6 +87,7 @@ import {UserRepository} from '../user/repositories/UserRepository'; import {processUserDeletion} from '../user/services/UserDeletionService'; import {UserHarvestRepository} from '../user/UserHarvestRepository'; import {getExpiryBucket} from '../utils/AttachmentDecay'; +import {parseReportedClientOs} from '../utils/SessionClientIdentity'; import {ScheduledMessageExecutor} from '../worker/executors/ScheduledMessageExecutor'; import {processExpiredAttachments} from '../worker/tasks/ExpireAttachments'; import {processInactivityDeletionsCore} from '../worker/tasks/ProcessInactivityDeletions'; @@ -627,7 +629,7 @@ export function TestHarnessController(app: HonoApp) { client_ip: clientIp, user_agent: userAgent, client_location: clientLocation, - platform, + client_properties: clientProperties, resend_used: resendUsed, invite_code: inviteCode, created_at: createdAtInput, @@ -649,9 +651,11 @@ export function TestHarnessController(app: HonoApp) { userId: String(userId), email: String(email), username: String(username), - clientIp: String(clientIp), - userAgent: String(userAgent), - platform: platform ? String(platform) : null, + origin: { + ip: String(clientIp), + userAgent: userAgent ? String(userAgent) : null, + clientOs: parseReportedClientOs(clientProperties ? String(clientProperties) : null), + }, authToken: String(token), clientLocation: String(clientLocation), inviteCode: inviteCode ? String(inviteCode) : null, @@ -659,7 +663,7 @@ export function TestHarnessController(app: HonoApp) { createdAt, }; const ttl = typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : seconds('15 minutes'); - await cacheService.set(`ip-auth-ticket:${ticket}`, payload, ttl); + await cacheService.set(getTicketCacheKey(String(ticket)), payload, ttl); await cacheService.set(`ip-auth-token:${token}`, {ticket: String(ticket)}, ttl); return ctx.json( { @@ -721,7 +725,7 @@ export function TestHarnessController(app: HonoApp) { return ctx.json({error: 'ticket or token is required'}, 400); } if (ticket) { - await cacheService.delete(`ip-auth-ticket:${ticket}`); + await cacheService.delete(getTicketCacheKey(String(ticket))); await cacheService.delete(`ip-auth:${ticket}`); } if (token) { diff --git a/fluxer_api/src/api/user/repositories/auth/AuthSessionRepository.ts b/fluxer_api/src/api/user/repositories/auth/AuthSessionRepository.ts index 805869d73..75e80e245 100644 --- a/fluxer_api/src/api/user/repositories/auth/AuthSessionRepository.ts +++ b/fluxer_api/src/api/user/repositories/auth/AuthSessionRepository.ts @@ -211,9 +211,7 @@ function toTombstoneRow(row: AuthSessionRow, deletedAt: Date): AuthSessionTombst approx_last_used_at: row.approx_last_used_at, client_ip: row.client_ip, client_user_agent: row.client_user_agent, - client_is_desktop: row.client_is_desktop, client_os: row.client_os ?? null, - client_platform: row.client_platform ?? null, client_country: row.client_country ?? null, deleted_at: deletedAt, version: row.version, diff --git a/fluxer_api/src/api/utils/SessionClientIdentity.ts b/fluxer_api/src/api/utils/SessionClientIdentity.ts new file mode 100644 index 000000000..95725161a --- /dev/null +++ b/fluxer_api/src/api/utils/SessionClientIdentity.ts @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Bowser from 'bowser'; +import {Logger} from '../Logger'; +import {parseJsonRecord} from './JsonBoundaryUtils'; + +export type SessionDeviceClass = 'mobile' | 'desktop'; + +interface SessionClientSignals { + userAgent: string | null; + reportedOs: string | null; + productName: string; +} + +export interface SessionClientInfo { + platform: string | null; + os: string | null; + browser: string | null; + device: SessionDeviceClass; +} + +type OsToken = 'android' | 'ios' | 'macos' | 'windows' | 'linux'; + +const OS_DISPLAY: Record = { + android: 'Android', + ios: 'iOS', + macos: 'macOS', + windows: 'Windows', + linux: 'Linux', +}; + +const BOWSER_OS_TO_TOKEN: Record = { + macOS: 'macos', + Windows: 'windows', + Linux: 'linux', + iOS: 'ios', + Android: 'android', +}; + +const NATIVE_UA_REGEX = /^Fluxer (Android|iOS|Linux|Desktop|Client)(?=[/ ]|$)/; +const ELECTRON_UA_REGEX = /\bElectron\/\d+(?:\.\d+)*/; +const PRODUCT_TOKEN_OS: Record = { + Android: 'android', + iOS: 'ios', + Linux: 'linux', + Desktop: null, + Client: null, +}; +const CLIENT_PROPERTIES_HEADER_MAX_LENGTH = 4096; +const MOBILE_PLATFORM_TYPES = new Set(['mobile', 'tablet']); + +function narrowOsToken(value: string | null): OsToken | null { + if (value === null) return null; + return Object.hasOwn(OS_DISPLAY, value) ? (value as OsToken) : null; +} + +function parseUserAgent(userAgent: string): Bowser.Parser.Parser | null { + try { + return Bowser.getParser(userAgent); + } catch (error) { + Logger.warn({error}, 'Failed to parse user agent'); + return null; + } +} + +function bowserOsToken(userAgent: string): OsToken | null { + if (!userAgent) return null; + return narrowOsToken(BOWSER_OS_TO_TOKEN[parseUserAgent(userAgent)?.getOSName() ?? ''] ?? null); +} + +export function isFluxerNativeUserAgent(userAgent: string | null): boolean { + return NATIVE_UA_REGEX.test(userAgent?.trim() ?? ''); +} + +export function parseReportedClientOs(headerValue: string | null): OsToken | null { + if (!headerValue) return null; + const trimmed = headerValue.trim(); + if (!trimmed || trimmed.length > CLIENT_PROPERTIES_HEADER_MAX_LENGTH) return null; + let decoded: string; + try { + decoded = Buffer.from(trimmed, 'base64').toString('utf8'); + } catch { + return null; + } + const record = parseJsonRecord(decoded); + if (!record) return null; + const os = record.os; + return typeof os === 'string' ? narrowOsToken(os) : null; +} + +export function resolveSessionClientInfo({ + userAgent, + reportedOs, + productName, +}: SessionClientSignals): SessionClientInfo { + const ua = userAgent?.trim() ?? ''; + const nativeMatch = NATIVE_UA_REGEX.exec(ua); + + if (nativeMatch) { + const productToken = nativeMatch[1] as keyof typeof PRODUCT_TOKEN_OS; + const osToken = narrowOsToken(reportedOs) ?? PRODUCT_TOKEN_OS[productToken] ?? bowserOsToken(ua); + const osDisplay = osToken ? OS_DISPLAY[osToken] : null; + const mobile = osToken === 'ios' || osToken === 'android'; + const platform = osDisplay + ? mobile + ? `${productName} ${osDisplay}` + : `${productName} Lite ${osDisplay}` + : `${productName} Lite`; + return {platform, os: osDisplay, browser: null, device: mobile ? 'mobile' : 'desktop'}; + } + + if (ELECTRON_UA_REGEX.test(ua)) { + const osToken = bowserOsToken(ua); + const osDisplay = osToken ? OS_DISPLAY[osToken] : null; + const mobile = osToken === 'ios' || osToken === 'android'; + return { + platform: osDisplay ? `${productName} ${osDisplay}` : productName, + os: osDisplay, + browser: null, + device: mobile ? 'mobile' : 'desktop', + }; + } + + const parser = ua ? parseUserAgent(ua) : null; + const browser = parser?.getBrowserName() || null; + const os = parser?.getOSName() || null; + const platformType = parser?.getPlatformType(true) ?? ''; + return { + platform: browser ?? os, + os, + browser, + device: MOBILE_PLATFORM_TYPES.has(platformType) ? 'mobile' : 'desktop', + }; +} diff --git a/fluxer_api/src/api/utils/UserAgentUtils.ts b/fluxer_api/src/api/utils/UserAgentUtils.ts deleted file mode 100644 index 17a8418b0..000000000 --- a/fluxer_api/src/api/utils/UserAgentUtils.ts +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import Bowser from 'bowser'; -import {Logger} from '../Logger'; - -interface UserAgentInfo { - clientOs: string; - detectedPlatform: string; -} - -const UNKNOWN_LABEL = 'Unknown'; - -function formatName(name?: string | null): string { - const normalized = name?.trim(); - return normalized || UNKNOWN_LABEL; -} - -function parseUserAgentSafe(userAgentRaw: string): UserAgentInfo { - const ua = userAgentRaw.trim(); - if (!ua) return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL}; - try { - const parser = Bowser.getParser(ua); - return { - clientOs: formatName(parser.getOSName()), - detectedPlatform: formatName(parser.getBrowserName()), - }; - } catch (error) { - Logger.warn({error}, 'Failed to parse user agent'); - return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL}; - } -} - -export function resolveSessionClientInfo(args: {userAgent: string | null; isDesktopClient: boolean | null}): { - clientOs: string; - clientPlatform: string; -} { - const parsed = parseUserAgentSafe(args.userAgent ?? ''); - const clientPlatform = args.isDesktopClient ? 'Fluxer Desktop' : parsed.detectedPlatform; - return { - clientOs: parsed.clientOs, - clientPlatform, - }; -} diff --git a/fluxer_api/src/api/utils/tests/SessionClientIdentity.test.ts b/fluxer_api/src/api/utils/tests/SessionClientIdentity.test.ts new file mode 100644 index 000000000..93a8cf31a --- /dev/null +++ b/fluxer_api/src/api/utils/tests/SessionClientIdentity.test.ts @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {describe, expect, it} from 'vitest'; +import { + isFluxerNativeUserAgent, + parseReportedClientOs, + resolveSessionClientInfo, + type SessionClientInfo, +} from '../SessionClientIdentity'; + +const ELECTRON_MAC_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) FluxerStable/2026.614.83512 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36'; +const ELECTRON_WINDOWS_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) FluxerCanary/2026.614.83512 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36'; +const CHROME_MAC_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'; +const SAFARI_IPHONE_UA = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + +const resolve = (userAgent: string | null, reportedOs: string | null, productName = 'Fluxer'): SessionClientInfo => + resolveSessionClientInfo({userAgent, reportedOs, productName}); + +describe('resolveSessionClientInfo', () => { + it('labels the Flutter mobile clients by their operating system', () => { + expect(resolve('Fluxer iOS/1.4.2 (stable)', 'ios')).toEqual({ + platform: 'Fluxer iOS', + os: 'iOS', + browser: null, + device: 'mobile', + }); + expect(resolve('Fluxer Android/1.4.2 (stable)', 'android')).toEqual({ + platform: 'Fluxer Android', + os: 'Android', + browser: null, + device: 'mobile', + }); + }); + + it('accepts the versionless user agent emitted before package info loads', () => { + expect(resolve('Fluxer iOS (stable)', 'ios')).toEqual({ + platform: 'Fluxer iOS', + os: 'iOS', + browser: null, + device: 'mobile', + }); + }); + + it('labels Flutter desktop builds as Lite and distinguishes them by reported operating system', () => { + expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'macos')).toEqual({ + platform: 'Fluxer Lite macOS', + os: 'macOS', + browser: null, + device: 'desktop', + }); + expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'windows')).toEqual({ + platform: 'Fluxer Lite Windows', + os: 'Windows', + browser: null, + device: 'desktop', + }); + expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'linux')).toEqual({ + platform: 'Fluxer Lite Linux', + os: 'Linux', + browser: null, + device: 'desktop', + }); + }); + + it('treats a narrow Linux window as a desktop because the product token cannot carry form factor', () => { + expect(resolve('Fluxer Linux/1.4.2 (stable)', 'linux')).toEqual({ + platform: 'Fluxer Lite Linux', + os: 'Linux', + browser: null, + device: 'desktop', + }); + }); + + it('resolves legacy rows that predate the reported operating system', () => { + expect(resolve('Fluxer iOS/1.4.2 (stable)', null)).toEqual({ + platform: 'Fluxer iOS', + os: 'iOS', + browser: null, + device: 'mobile', + }); + expect(resolve('Fluxer Desktop/1.4.2 (stable)', null)).toEqual({ + platform: 'Fluxer Lite', + os: null, + browser: null, + device: 'desktop', + }); + }); + + it('ignores a corrupt reported operating system instead of rendering it', () => { + expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'Windows 11')).toEqual({ + platform: 'Fluxer Lite', + os: null, + browser: null, + device: 'desktop', + }); + }); + + it('labels the desktop application by operating system without naming a browser', () => { + expect(resolve(ELECTRON_MAC_UA, null)).toEqual({ + platform: 'Fluxer macOS', + os: 'macOS', + browser: null, + device: 'desktop', + }); + expect(resolve(ELECTRON_WINDOWS_UA, null)).toEqual({ + platform: 'Fluxer Windows', + os: 'Windows', + browser: null, + device: 'desktop', + }); + }); + + it('keeps browser sessions reporting their browser', () => { + expect(resolve(CHROME_MAC_UA, null)).toEqual({ + platform: 'Chrome', + os: 'macOS', + browser: 'Chrome', + device: 'desktop', + }); + expect(resolve(SAFARI_IPHONE_UA, null)).toEqual({ + platform: 'Safari', + os: 'iOS', + browser: 'Safari', + device: 'mobile', + }); + }); + + it('keeps the operating system when the browser cannot be named', () => { + expect(resolve('SomeBot (Windows NT 10.0; Win64; x64)', null)).toEqual({ + platform: 'Windows', + os: 'Windows', + browser: null, + device: 'desktop', + }); + }); + + it('rejects a product token that only prefixes a real one', () => { + const resolved = resolve('Fluxer iOS-not-really/6.6.6', 'ios'); + expect(resolved.platform).not.toBe('Fluxer iOS'); + expect(resolved.browser).toBe(resolved.platform); + }); + + it('never emits an Unknown literal when the user agent is absent', () => { + expect(resolve(null, null)).toEqual({platform: null, os: null, browser: null, device: 'desktop'}); + expect(resolve('', null)).toEqual({platform: null, os: null, browser: null, device: 'desktop'}); + }); + + it('uses instance branding for the product word', () => { + expect(resolve('Fluxer iOS/1.4.2 (stable)', 'ios', 'Acme').platform).toBe('Acme iOS'); + expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'windows', 'Acme').platform).toBe('Acme Lite Windows'); + expect(resolve(ELECTRON_MAC_UA, null, 'Acme').platform).toBe('Acme macOS'); + }); +}); + +describe('isFluxerNativeUserAgent', () => { + it('matches only the Fluxer native product tokens', () => { + expect(isFluxerNativeUserAgent('Fluxer iOS/1.4.2 (stable)')).toBe(true); + expect(isFluxerNativeUserAgent('Fluxer Desktop (canary)')).toBe(true); + expect(isFluxerNativeUserAgent(ELECTRON_MAC_UA)).toBe(false); + expect(isFluxerNativeUserAgent(CHROME_MAC_UA)).toBe(false); + expect(isFluxerNativeUserAgent(null)).toBe(false); + }); +}); + +describe('parseReportedClientOs', () => { + const encode = (value: unknown): string => Buffer.from(JSON.stringify(value), 'utf8').toString('base64'); + + it('reads the operating system from the client properties header', () => { + expect(parseReportedClientOs(encode({os: 'macos', device: 'mobile'}))).toBe('macos'); + expect(parseReportedClientOs(encode({os: 'ios'}))).toBe('ios'); + }); + + it('rejects anything outside the known operating systems', () => { + expect(parseReportedClientOs(null)).toBeNull(); + expect(parseReportedClientOs('')).toBeNull(); + expect(parseReportedClientOs('not base64 $$$')).toBeNull(); + expect(parseReportedClientOs(encode([1, 2]))).toBeNull(); + expect(parseReportedClientOs(encode({os: 'solaris'}))).toBeNull(); + expect(parseReportedClientOs(encode({os: 42}))).toBeNull(); + expect(parseReportedClientOs(encode({}))).toBeNull(); + }); + + it('rejects an oversized header without decoding it', () => { + expect(parseReportedClientOs('a'.repeat(4097))).toBeNull(); + }); +}); diff --git a/fluxer_api/src/api/worker/tasks/HarvestUserData.ts b/fluxer_api/src/api/worker/tasks/HarvestUserData.ts index 256a3f8ed..0fe25a717 100644 --- a/fluxer_api/src/api/worker/tasks/HarvestUserData.ts +++ b/fluxer_api/src/api/worker/tasks/HarvestUserData.ts @@ -50,7 +50,7 @@ import type {User} from '../../models/User'; import type {UserGuildSettings} from '../../models/UserGuildSettings'; import type {UserSettings} from '../../models/UserSettings'; import type {WebAuthnCredential} from '../../models/WebAuthnCredential'; -import {resolveSessionClientInfo} from '../../utils/UserAgentUtils'; +import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity'; import {createArchiveJsonBuffer} from '../utils/ArchiveJson'; import {appendAssetToArchive, buildHashedAssetKey, getAnimatedAssetExtension} from '../utils/AssetArchiveHelpers'; import {ContentAddressedAttachmentCollector} from '../utils/ContentAddressedAttachmentCollector'; @@ -114,6 +114,7 @@ interface HarvestMessageResult { interface UserDataJsonParams { user: User; userId: UserID; + productName: string; authSessions: Array; relationships: Array; userNotes: Map; @@ -354,6 +355,7 @@ function buildUserDataJson(params: UserDataJsonParams) { const { user, userId, + productName, authSessions, relationships, userNotes, @@ -412,17 +414,18 @@ function buildUserDataJson(params: UserDataJsonParams) { authenticator_types: Array.from(user.authenticatorTypes), }, auth_sessions: authSessions.map((session) => { - const {clientOs, clientPlatform} = resolveSessionClientInfo({ + const clientInfo = resolveSessionClientInfo({ userAgent: session.clientUserAgent, - isDesktopClient: session.clientIsDesktop, + reportedOs: session.clientOs ?? null, + productName, }); return { created_at: session.createdAt.toISOString(), approx_last_used_at: session.approximateLastUsedAt?.toISOString() ?? null, client_ip: session.clientIp, - client_os: clientOs, + client_os: clientInfo.os, client_user_agent: session.clientUserAgent, - client_platform: clientPlatform, + client_platform: clientInfo.platform, }; }), relationships: relationships.map((rel) => ({ @@ -872,9 +875,11 @@ const harvestUserData: WorkerTaskHandler = async (payload, helpers) => { const guildSettings = await Promise.all( guildIds.map((guildId: GuildID) => userRepository.findGuildSettings(userId, guildId)), ); + const {branding} = await instanceConfigRepository.getAppPublicConfig(); const userData = buildUserDataJson({ user, userId, + productName: branding.product_name, authSessions, relationships, userNotes, diff --git a/fluxer_app/src/features/app/config/I18nDisplayConstants.ts b/fluxer_app/src/features/app/config/I18nDisplayConstants.ts index fc9e17baf..5f027b977 100644 --- a/fluxer_app/src/features/app/config/I18nDisplayConstants.ts +++ b/fluxer_app/src/features/app/config/I18nDisplayConstants.ts @@ -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`; diff --git a/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts b/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts index 18b3aa6b9..088fb2485 100644 --- a/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts +++ b/fluxer_app/src/features/auth/commands/AuthenticationCommands.ts @@ -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): Record => ({ - 'X-Fluxer-Platform': getPlatformHeaderValue(), +const withAuthLocaleHeader = (headers?: Record): Record => ({ 'Accept-Language': UserSettings.getLocale(), ...(headers ?? {}), }); @@ -247,7 +244,7 @@ export async function login({ try { const response = await http.post(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(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(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(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 { try { const response = await http.post(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(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 { try { const response = await http.post(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(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(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(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(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 { 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 { export async function resendVerificationEmail(): Promise { 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 { 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 { export async function resendIpAuthorization(ticket: string): Promise { 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 { const response = await http.get(Endpoints.AUTH_IP_AUTHORIZATION_POLL(ticket), { - headers: withPlatformHeader(), + headers: withAuthLocaleHeader(), }); return response.body; } @@ -547,7 +544,7 @@ export async function completeDesktopHandoff({ }): Promise { 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(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 { const response = await http.post(Endpoints.AUTH_SSO_COMPLETE, { body: {code, state}, - headers: withPlatformHeader(), + headers: withAuthLocaleHeader(), }); return response.body; } diff --git a/fluxer_app/src/features/auth/flow/HandoffApprovalFlow.tsx b/fluxer_app/src/features/auth/flow/HandoffApprovalFlow.tsx index 54fe4fb24..be78ad56d 100644 --- a/fluxer_app/src/features/auth/flow/HandoffApprovalFlow.tsx +++ b/fluxer_app/src/features/auth/flow/HandoffApprovalFlow.tsx @@ -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 (

@@ -183,13 +168,13 @@ export function HandoffApprovalFlow({

{hasAnyDeviceInfo ? (
- {platformLabel ? ( + {platform ? (
Platform - {platformLabel} + {platform}
) : null} diff --git a/fluxer_app/src/features/auth/models/AuthSession.ts b/fluxer_app/src/features/auth/models/AuthSession.ts index abfad4dd4..d1ce18b89 100644 --- a/fluxer_app/src/features/auth/models/AuthSession.ts +++ b/fluxer_app/src/features/auth/models/AuthSession.ts @@ -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; diff --git a/fluxer_app/src/features/user/components/modals/tabs/DevicesTab.tsx b/fluxer_app/src/features/user/components/modals/tabs/DevicesTab.tsx index ad69d006b..662ea1954 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/DevicesTab.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/DevicesTab.tsx @@ -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(() => (
@@ -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 ( Device details} data-flx="user.devices-tab.device-details-modal.header" /> @@ -196,8 +193,8 @@ const AuthSessionItem: React.FC = 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 = 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 = observer(
- {clientOs} - - {platformLabel} + {authSession.clientBrowser === null ? ( + platformLabel + ) : ( + <> + {clientOs} + + {platformLabel} + + )} {metadataLine && (
; const HandoffInfoClientInfo = z.object({ platform: z.string().nullish().describe('The platform of the requesting device'), os: z.string().nullish().describe('The operating system of the requesting device'), + device: z.enum(['mobile', 'desktop']).describe('Device class of the requesting device, decided by the server'), location: AuthSessionLocation.nullish().describe('The approximate location of the requesting device'), });