// SPDX-License-Identifier: AGPL-3.0-or-later import {randomBytes} from 'node:crypto'; import {HandoffCodeExpiredError} from '@fluxer/errors/src/domains/auth/HandoffCodeExpiredError'; 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-v2:'; const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:'; const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; const CODE_LENGTH = 12; const NORMALIZED_CODE_REGEX = /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{12}$/; const HANDOFF_ATTEMPT_PREFIX = 'desktop-handoff-attempts:'; const HANDOFF_APPROVER_PREFIX = 'desktop-handoff-approver:'; const MAX_FAILED_ATTEMPTS = 5; const ATTEMPT_TTL_SECONDS = 900; const MAX_INFO_LOOKUPS = 3; interface HandoffData { createdAt: number; origin: SessionOrigin; infoLookupCount: number; } interface HandoffTokenData { token: string; userId: string; } interface HandoffApproverData { approvedAt: number; } function generateHandoffCode(): string { const maxUnbiased = 256 - (256 % CODE_CHARACTERS.length); let code = ''; while (code.length < CODE_LENGTH) { const bytes = randomBytes(CODE_LENGTH - code.length); for (let i = 0; i < bytes.length && code.length < CODE_LENGTH; i++) { if (bytes[i] < maxUnbiased) { code += CODE_CHARACTERS[bytes[i] % CODE_CHARACTERS.length]; } } } return `${code.slice(0, 6)}-${code.slice(6, 12)}`; } function normalizeHandoffCode(code: string): string { return code.replace(/[-\s]/g, '').toUpperCase(); } function assertValidHandoffCode(code: string): void { if (!NORMALIZED_CODE_REGEX.test(code)) { throw new InvalidHandoffCodeError(); } } export class DesktopHandoffService { constructor(private readonly apiContext: ApiContext) {} async initiateHandoff(args: {origin: SessionOrigin}): Promise<{ code: string; expiresAt: Date; }> { const {cache} = this.apiContext.services; const code = generateHandoffCode(); const normalizedCode = normalizeHandoffCode(code); const handoffData: HandoffData = { createdAt: Date.now(), origin: args.origin, infoLookupCount: 0, }; const expirySeconds = seconds('5 minutes'); await cache.set(`${HANDOFF_CODE_PREFIX}${normalizedCode}`, handoffData, expirySeconds); const expiresAt = new Date(Date.now() + ms('5 minutes')); return {code, expiresAt}; } async completeHandoff( code: string, createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>, approverIp: string, ): Promise { const {cache} = this.apiContext.services; const normalizedCode = normalizeHandoffCode(code); assertValidHandoffCode(normalizedCode); await this.checkAttemptLimit(approverIp); const storedApprover = await cache.get(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`); if (!storedApprover) { await this.recordFailedAttempt(approverIp); throw new InvalidHandoffCodeError(); } const handoffData = await cache.get(`${HANDOFF_CODE_PREFIX}${normalizedCode}`); if (!handoffData) { await this.recordFailedAttempt(approverIp); throw new InvalidHandoffCodeError(); } const remainingSeconds = Math.max( 0, seconds('5 minutes') - Math.floor((Date.now() - handoffData.createdAt) / 1000), ); if (remainingSeconds <= 0) { throw new HandoffCodeExpiredError(); } const {token, userId} = await createTokenData(handoffData.origin); const tokenData: HandoffTokenData = { token, userId, }; await cache.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds); await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`); await cache.delete(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`); } async getHandoffInfo( code: string, approverIp: string, ): Promise<{ status: 'pending' | 'expired'; origin?: SessionOrigin; }> { const {cache} = this.apiContext.services; const normalizedCode = normalizeHandoffCode(code); assertValidHandoffCode(normalizedCode); await this.checkAttemptLimit(approverIp); const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`; const handoffData = await cache.get(codeKey); if (!handoffData) { await this.recordFailedAttempt(approverIp); return {status: 'expired'}; } if (handoffData.infoLookupCount >= MAX_INFO_LOOKUPS) { throw new InvalidHandoffCodeError(); } const remainingTtl = await cache.ttl(codeKey); if (remainingTtl > 0) { handoffData.infoLookupCount += 1; await cache.set(codeKey, handoffData, remainingTtl); } await cache.set( `${HANDOFF_APPROVER_PREFIX}${normalizedCode}`, {approvedAt: Date.now()}, remainingTtl > 0 ? remainingTtl : seconds('5 minutes'), ); return {status: 'pending', origin: handoffData.origin}; } async getHandoffStatus( code: string, _pollerIp: string, ): Promise<{ status: 'pending' | 'completed' | 'expired'; token?: string; userId?: string; }> { const {cache} = this.apiContext.services; const normalizedCode = normalizeHandoffCode(code); assertValidHandoffCode(normalizedCode); const tokenKey = `${HANDOFF_TOKEN_PREFIX}${normalizedCode}`; const tokenData = await cache.get(tokenKey); if (tokenData) { await cache.delete(tokenKey); return { status: 'completed', token: tokenData.token, userId: tokenData.userId, }; } const handoffData = await cache.get(`${HANDOFF_CODE_PREFIX}${normalizedCode}`); if (handoffData) { return {status: 'pending'}; } return {status: 'expired'}; } async cancelHandoff(code: string): Promise { const {cache} = this.apiContext.services; const normalizedCode = normalizeHandoffCode(code); assertValidHandoffCode(normalizedCode); await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`); await cache.delete(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`); await cache.delete(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`); } private async checkAttemptLimit(clientIp: string): Promise { const {cache} = this.apiContext.services; const count = await cache.get(`${HANDOFF_ATTEMPT_PREFIX}${clientIp}`); if (count != null && count >= MAX_FAILED_ATTEMPTS) { throw new InvalidHandoffCodeError(); } } private async recordFailedAttempt(clientIp: string): Promise { const {cache} = this.apiContext.services; const key = `${HANDOFF_ATTEMPT_PREFIX}${clientIp}`; const current = await cache.get(key); await cache.set(key, (current ?? 0) + 1, ATTEMPT_TTL_SECONDS); } }