Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/main/cloud/identity.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
45 changes: 45 additions & 0 deletions src/main/cloud/identity.ts
Original file line number Diff line number Diff line change
@@ -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<CloudUserIdentity | null> {
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 }) }
}
80 changes: 80 additions & 0 deletions src/main/cloud/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
49 changes: 48 additions & 1 deletion src/main/cloud/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<CloudUserIdentity | null>
} | 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<CloudUserIdentity | null> {
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<string, Promise<AuthTokens | null>>()
private loginInFlight: Promise<AuthStatus> | null = null
Expand All @@ -49,6 +89,7 @@ export class CloudSession {
this.authGeneration += 1
this.loginInFlight = null
clearTokens()
this.credentialsChanged()
}

status(): AuthStatus {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
}

Expand Down
Loading