Skip to content

Commit 21a4c25

Browse files
committed
feat: add Google Gemini subscription support with browser OAuth
- Backend: GeminiAuthService for Google OAuth token exchange and refresh - Backend: GeminiAuthController with /start and /callback endpoints - Backend: Token refresh in proxy (refresh_token → access_token) for both primary and fallback paths - Backend: Google subscription uses Bearer header instead of ?key= param - Frontend: OAuth popup flow with popup blocker detection, origin validation, and automatic cleanup on popup close - Frontend: Gemini provider marked with oauthFlow flag - Tests: 100% line coverage on all new/modified files
1 parent b7707dd commit 21a4c25

19 files changed

+1577
-23
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"manifest": minor
3+
---
4+
5+
Add Google Gemini subscription support for routing proxy

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import { BadRequestException } from '@nestjs/common';
2+
import { GeminiAuthController } from './gemini-auth.controller';
3+
import { GeminiAuthService } from './gemini-auth.service';
4+
import { RoutingService } from '../routing.service';
5+
import { ResolveAgentService } from '../resolve-agent.service';
6+
import { Request, Response } from 'express';
7+
8+
describe('GeminiAuthController', () => {
9+
let controller: GeminiAuthController;
10+
let geminiAuth: jest.Mocked<GeminiAuthService>;
11+
let routingService: jest.Mocked<RoutingService>;
12+
let resolveAgentService: jest.Mocked<ResolveAgentService>;
13+
14+
const mockReq = {
15+
protocol: 'http',
16+
headers: {},
17+
get: jest.fn().mockReturnValue('localhost:3001'),
18+
} as unknown as Request;
19+
20+
const mockRes = {
21+
redirect: jest.fn(),
22+
status: jest.fn().mockReturnThis(),
23+
send: jest.fn(),
24+
} as unknown as Response;
25+
26+
const mockUser = { id: 'user-1', name: 'Test', email: 'test@test.com' };
27+
28+
beforeEach(() => {
29+
geminiAuth = {
30+
isConfigured: jest.fn().mockReturnValue(true),
31+
generateState: jest.fn().mockReturnValue('test-state-123'),
32+
buildAuthUrl: jest.fn().mockReturnValue('https://accounts.google.com/o/oauth2?state=test'),
33+
exchangeCode: jest.fn().mockResolvedValue('refresh-token-abc'),
34+
getAccessToken: jest.fn().mockResolvedValue('access-token'),
35+
getClientId: jest.fn().mockReturnValue('client-id'),
36+
} as unknown as jest.Mocked<GeminiAuthService>;
37+
38+
routingService = {
39+
upsertProvider: jest.fn().mockResolvedValue({ provider: {}, isNew: true }),
40+
} as unknown as jest.Mocked<RoutingService>;
41+
42+
resolveAgentService = {
43+
resolve: jest.fn().mockResolvedValue({ id: 'agent-id-1', name: 'my-agent' }),
44+
} as unknown as jest.Mocked<ResolveAgentService>;
45+
46+
controller = new GeminiAuthController(geminiAuth, routingService, resolveAgentService);
47+
48+
jest.clearAllMocks();
49+
// Re-apply defaults after clearAllMocks
50+
geminiAuth.isConfigured.mockReturnValue(true);
51+
geminiAuth.generateState.mockReturnValue('test-state-123');
52+
geminiAuth.buildAuthUrl.mockReturnValue('https://accounts.google.com/o/oauth2?state=test');
53+
geminiAuth.exchangeCode.mockResolvedValue('refresh-token-abc');
54+
resolveAgentService.resolve.mockResolvedValue({ id: 'agent-id-1', name: 'my-agent' } as never);
55+
routingService.upsertProvider.mockResolvedValue({ provider: {} as never, isNew: true });
56+
(mockRes.redirect as jest.Mock).mockClear();
57+
(mockRes.status as jest.Mock).mockClear().mockReturnThis();
58+
(mockRes.send as jest.Mock).mockClear();
59+
(mockReq.get as jest.Mock).mockReturnValue('localhost:3001');
60+
});
61+
62+
describe('start', () => {
63+
it('throws when agentName is missing', () => {
64+
expect(() => controller.start('', mockUser as never, mockReq, mockRes)).toThrow(
65+
BadRequestException,
66+
);
67+
});
68+
69+
it('throws when Google OAuth is not configured', () => {
70+
geminiAuth.isConfigured.mockReturnValue(false);
71+
expect(() => controller.start('my-agent', mockUser as never, mockReq, mockRes)).toThrow(
72+
'Google OAuth not configured',
73+
);
74+
});
75+
76+
it('generates state, stores pending, and redirects to auth URL', () => {
77+
controller.start('my-agent', mockUser as never, mockReq, mockRes);
78+
79+
expect(geminiAuth.generateState).toHaveBeenCalled();
80+
expect(geminiAuth.buildAuthUrl).toHaveBeenCalledWith(
81+
'http://localhost:3001/api/v1/routing/gemini-auth/callback',
82+
'test-state-123',
83+
);
84+
expect(mockRes.redirect).toHaveBeenCalledWith(
85+
'https://accounts.google.com/o/oauth2?state=test',
86+
);
87+
});
88+
89+
it('sweeps expired states on each start call', () => {
90+
// Start creates a pending state
91+
controller.start('agent-1', mockUser as never, mockReq, mockRes);
92+
const pendingStates = (
93+
controller as unknown as { pendingStates: Map<string, { expiresAt: number }> }
94+
).pendingStates;
95+
expect(pendingStates.size).toBe(1);
96+
97+
// Expire it manually
98+
const entry = pendingStates.get('test-state-123')!;
99+
entry.expiresAt = Date.now() - 1000;
100+
101+
// Next start should clean up the expired one and add a new one
102+
geminiAuth.generateState.mockReturnValue('new-state-456');
103+
controller.start('agent-2', mockUser as never, mockReq, mockRes);
104+
expect(pendingStates.has('test-state-123')).toBe(false);
105+
expect(pendingStates.has('new-state-456')).toBe(true);
106+
expect(pendingStates.size).toBe(1);
107+
});
108+
109+
it('uses x-forwarded-proto and x-forwarded-host headers', () => {
110+
const reqWithProxy = {
111+
protocol: 'http',
112+
headers: {
113+
'x-forwarded-proto': 'https',
114+
'x-forwarded-host': 'app.example.com',
115+
},
116+
get: jest.fn().mockReturnValue('localhost:3001'),
117+
} as unknown as Request;
118+
119+
controller.start('my-agent', mockUser as never, reqWithProxy, mockRes);
120+
121+
expect(geminiAuth.buildAuthUrl).toHaveBeenCalledWith(
122+
'https://app.example.com/api/v1/routing/gemini-auth/callback',
123+
'test-state-123',
124+
);
125+
});
126+
});
127+
128+
describe('callback', () => {
129+
it('returns 400 when code is missing', async () => {
130+
await controller.callback('', 'state', mockReq, mockRes);
131+
132+
expect(mockRes.status).toHaveBeenCalledWith(400);
133+
expect(mockRes.send).toHaveBeenCalledWith(
134+
expect.stringContaining('Missing code or state parameter'),
135+
);
136+
});
137+
138+
it('returns 400 when state is missing', async () => {
139+
await controller.callback('code', '', mockReq, mockRes);
140+
141+
expect(mockRes.status).toHaveBeenCalledWith(400);
142+
});
143+
144+
it('returns 400 when state is not found in pending', async () => {
145+
await controller.callback('code', 'unknown-state', mockReq, mockRes);
146+
147+
expect(mockRes.status).toHaveBeenCalledWith(400);
148+
expect(mockRes.send).toHaveBeenCalledWith(
149+
expect.stringContaining('OAuth state expired or invalid'),
150+
);
151+
});
152+
153+
it('returns 400 when state has expired', async () => {
154+
// Start to create a pending state, then expire it
155+
controller.start('my-agent', mockUser as never, mockReq, mockRes);
156+
157+
// Manually expire the state by accessing the pendingStates map
158+
const pendingStates = (
159+
controller as unknown as { pendingStates: Map<string, { expiresAt: number }> }
160+
).pendingStates;
161+
const entry = pendingStates.get('test-state-123')!;
162+
entry.expiresAt = Date.now() - 1000; // expired
163+
164+
await controller.callback('code', 'test-state-123', mockReq, mockRes);
165+
166+
expect(mockRes.status).toHaveBeenCalledWith(400);
167+
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('expired or invalid'));
168+
});
169+
170+
it('exchanges code and upserts provider on success', async () => {
171+
// Start to create pending state
172+
controller.start('my-agent', mockUser as never, mockReq, mockRes);
173+
jest.clearAllMocks();
174+
(mockReq.get as jest.Mock).mockReturnValue('localhost:3001');
175+
geminiAuth.exchangeCode.mockResolvedValue('refresh-token-abc');
176+
resolveAgentService.resolve.mockResolvedValue({
177+
id: 'agent-id-1',
178+
name: 'my-agent',
179+
} as never);
180+
routingService.upsertProvider.mockResolvedValue({ provider: {} as never, isNew: true });
181+
182+
await controller.callback('auth-code', 'test-state-123', mockReq, mockRes);
183+
184+
expect(geminiAuth.exchangeCode).toHaveBeenCalledWith(
185+
'auth-code',
186+
'http://localhost:3001/api/v1/routing/gemini-auth/callback',
187+
);
188+
expect(resolveAgentService.resolve).toHaveBeenCalledWith('user-1', 'my-agent');
189+
expect(routingService.upsertProvider).toHaveBeenCalledWith(
190+
'agent-id-1',
191+
'user-1',
192+
'gemini',
193+
'refresh-token-abc',
194+
'subscription',
195+
);
196+
expect(mockRes.send).toHaveBeenCalledWith(
197+
expect.stringContaining('Gemini connected successfully'),
198+
);
199+
});
200+
201+
it('returns success HTML with green color and postMessage', async () => {
202+
controller.start('my-agent', mockUser as never, mockReq, mockRes);
203+
jest.clearAllMocks();
204+
(mockReq.get as jest.Mock).mockReturnValue('localhost:3001');
205+
geminiAuth.exchangeCode.mockResolvedValue('rt');
206+
resolveAgentService.resolve.mockResolvedValue({ id: 'a1', name: 'ag' } as never);
207+
routingService.upsertProvider.mockResolvedValue({ provider: {} as never, isNew: true });
208+
209+
await controller.callback('code', 'test-state-123', mockReq, mockRes);
210+
211+
const html = (mockRes.send as jest.Mock).mock.calls[0][0] as string;
212+
expect(html).toContain('#16a34a');
213+
expect(html).toContain('success:true');
214+
expect(html).toContain('gemini-auth-done');
215+
expect(html).toContain('window.location.origin');
216+
});
217+
218+
it('returns 500 when exchangeCode throws', async () => {
219+
controller.start('my-agent', mockUser as never, mockReq, mockRes);
220+
jest.clearAllMocks();
221+
(mockReq.get as jest.Mock).mockReturnValue('localhost:3001');
222+
geminiAuth.exchangeCode.mockRejectedValue(new Error('Exchange failed'));
223+
224+
await controller.callback('code', 'test-state-123', mockReq, mockRes);
225+
226+
expect(mockRes.status).toHaveBeenCalledWith(500);
227+
expect(mockRes.send).toHaveBeenCalledWith(
228+
expect.stringContaining('Failed to complete Google authentication'),
229+
);
230+
});
231+
232+
it('returns error HTML with red color', async () => {
233+
controller.start('my-agent', mockUser as never, mockReq, mockRes);
234+
jest.clearAllMocks();
235+
(mockReq.get as jest.Mock).mockReturnValue('localhost:3001');
236+
geminiAuth.exchangeCode.mockRejectedValue(new Error('fail'));
237+
238+
await controller.callback('code', 'test-state-123', mockReq, mockRes);
239+
240+
const html = (mockRes.send as jest.Mock).mock.calls[0][0] as string;
241+
expect(html).toContain('#dc2626');
242+
expect(html).toContain('success:false');
243+
});
244+
245+
it('handles non-Error thrown values', async () => {
246+
controller.start('my-agent', mockUser as never, mockReq, mockRes);
247+
jest.clearAllMocks();
248+
(mockReq.get as jest.Mock).mockReturnValue('localhost:3001');
249+
geminiAuth.exchangeCode.mockRejectedValue('string error');
250+
251+
await controller.callback('code', 'test-state-123', mockReq, mockRes);
252+
253+
expect(mockRes.status).toHaveBeenCalledWith(500);
254+
});
255+
256+
it('deletes pending state after use (replay protection)', async () => {
257+
controller.start('my-agent', mockUser as never, mockReq, mockRes);
258+
jest.clearAllMocks();
259+
(mockReq.get as jest.Mock).mockReturnValue('localhost:3001');
260+
geminiAuth.exchangeCode.mockResolvedValue('rt');
261+
resolveAgentService.resolve.mockResolvedValue({ id: 'a1', name: 'ag' } as never);
262+
routingService.upsertProvider.mockResolvedValue({ provider: {} as never, isNew: true });
263+
264+
// First call succeeds
265+
await controller.callback('code', 'test-state-123', mockReq, mockRes);
266+
expect(mockRes.send).toHaveBeenCalled();
267+
268+
// Second call with same state fails
269+
jest.clearAllMocks();
270+
(mockRes.status as jest.Mock).mockReturnThis();
271+
await controller.callback('code', 'test-state-123', mockReq, mockRes);
272+
expect(mockRes.status).toHaveBeenCalledWith(400);
273+
});
274+
});
275+
276+
describe('closePage', () => {
277+
it('returns HTML with the message', () => {
278+
const html = (
279+
controller as unknown as { closePage: (msg: string, s?: boolean) => string }
280+
).closePage('Test message');
281+
expect(html).toContain('Test message');
282+
expect(html).toContain('<!DOCTYPE html>');
283+
expect(html).toContain('#dc2626'); // error red by default
284+
});
285+
286+
it('returns green for success', () => {
287+
const html = (
288+
controller as unknown as { closePage: (msg: string, s?: boolean) => string }
289+
).closePage('OK', true);
290+
expect(html).toContain('#16a34a');
291+
expect(html).toContain('success:true');
292+
});
293+
});
294+
});

0 commit comments

Comments
 (0)