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

This commit is contained in:
Hampus
2026-08-19 00:41:29 +02:00
committed by GitHub
parent 5660972c71
commit 9b0b703c9d
27 changed files with 589 additions and 269 deletions
@@ -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,
};
+1 -13
View File
@@ -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,
});
+4 -1
View File
@@ -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,
+21 -29
View File
@@ -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<string, string> = {
[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<IpAuthorizationTicketCache>(`ip-auth-ticket:${ticket}`, cachePayload, ttlSeconds);
await cache.set<IpAuthorizationTicketCache>(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};
}
+12 -17
View File
@@ -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,
+4 -1
View File
@@ -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};
}
+4 -1
View File
@@ -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();
}
+24 -23
View File
@@ -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<HandoffInitiateResponse> {
const result = await this.desktopHandoffService.initiateHandoff({userAgent, clientIp, clientPlatform});
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
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<HandoffInfoResponse> {
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<void> {
async completeHandoff({data, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
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,
);
+31 -20
View File
@@ -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,
@@ -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<void> {
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(
@@ -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 ?? ''};
}
@@ -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<HandoffInitiateResponse>(harness)
.post('/auth/handoff/initiate')
.header('User-Agent', desktopUserAgent)
.header('x-forwarded-for', DESKTOP_IP)
.body(null)
.execute();
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(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<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.execute();
const sessions = await createBuilder<Array<AuthSessionsResponseItem>>(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);
@@ -18,9 +18,7 @@ export interface AuthSessionRow {
approx_last_used_at: Date;
client_ip: string;
client_user_agent: Nullish<string>;
client_is_desktop: Nullish<boolean>;
client_os?: Nullish<string>;
client_platform?: Nullish<string>;
client_os: Nullish<string>;
client_country: Nullish<string>;
version: number;
}
@@ -32,9 +30,7 @@ export interface AuthSessionTombstoneRow {
approx_last_used_at: Date;
client_ip: string;
client_user_agent: Nullish<string>;
client_is_desktop: Nullish<boolean>;
client_os: Nullish<string>;
client_platform: Nullish<string>;
client_country: Nullish<string>;
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<keyof AuthSessionRow>;
@@ -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',
+2 -12
View File
@@ -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;
+16 -2
View File
@@ -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"}
],
@@ -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) {
@@ -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,
@@ -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<OsToken, string> = {
android: 'Android',
ios: 'iOS',
macos: 'macOS',
windows: 'Windows',
linux: 'Linux',
};
const BOWSER_OS_TO_TOKEN: Record<string, OsToken> = {
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<string, OsToken | null> = {
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',
};
}
@@ -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,
};
}
@@ -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();
});
});
@@ -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<AuthSession>;
relationships: Array<Relationship>;
userNotes: Map<UserID, string>;
@@ -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,
@@ -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
@@ -196,6 +196,7 @@ const AuthSessionClientInfo = z.object({
platform: z.string().nullish().describe('The platform reported by the client'),
os: z.string().nullish().describe('The operating system reported by the client'),
browser: z.string().nullish().describe('The browser reported by the client'),
device: z.enum(['mobile', 'desktop']).describe('Device class of the session, decided by the server'),
location: AuthSessionLocation.nullish().describe('The geolocation data sent by the client'),
});
@@ -233,6 +234,7 @@ export type HandoffInitiateResponse = z.infer<typeof HandoffInitiateResponse>;
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'),
});