mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
fix(api): identify session clients correctly and attribute handoff sessions (#1727)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user