Skip to content

Commit de1260e

Browse files
committed
feat(auth): implement token-based authentication for desktop app and update related tests release:patch
1 parent a1ac7a8 commit de1260e

7 files changed

Lines changed: 236 additions & 17 deletions

File tree

‎.claude/settings.local.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,8 @@
223223
"Bash(pnpm --filter web typecheck:*)",
224224
"Bash(pnpm turbo run typecheck:*)",
225225
"Bash(pnpm turbo run lint:*)",
226-
"Bash(pnpm precommit:*)"
226+
"Bash(pnpm precommit:*)",
227+
"Bash(pnpm --filter @pairux/web test -- --run src/app/api/sessions/[sessionId]/signal/stream/route.test.ts)"
227228
],
228229
"deny": [
229230
"Bash(npm *)",

‎apps/desktop/src/main/ipc/auth.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,15 @@ export function registerAuthHandlers(): void {
131131
return { valid: true, user: stored.user };
132132
});
133133

134+
// Get access token for API calls (used by renderer for SSE connections)
135+
ipcMain.handle('auth:getToken', (): { token: string | null } => {
136+
const stored = getStoredAuth();
137+
if (!stored || isAuthExpired(stored)) {
138+
return { token: null };
139+
}
140+
return { token: stored.accessToken };
141+
});
142+
134143
// Open external URL (for signup/forgot password)
135144
ipcMain.handle('auth:openExternal', async (_event, url: string): Promise<void> => {
136145
// For signup/forgot password, open the web app

‎apps/desktop/src/preload/api.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ export interface IPCChannels {
8383
return: Promise<void>;
8484
};
8585

86+
'auth:getToken': {
87+
args: undefined;
88+
return: { token: string | null };
89+
};
90+
8691
// Session channels
8792
'session:create': {
8893
args: CreateSessionSettings | undefined;

‎apps/desktop/src/renderer/components/capture/CapturePreview.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export function CapturePreview({
147147
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
148148
if (session !== null && stream !== null && !isHosting) {
149149
console.log('[CapturePreview] Starting WebRTC hosting for session:', session.id);
150-
startHosting();
150+
void startHosting();
151151
}
152152
}, [session, stream, isHosting, startHosting]);
153153

‎apps/desktop/src/renderer/hooks/useWebRTCHostAPI.ts‎

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { useState, useEffect, useRef, useCallback } from 'react';
1010
import { API_BASE_URL } from '../../shared/config';
11+
import { getElectronAPI } from '@/lib/ipc';
1112
import type {
1213
ConnectionState,
1314
NetworkQuality,
@@ -77,7 +78,7 @@ interface UseWebRTCHostAPIReturn {
7778
viewers: Map<string, ViewerConnection>;
7879
controllingViewer: string | null;
7980
error: string | null;
80-
startHosting: () => void;
81+
startHosting: () => Promise<void>;
8182
stopHosting: () => void;
8283
grantControl: (viewerId: string) => void;
8384
revokeControl: (viewerId: string) => void;
@@ -104,6 +105,7 @@ export function useWebRTCHostAPI({
104105
const viewersRef = useRef<Map<string, ViewerConnection>>(new Map());
105106
const statsIntervalRef = useRef<NodeJS.Timeout | null>(null);
106107
const removeViewerRef = useRef<((viewerId: string) => void) | undefined>(undefined);
108+
const authTokenRef = useRef<string | null>(null);
107109

108110
// Keep callback refs updated
109111
const onControlRequestRef = useRef(onControlRequest);
@@ -117,9 +119,14 @@ export function useWebRTCHostAPI({
117119
const sendSignal = useCallback(
118120
async (signal: SignalMessage) => {
119121
try {
122+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
123+
if (authTokenRef.current) {
124+
headers.Authorization = `Bearer ${authTokenRef.current}`;
125+
}
126+
120127
const response = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/signal`, {
121128
method: 'POST',
122-
headers: { 'Content-Type': 'application/json' },
129+
headers,
123130
body: JSON.stringify(signal),
124131
});
125132

@@ -171,9 +178,14 @@ export function useWebRTCHostAPI({
171178
});
172179

173180
// Report to API
181+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
182+
if (authTokenRef.current) {
183+
headers.Authorization = `Bearer ${authTokenRef.current}`;
184+
}
185+
174186
await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/stats`, {
175187
method: 'POST',
176-
headers: { 'Content-Type': 'application/json' },
188+
headers,
177189
body: JSON.stringify({
178190
participantId: hostId,
179191
role: 'host',
@@ -480,17 +492,37 @@ export function useWebRTCHostAPI({
480492
);
481493

482494
// Start hosting
483-
const startHosting = useCallback(() => {
495+
const startHosting = useCallback(async () => {
484496
if (!localStream) {
485497
setError('No stream available. Please start screen sharing first.');
486498
return;
487499
}
488500

489501
console.log('[WebRTCHost] Starting hosting for session:', sessionId);
490502

503+
// Get auth token for API authentication
504+
try {
505+
const api = getElectronAPI();
506+
const { token } = await api.invoke('auth:getToken', undefined);
507+
authTokenRef.current = token;
508+
console.log('[WebRTCHost] Auth token retrieved:', token ? 'yes' : 'no');
509+
} catch (err) {
510+
console.error('[WebRTCHost] Failed to get auth token:', err);
511+
setError('Failed to authenticate. Please log in again.');
512+
return;
513+
}
514+
515+
// Build SSE URL with token (EventSource doesn't support custom headers)
516+
const sseParams = new URLSearchParams({
517+
participantId: hostId,
518+
});
519+
if (authTokenRef.current) {
520+
sseParams.set('token', authTokenRef.current);
521+
}
522+
491523
// Connect to SSE stream for signals
492524
const eventSource = new EventSource(
493-
`${API_BASE_URL}/api/sessions/${sessionId}/signal/stream?participantId=${hostId}`
525+
`${API_BASE_URL}/api/sessions/${sessionId}/signal/stream?${sseParams.toString()}`
494526
);
495527

496528
eventSourceRef.current = eventSource;

‎apps/web/src/app/api/sessions/[sessionId]/signal/stream/route.test.ts‎

Lines changed: 147 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,46 @@
1-
import { describe, it, expect, vi, beforeEach } from 'vitest';
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { GET } from './route';
33
import { createMockSupabaseClient, mockUser, mockSession } from '@/test/mocks/supabase';
44

55
const mockGetAuthenticatedUser = vi.fn();
6+
const mockCreateSupabaseClient = vi.fn();
67

78
vi.mock('@/lib/supabase/server', () => ({
89
createClient: vi.fn(),
910
getAuthenticatedUser: (...args: unknown[]) => mockGetAuthenticatedUser(...args),
1011
}));
1112

13+
vi.mock('@supabase/supabase-js', () => ({
14+
createClient: (...args: unknown[]) => mockCreateSupabaseClient(...args),
15+
}));
16+
1217
import { createClient } from '@/lib/supabase/server';
1318

1419
describe('GET /api/sessions/[sessionId]/signal/stream', () => {
20+
const originalEnv = { ...process.env };
21+
1522
beforeEach(() => {
1623
vi.clearAllMocks();
1724
vi.spyOn(console, 'error').mockImplementation(() => {});
25+
// Set up env vars for token auth tests
26+
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co';
27+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'test-anon-key';
28+
});
29+
30+
afterEach(() => {
31+
process.env = { ...originalEnv };
1832
});
1933

20-
const createRequest = (sessionId: string, participantId?: string) => {
21-
const url = participantId
22-
? `http://localhost/api/sessions/${sessionId}/signal/stream?participantId=${participantId}`
34+
const createRequest = (
35+
sessionId: string,
36+
options?: { participantId?: string; token?: string }
37+
) => {
38+
const params = new URLSearchParams();
39+
if (options?.participantId) params.set('participantId', options.participantId);
40+
if (options?.token) params.set('token', options.token);
41+
const queryString = params.toString();
42+
const url = queryString
43+
? `http://localhost/api/sessions/${sessionId}/signal/stream?${queryString}`
2344
: `http://localhost/api/sessions/${sessionId}/signal/stream`;
2445
return new Request(url, {
2546
method: 'GET',
@@ -150,9 +171,12 @@ describe('GET /api/sessions/[sessionId]/signal/stream', () => {
150171
vi.mocked(createClient).mockResolvedValue(mockSupabase as never);
151172
mockGetAuthenticatedUser.mockResolvedValue({ user: null, error: null });
152173

153-
const response = await GET(createRequest('test-session-id', 'guest-participant-id'), {
154-
params: Promise.resolve({ sessionId: 'test-session-id' }),
155-
});
174+
const response = await GET(
175+
createRequest('test-session-id', { participantId: 'guest-participant-id' }),
176+
{
177+
params: Promise.resolve({ sessionId: 'test-session-id' }),
178+
}
179+
);
156180

157181
expect(response.status).toBe(200);
158182
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
@@ -234,4 +258,120 @@ describe('GET /api/sessions/[sessionId]/signal/stream', () => {
234258
expect.any(Function)
235259
);
236260
});
261+
262+
// Token-based authentication tests (for desktop app)
263+
describe('token-based authentication', () => {
264+
it('returns SSE stream for authenticated host via token', async () => {
265+
const mockChannel = {
266+
on: vi.fn().mockReturnThis(),
267+
subscribe: vi.fn().mockReturnThis(),
268+
track: vi.fn().mockResolvedValue('ok'),
269+
};
270+
271+
const mockFrom = vi.fn().mockReturnValue({
272+
select: vi.fn().mockReturnThis(),
273+
eq: vi.fn().mockReturnThis(),
274+
single: vi.fn().mockResolvedValue({ data: mockSession, error: null }),
275+
});
276+
277+
const mockSupabase = createMockSupabaseClient({
278+
from: mockFrom,
279+
channel: vi.fn().mockReturnValue(mockChannel),
280+
removeChannel: vi.fn().mockResolvedValue('ok'),
281+
auth: {
282+
getUser: vi.fn().mockResolvedValue({ data: { user: mockUser }, error: null }),
283+
},
284+
});
285+
mockCreateSupabaseClient.mockReturnValue(mockSupabase);
286+
287+
const response = await GET(createRequest('test-session-id', { token: 'valid-token' }), {
288+
params: Promise.resolve({ sessionId: 'test-session-id' }),
289+
});
290+
291+
expect(response.status).toBe(200);
292+
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
293+
// Verify createSupabaseClient was called with token in headers
294+
expect(mockCreateSupabaseClient).toHaveBeenCalledWith(
295+
'https://test.supabase.co',
296+
'test-anon-key',
297+
expect.objectContaining({
298+
auth: { persistSession: false, autoRefreshToken: false },
299+
global: { headers: { Authorization: 'Bearer valid-token' } },
300+
})
301+
);
302+
});
303+
304+
it('returns 500 when env vars missing for token auth', async () => {
305+
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
306+
307+
const response = await GET(createRequest('test-session-id', { token: 'some-token' }), {
308+
params: Promise.resolve({ sessionId: 'test-session-id' }),
309+
});
310+
const body = await response.json();
311+
312+
expect(response.status).toBe(500);
313+
expect(body.error).toBe('Server configuration error');
314+
});
315+
316+
it('returns 401 when token is invalid and no participantId', async () => {
317+
const otherUserSession = { ...mockSession, host_user_id: 'other-user-id' };
318+
const mockFrom = vi.fn().mockReturnValue({
319+
select: vi.fn().mockReturnThis(),
320+
eq: vi.fn().mockReturnThis(),
321+
single: vi.fn().mockResolvedValue({ data: otherUserSession, error: null }),
322+
});
323+
324+
const mockSupabase = createMockSupabaseClient({
325+
from: mockFrom,
326+
auth: {
327+
getUser: vi
328+
.fn()
329+
.mockResolvedValue({ data: { user: null }, error: { message: 'Invalid token' } }),
330+
},
331+
});
332+
mockCreateSupabaseClient.mockReturnValue(mockSupabase);
333+
334+
const response = await GET(createRequest('test-session-id', { token: 'invalid-token' }), {
335+
params: Promise.resolve({ sessionId: 'test-session-id' }),
336+
});
337+
const body = await response.json();
338+
339+
expect(response.status).toBe(401);
340+
expect(body.error).toBe('Authentication required');
341+
});
342+
343+
it('allows token auth with participantId as fallback', async () => {
344+
const otherUserSession = { ...mockSession, host_user_id: 'other-user-id' };
345+
const mockChannel = {
346+
on: vi.fn().mockReturnThis(),
347+
subscribe: vi.fn().mockReturnThis(),
348+
track: vi.fn().mockResolvedValue('ok'),
349+
};
350+
351+
const mockFrom = vi.fn().mockReturnValue({
352+
select: vi.fn().mockReturnThis(),
353+
eq: vi.fn().mockReturnThis(),
354+
single: vi.fn().mockResolvedValue({ data: otherUserSession, error: null }),
355+
});
356+
357+
const mockSupabase = createMockSupabaseClient({
358+
from: mockFrom,
359+
channel: vi.fn().mockReturnValue(mockChannel),
360+
removeChannel: vi.fn().mockResolvedValue('ok'),
361+
auth: {
362+
getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null }),
363+
},
364+
});
365+
mockCreateSupabaseClient.mockReturnValue(mockSupabase);
366+
367+
// Token is invalid but participantId provided as fallback
368+
const response = await GET(
369+
createRequest('test-session-id', { token: 'invalid-token', participantId: 'guest-id' }),
370+
{ params: Promise.resolve({ sessionId: 'test-session-id' }) }
371+
);
372+
373+
expect(response.status).toBe(200);
374+
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
375+
});
376+
});
237377
});

‎apps/web/src/app/api/sessions/[sessionId]/signal/stream/route.ts‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { createClient, getAuthenticatedUser } from '@/lib/supabase/server';
2+
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
3+
import type { Database } from '@pairux/shared-types';
24
import { z } from 'zod';
35

46
// Type for session (until Supabase types are regenerated)
@@ -11,6 +13,7 @@ interface Session {
1113
// Query params schema
1214
const querySchema = z.object({
1315
participantId: z.string().optional(),
16+
token: z.string().optional(), // Bearer token for desktop app (EventSource doesn't support headers)
1417
});
1518

1619
// Heartbeat interval (30 seconds)
@@ -26,6 +29,7 @@ export async function GET(
2629
const { searchParams } = new URL(request.url);
2730
const parseResult = querySchema.safeParse({
2831
participantId: searchParams.get('participantId') ?? undefined,
32+
token: searchParams.get('token') ?? undefined,
2933
});
3034

3135
if (!parseResult.success) {
@@ -35,9 +39,37 @@ export async function GET(
3539
);
3640
}
3741

38-
const { participantId } = parseResult.data;
39-
const supabase = await createClient();
40-
const { user } = await getAuthenticatedUser(supabase);
42+
const { participantId, token } = parseResult.data;
43+
44+
// Handle authentication - support both cookie-based (web) and token-based (desktop)
45+
let supabase;
46+
let user = null;
47+
48+
if (token) {
49+
// Desktop app: authenticate with Bearer token from query param
50+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
51+
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
52+
53+
if (!url || !key) {
54+
return new Response(JSON.stringify({ error: 'Server configuration error' }), {
55+
status: 500,
56+
headers: { 'Content-Type': 'application/json' },
57+
});
58+
}
59+
60+
supabase = createSupabaseClient<Database>(url, key, {
61+
auth: { persistSession: false, autoRefreshToken: false },
62+
global: { headers: { Authorization: `Bearer ${token}` } },
63+
});
64+
65+
const { data } = await supabase.auth.getUser(token);
66+
user = data.user;
67+
} else {
68+
// Web browser: use cookie-based auth
69+
supabase = await createClient();
70+
const authResult = await getAuthenticatedUser(supabase);
71+
user = authResult.user;
72+
}
4173

4274
// Verify session exists and is active
4375
const { data: sessionData } = await supabase

0 commit comments

Comments
 (0)