From 2eb004f6acc1c70912d465d7de1e783780e61895 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 15 Sep 2026 20:05:10 -0700 Subject: [PATCH] Resolve Desktop person identity through authenticated Cloud API --- src/main/cloud/identity.test.ts | 73 ++++++++++++++++++++++++++++++ src/main/cloud/identity.ts | 45 +++++++++++++++++++ src/main/cloud/session.test.ts | 80 +++++++++++++++++++++++++++++++++ src/main/cloud/session.ts | 49 +++++++++++++++++++- 4 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 src/main/cloud/identity.test.ts create mode 100644 src/main/cloud/identity.ts diff --git a/src/main/cloud/identity.test.ts b/src/main/cloud/identity.test.ts new file mode 100644 index 000000000..da510c048 --- /dev/null +++ b/src/main/cloud/identity.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getUserIdentity } from './identity' + +function respond(body: unknown, status = 200) { + const fetch = vi.fn(async () => new Response(JSON.stringify(body), { status })) + vi.stubGlobal('fetch', fetch) + return fetch +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('authenticated person identity', () => { + it('keeps linked Firebase identity separate from the canonical account', async () => { + const fetch = respond({ + id: 'canonical-person', + status: 'active', + firebase_uid: 'firebase-person' + }) + await expect( + getUserIdentity('access-token', { apiBase: 'https://cloud.example/api/' }) + ).resolves.toEqual({ userId: 'canonical-person', firebaseUid: 'firebase-person' }) + expect(fetch).toHaveBeenCalledWith( + 'https://cloud.example/api/user', + expect.objectContaining({ + headers: { Accept: 'application/json', Authorization: 'Bearer access-token' }, + redirect: 'error' + }) + ) + }) + + it('does not infer a Firebase UID for legacy grants', async () => { + respond({ id: 'canonical-person', status: 'active' }) + await expect(getUserIdentity('legacy-token')).resolves.toEqual({ userId: 'canonical-person' }) + }) + + it.each([401, 403])('returns no identity for HTTP %i', async (status) => { + respond({ id: 'untrusted', firebase_uid: 'untrusted' }, status) + await expect(getUserIdentity('rejected-token')).resolves.toBeNull() + }) + + it.each([ + null, + {}, + { id: '' }, + { id: 'person', firebase_uid: null }, + { id: 'person', firebase_uid: ' ' }, + { id: 'person', firebase_uid: 'x'.repeat(129) } + ])('rejects malformed identity data: %j', async (body) => { + respond(body) + await expect(getUserIdentity('token')).rejects.toThrow(/Invalid .*identity response/) + }) + + it('propagates cancellation without including credentials in errors', async () => { + const abort = new AbortController() + let requestSignal: AbortSignal | null | undefined + vi.stubGlobal( + 'fetch', + vi.fn((_url, options: RequestInit) => { + requestSignal = options.signal + return new Promise((_resolve, reject) => { + requestSignal!.addEventListener('abort', () => reject(requestSignal!.reason), { + once: true + }) + }) + }) + ) + const request = getUserIdentity('private-token', { signal: abort.signal }) + abort.abort() + await expect(request).rejects.toThrow(/abort/i) + expect(requestSignal?.aborted).toBe(true) + }) +}) diff --git a/src/main/cloud/identity.ts b/src/main/cloud/identity.ts new file mode 100644 index 000000000..2330544ea --- /dev/null +++ b/src/main/cloud/identity.ts @@ -0,0 +1,45 @@ +import { CLOUD_CONFIG } from './config' + +/** Canonical authorization identity and optional original Firebase identity. */ +export interface CloudUserIdentity { + userId: string + /** Absent on legacy/non-Firebase OAuth grants. Never substitute userId here. */ + firebaseUid?: string +} + +export interface UserIdentityOptions { + apiBase?: string + timeoutMs?: number + signal?: AbortSignal +} + +function isIdentifier(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.trim() === value +} + +/** Resolve identity through the authenticated server, rather than decoded JWT claims. */ +export async function getUserIdentity( + accessToken: string, + options: UserIdentityOptions = {} +): Promise { + const timeout = AbortSignal.timeout(options.timeoutMs ?? 10_000) + const response = await fetch( + `${(options.apiBase ?? CLOUD_CONFIG.apiBase).replace(/\/+$/, '')}/user`, + { + headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` }, + signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout, + redirect: 'error' + } + ) + if (response.status === 401 || response.status === 403) return null + if (!response.ok) throw new Error(`User identity unavailable: HTTP ${response.status}`) + const body: unknown = await response.json() + if (!body || typeof body !== 'object' || !('id' in body) || !isIdentifier(body.id)) { + throw new Error('Invalid user identity response') + } + const firebaseUid = 'firebase_uid' in body ? body.firebase_uid : undefined + if (firebaseUid !== undefined && (!isIdentifier(firebaseUid) || firebaseUid.length > 128)) { + throw new Error('Invalid Firebase identity response') + } + return { userId: body.id, ...(firebaseUid === undefined ? {} : { firebaseUid }) } +} diff --git a/src/main/cloud/session.test.ts b/src/main/cloud/session.test.ts index 07606e602..727190cec 100644 --- a/src/main/cloud/session.test.ts +++ b/src/main/cloud/session.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('./oauth', () => ({ signIn: vi.fn(), refresh: vi.fn() })) +vi.mock('./identity', () => ({ getUserIdentity: vi.fn() })) vi.mock('./tokenStore', () => ({ activateWorkspace: vi.fn(), clearTokens: vi.fn(), @@ -18,6 +19,8 @@ vi.mock('./workspaces', () => ({ })) import { statusFromAccessToken, workspaceIdOf } from './claims' +import { getUserIdentity } from './identity' +import type { CloudUserIdentity } from './identity' import { refresh, signIn } from './oauth' import { CloudSession } from './session' import { @@ -100,6 +103,83 @@ beforeEach(() => { afterEach(() => vi.clearAllMocks()) +describe('CloudSession person identity', () => { + it('does not resolve identity when signed out', async () => { + await expect(new CloudSession().getUserIdentity()).resolves.toBeNull() + expect(getUserIdentity).not.toHaveBeenCalled() + }) + + it('shares concurrent lookups, then revalidates subsequent activations', async () => { + cache(makeTokens('w1'), true) + let resolve!: (identity: CloudUserIdentity) => void + mocked(getUserIdentity).mockImplementation( + () => + new Promise((done) => { + resolve = done + }) + ) + const session = new CloudSession() + const requests = [session.getUserIdentity(), session.getUserIdentity()] + await Promise.resolve() + expect(getUserIdentity).toHaveBeenCalledOnce() + const identity = { userId: 'canonical', firebaseUid: 'firebase' } + resolve(identity) + await expect(Promise.all(requests)).resolves.toEqual([identity, identity]) + mocked(getUserIdentity).mockResolvedValue(identity) + await expect(session.getUserIdentity()).resolves.toEqual(identity) + expect(getUserIdentity).toHaveBeenCalledTimes(2) + }) + + it('revokes immediately and ignores an identity response that completes after logout', async () => { + cache(makeTokens('w1'), true) + let resolve!: (identity: CloudUserIdentity) => void + mocked(getUserIdentity).mockImplementation( + () => + new Promise((done) => { + resolve = done + }) + ) + const session = new CloudSession() + const changed = vi.fn() + const unsubscribe = session.onAuthChanged(changed) + const pending = session.getUserIdentity() + await Promise.resolve() + const signal = mocked(getUserIdentity).mock.calls[0]![1]!.signal! + session.logout() + expect(changed).toHaveBeenCalledOnce() + expect(signal.aborted).toBe(true) + resolve({ userId: 'old-person', firebaseUid: 'old-firebase' }) + await expect(pending).resolves.toBeNull() + unsubscribe() + session.logout() + expect(changed).toHaveBeenCalledOnce() + }) + + it('discards an old workspace response after activating different credentials', async () => { + cache(makeTokens('w1'), true) + cache(makeTokens('w2')) + let resolve!: (identity: CloudUserIdentity) => void + mocked(getUserIdentity).mockImplementationOnce( + () => + new Promise((done) => { + resolve = done + }) + ) + const session = new CloudSession() + const changed = vi.fn() + session.onAuthChanged(changed) + const pending = session.getUserIdentity() + await Promise.resolve() + await session.switchWorkspace('w2') + expect(changed).toHaveBeenCalledOnce() + resolve({ userId: 'canonical', firebaseUid: 'old-firebase' }) + await expect(pending).resolves.toBeNull() + const identity = { userId: 'canonical', firebaseUid: 'new-firebase' } + mocked(getUserIdentity).mockResolvedValue(identity) + await expect(session.getUserIdentity()).resolves.toEqual(identity) + }) +}) + describe('CloudSession access tokens', () => { it('returns null when signed out and an unexpired token without refreshing', async () => { const session = new CloudSession() diff --git a/src/main/cloud/session.ts b/src/main/cloud/session.ts index 0b0719e5b..933da6fc5 100644 --- a/src/main/cloud/session.ts +++ b/src/main/cloud/session.ts @@ -7,6 +7,8 @@ * the main process; the UI only ever gets {@link AuthStatus} + {@link Workspace}. */ import type { TokenProvider } from '../comfybuilder' +import { EventEmitter } from 'node:events' +import { getUserIdentity, type CloudUserIdentity } from './identity' import { workspaceIdOf } from './claims' import { refresh, signIn } from './oauth' import { @@ -26,6 +28,44 @@ import { listWorkspaceMembers, listWorkspaces } from './workspaces' const REFRESH_SKEW_MS = 60_000 export class CloudSession { + private readonly changes = new EventEmitter() + private identityRequest: { + accessToken: string + abort: AbortController + promise: Promise + } | null = null + + /** Main-process consumers can revoke identity as soon as credentials change. */ + onAuthChanged(listener: () => void): () => void { + this.changes.on('changed', listener) + return () => { + this.changes.off('changed', listener) + } + } + + private credentialsChanged(): void { + this.identityRequest?.abort.abort() + this.identityRequest = null + this.changes.emit('changed') + } + + /** Server-confirmed person identity; auth tokens never cross the renderer bridge. */ + async getUserIdentity(): Promise { + const accessToken = await this.getAccessToken() + if (!accessToken || loadTokens()?.accessToken !== accessToken) return null + if (this.identityRequest?.accessToken === accessToken) return this.identityRequest.promise + const abort = new AbortController() + const promise = getUserIdentity(accessToken, { signal: abort.signal }) + .then((identity) => + abort.signal.aborted || loadTokens()?.accessToken !== accessToken ? null : identity + ) + .finally(() => { + if (this.identityRequest?.abort === abort) this.identityRequest = null + }) + this.identityRequest = { accessToken, abort, promise } + return promise + } + /** Refresh rotations are single-flight per workspace token family. */ private readonly refreshing = new Map>() private loginInFlight: Promise | null = null @@ -49,6 +89,7 @@ export class CloudSession { this.authGeneration += 1 this.loginInFlight = null clearTokens() + this.credentialsChanged() } status(): AuthStatus { @@ -106,7 +147,9 @@ export class CloudSession { if (!refreshToken) return tokens try { const rotated = await refresh(refreshToken) + const wasActive = loadTokens()?.accessToken === tokens.accessToken const saved = replaceWorkspaceTokens(workspaceId, tokens.accessToken, refreshToken, rotated) + if (saved && wasActive) this.credentialsChanged() return saved ? rotated : loadWorkspaceTokens(workspaceId) } catch { return loadWorkspaceTokens(workspaceId) @@ -143,7 +186,10 @@ export class CloudSession { } if (generation !== this.authGeneration) return this.status() if (cached && cached.expiresAt > Date.now()) { - if (cached !== current) activateWorkspace(workspaceId) + if (cached !== current) { + activateWorkspace(workspaceId) + this.credentialsChanged() + } return this.status() } return this.authenticateAtGeneration(generation, workspaceId) @@ -162,6 +208,7 @@ export class CloudSession { if (generation !== this.authGeneration) return this.status() if (workspaceId && status.workspaceId !== workspaceId) return this.status() saveTokens(tokens) + this.credentialsChanged() return status }