Skip to content

Commit 62ad820

Browse files
authored
refactor: move oauth service to trpc (#281)
1 parent 34b22fc commit 62ad820

File tree

13 files changed

+190
-129
lines changed

13 files changed

+190
-129
lines changed

apps/array/src/main/di/container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { FileWatcherService } from "../services/file-watcher/service.js";
77
import { FoldersService } from "../services/folders/service.js";
88
import { FsService } from "../services/fs/service.js";
99
import { GitService } from "../services/git/service.js";
10+
import { OAuthService } from "../services/oauth/service.js";
1011
import { ShellService } from "../services/shell/service.js";
1112
import { MAIN_TOKENS } from "./tokens.js";
1213

@@ -21,4 +22,5 @@ container.bind(MAIN_TOKENS.FileWatcherService).to(FileWatcherService);
2122
container.bind(MAIN_TOKENS.FoldersService).to(FoldersService);
2223
container.bind(MAIN_TOKENS.FsService).to(FsService);
2324
container.bind(MAIN_TOKENS.GitService).to(GitService);
25+
container.bind(MAIN_TOKENS.OAuthService).to(OAuthService);
2426
container.bind(MAIN_TOKENS.ShellService).to(ShellService);

apps/array/src/main/di/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ export const MAIN_TOKENS = Object.freeze({
1313
FoldersService: Symbol.for("Main.FoldersService"),
1414
FsService: Symbol.for("Main.FsService"),
1515
GitService: Symbol.for("Main.GitService"),
16+
OAuthService: Symbol.for("Main.OAuthService"),
1617
ShellService: Symbol.for("Main.ShellService"),
1718
});

apps/array/src/main/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ type TaskController = unknown;
3636
import { registerGitIpc } from "./services/git.js";
3737
import "./services/index.js";
3838
import { ExternalAppsService } from "./services/external-apps/service.js";
39-
import { registerOAuthHandlers } from "./services/oauth.js";
4039
import {
4140
initializePostHog,
4241
shutdownPostHog,
@@ -291,7 +290,6 @@ registerAutoUpdater(() => mainWindow);
291290
ipcMain.handle("app:get-version", () => app.getVersion());
292291

293292
// Register IPC handlers via services
294-
registerOAuthHandlers();
295293
registerGitIpc();
296294
registerAgentIpc(taskControllers, () => mainWindow);
297295
registerWorkspaceIpc(() => mainWindow);

apps/array/src/main/preload.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import type {
88
WorkspaceInfo,
99
WorkspaceTerminalInfo,
1010
} from "../shared/types";
11-
import type { CloudRegion, OAuthTokenResponse } from "../shared/types/oauth";
1211
import "electron-log/preload";
1312

1413
process.once("loaded", () => {
@@ -52,18 +51,6 @@ interface AgentStartParams {
5251
}
5352

5453
contextBridge.exposeInMainWorld("electronAPI", {
55-
// OAuth API
56-
oauthStartFlow: (
57-
region: CloudRegion,
58-
): Promise<{ success: boolean; data?: OAuthTokenResponse; error?: string }> =>
59-
ipcRenderer.invoke("oauth:start-flow", region),
60-
oauthRefreshToken: (
61-
refreshToken: string,
62-
region: CloudRegion,
63-
): Promise<{ success: boolean; data?: OAuthTokenResponse; error?: string }> =>
64-
ipcRenderer.invoke("oauth:refresh-token", refreshToken, region),
65-
oauthCancelFlow: (): Promise<{ success: boolean; error?: string }> =>
66-
ipcRenderer.invoke("oauth:cancel-flow"),
6754
// Repo API
6855
validateRepo: (directoryPath: string): Promise<boolean> =>
6956
ipcRenderer.invoke("validate-repo", directoryPath),

apps/array/src/main/services/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*/
55

66
import "./git.js";
7-
import "./oauth.js";
87
import "./posthog-analytics.js";
98
import "./session-manager.js";
109
import "./settingsStore.js";
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { z } from "zod";
2+
3+
export const cloudRegion = z.enum(["us", "eu", "dev"]);
4+
export type CloudRegion = z.infer<typeof cloudRegion>;
5+
6+
export const oAuthTokenResponse = z.object({
7+
access_token: z.string(),
8+
expires_in: z.number(),
9+
token_type: z.string(),
10+
scope: z.string(),
11+
refresh_token: z.string(),
12+
scoped_teams: z.array(z.number()).optional(),
13+
scoped_organizations: z.array(z.string()).optional(),
14+
});
15+
export type OAuthTokenResponse = z.infer<typeof oAuthTokenResponse>;
16+
17+
export const startFlowInput = z.object({
18+
region: cloudRegion,
19+
});
20+
export type StartFlowInput = z.infer<typeof startFlowInput>;
21+
22+
export const startFlowOutput = z.object({
23+
success: z.boolean(),
24+
data: oAuthTokenResponse.optional(),
25+
error: z.string().optional(),
26+
});
27+
export type StartFlowOutput = z.infer<typeof startFlowOutput>;
28+
29+
export const refreshTokenInput = z.object({
30+
refreshToken: z.string(),
31+
region: cloudRegion,
32+
});
33+
export type RefreshTokenInput = z.infer<typeof refreshTokenInput>;
34+
35+
export const refreshTokenOutput = z.object({
36+
success: z.boolean(),
37+
data: oAuthTokenResponse.optional(),
38+
error: z.string().optional(),
39+
});
40+
export type RefreshTokenOutput = z.infer<typeof refreshTokenOutput>;
41+
42+
export const cancelFlowOutput = z.object({
43+
success: z.boolean(),
44+
error: z.string().optional(),
45+
});
46+
export type CancelFlowOutput = z.infer<typeof cancelFlowOutput>;

apps/array/src/main/services/oauth.ts renamed to apps/array/src/main/services/oauth/service.ts

Lines changed: 99 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
import * as crypto from "node:crypto";
22
import * as http from "node:http";
33
import type { Socket } from "node:net";
4-
import { ipcMain, shell } from "electron";
4+
import { shell } from "electron";
5+
import { injectable } from "inversify";
56
import {
67
getCloudUrlFromRegion,
78
getOauthClientIdFromRegion,
89
OAUTH_PORT,
910
OAUTH_SCOPES,
10-
} from "../../constants/oauth";
11+
} from "../../../constants/oauth.js";
1112
import type {
13+
CancelFlowOutput,
1214
CloudRegion,
13-
OAuthConfig,
1415
OAuthTokenResponse,
15-
} from "../../shared/types/oauth";
16+
RefreshTokenOutput,
17+
StartFlowOutput,
18+
} from "./schemas.js";
19+
20+
interface OAuthConfig {
21+
scopes: string[];
22+
cloudRegion: CloudRegion;
23+
}
1624

1725
function generateCodeVerifier(): string {
1826
return crypto.randomBytes(32).toString("base64url");
@@ -155,7 +163,6 @@ async function startCallbackServer(authUrl: string): Promise<{
155163
}
156164
});
157165

158-
// Track connections
159166
server.on("connection", (conn) => {
160167
connections.add(conn);
161168
conn.on("close", () => {
@@ -164,7 +171,6 @@ async function startCallbackServer(authUrl: string): Promise<{
164171
});
165172

166173
const closeServer = () => {
167-
// Destroy all active connections
168174
for (const conn of connections) {
169175
conn.destroy();
170176
}
@@ -233,76 +239,23 @@ async function refreshTokenRequest(
233239
return response.json();
234240
}
235241

236-
let activeCloseServer: (() => void) | null = null;
237-
238-
export async function performOAuthFlow(
239-
config: OAuthConfig,
240-
): Promise<OAuthTokenResponse> {
241-
const cloudUrl = getCloudUrlFromRegion(config.cloudRegion);
242-
const codeVerifier = generateCodeVerifier();
243-
const codeChallenge = generateCodeChallenge(codeVerifier);
244-
245-
const authUrl = new URL(`${cloudUrl}/oauth/authorize`);
246-
authUrl.searchParams.set(
247-
"client_id",
248-
getOauthClientIdFromRegion(config.cloudRegion),
249-
);
250-
authUrl.searchParams.set(
251-
"redirect_uri",
252-
`http://localhost:${OAUTH_PORT}/callback`,
253-
);
254-
authUrl.searchParams.set("response_type", "code");
255-
authUrl.searchParams.set("code_challenge", codeChallenge);
256-
authUrl.searchParams.set("code_challenge_method", "S256");
257-
authUrl.searchParams.set("scope", config.scopes.join(" "));
258-
authUrl.searchParams.set("required_access_level", "project");
259-
260-
const localLoginUrl = `http://localhost:${OAUTH_PORT}/authorize`;
261-
262-
const { closeServer, waitForCallback } = await startCallbackServer(
263-
authUrl.toString(),
264-
);
265-
266-
activeCloseServer = closeServer;
267-
268-
await shell.openExternal(localLoginUrl);
269-
270-
try {
271-
const code = await Promise.race([
272-
waitForCallback(),
273-
new Promise<never>((_, reject) =>
274-
setTimeout(() => reject(new Error("Authorization timed out")), 180_000),
275-
),
276-
]);
277-
278-
const token = await exchangeCodeForToken(code, codeVerifier, config);
279-
280-
closeServer();
281-
activeCloseServer = null;
282-
283-
return token;
284-
} catch (error) {
285-
closeServer();
286-
activeCloseServer = null;
287-
throw error;
288-
}
289-
}
242+
@injectable()
243+
export class OAuthService {
244+
private activeCloseServer: (() => void) | null = null;
290245

291-
export function registerOAuthHandlers(): void {
292-
ipcMain.handle("oauth:start-flow", async (_, region: CloudRegion) => {
246+
async startFlow(region: CloudRegion): Promise<StartFlowOutput> {
293247
try {
294-
// Close any existing server before starting a new flow
295-
if (activeCloseServer) {
296-
activeCloseServer();
297-
activeCloseServer = null;
248+
if (this.activeCloseServer) {
249+
this.activeCloseServer();
250+
this.activeCloseServer = null;
298251
}
299252

300253
const config: OAuthConfig = {
301254
scopes: OAUTH_SCOPES,
302255
cloudRegion: region,
303256
};
304257

305-
const tokenResponse = await performOAuthFlow(config);
258+
const tokenResponse = await this.performOAuthFlow(config);
306259

307260
return {
308261
success: true,
@@ -314,31 +267,31 @@ export function registerOAuthHandlers(): void {
314267
error: error instanceof Error ? error.message : "Unknown error",
315268
};
316269
}
317-
});
270+
}
318271

319-
ipcMain.handle(
320-
"oauth:refresh-token",
321-
async (_, refreshToken: string, region: CloudRegion) => {
322-
try {
323-
const tokenResponse = await refreshTokenRequest(refreshToken, region);
324-
return {
325-
success: true,
326-
data: tokenResponse,
327-
};
328-
} catch (error) {
329-
return {
330-
success: false,
331-
error: error instanceof Error ? error.message : "Unknown error",
332-
};
333-
}
334-
},
335-
);
272+
async refreshToken(
273+
refreshToken: string,
274+
region: CloudRegion,
275+
): Promise<RefreshTokenOutput> {
276+
try {
277+
const tokenResponse = await refreshTokenRequest(refreshToken, region);
278+
return {
279+
success: true,
280+
data: tokenResponse,
281+
};
282+
} catch (error) {
283+
return {
284+
success: false,
285+
error: error instanceof Error ? error.message : "Unknown error",
286+
};
287+
}
288+
}
336289

337-
ipcMain.handle("oauth:cancel-flow", async () => {
290+
cancelFlow(): CancelFlowOutput {
338291
try {
339-
if (activeCloseServer) {
340-
activeCloseServer();
341-
activeCloseServer = null;
292+
if (this.activeCloseServer) {
293+
this.activeCloseServer();
294+
this.activeCloseServer = null;
342295
}
343296
return { success: true };
344297
} catch (error) {
@@ -347,5 +300,61 @@ export function registerOAuthHandlers(): void {
347300
error: error instanceof Error ? error.message : "Unknown error",
348301
};
349302
}
350-
});
303+
}
304+
305+
private async performOAuthFlow(
306+
config: OAuthConfig,
307+
): Promise<OAuthTokenResponse> {
308+
const cloudUrl = getCloudUrlFromRegion(config.cloudRegion);
309+
const codeVerifier = generateCodeVerifier();
310+
const codeChallenge = generateCodeChallenge(codeVerifier);
311+
312+
const authUrl = new URL(`${cloudUrl}/oauth/authorize`);
313+
authUrl.searchParams.set(
314+
"client_id",
315+
getOauthClientIdFromRegion(config.cloudRegion),
316+
);
317+
authUrl.searchParams.set(
318+
"redirect_uri",
319+
`http://localhost:${OAUTH_PORT}/callback`,
320+
);
321+
authUrl.searchParams.set("response_type", "code");
322+
authUrl.searchParams.set("code_challenge", codeChallenge);
323+
authUrl.searchParams.set("code_challenge_method", "S256");
324+
authUrl.searchParams.set("scope", config.scopes.join(" "));
325+
authUrl.searchParams.set("required_access_level", "project");
326+
327+
const localLoginUrl = `http://localhost:${OAUTH_PORT}/authorize`;
328+
329+
const { closeServer, waitForCallback } = await startCallbackServer(
330+
authUrl.toString(),
331+
);
332+
333+
this.activeCloseServer = closeServer;
334+
335+
await shell.openExternal(localLoginUrl);
336+
337+
try {
338+
const code = await Promise.race([
339+
waitForCallback(),
340+
new Promise<never>((_, reject) =>
341+
setTimeout(
342+
() => reject(new Error("Authorization timed out")),
343+
180_000,
344+
),
345+
),
346+
]);
347+
348+
const token = await exchangeCodeForToken(code, codeVerifier, config);
349+
350+
closeServer();
351+
this.activeCloseServer = null;
352+
353+
return token;
354+
} catch (error) {
355+
closeServer();
356+
this.activeCloseServer = null;
357+
throw error;
358+
}
359+
}
351360
}

apps/array/src/main/trpc/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { foldersRouter } from "./routers/folders.js";
77
import { fsRouter } from "./routers/fs.js";
88
import { gitRouter } from "./routers/git.js";
99
import { logsRouter } from "./routers/logs.js";
10+
import { oauthRouter } from "./routers/oauth.js";
1011
import { osRouter } from "./routers/os.js";
1112
import { secureStoreRouter } from "./routers/secure-store.js";
1213
import { shellRouter } from "./routers/shell.js";
@@ -22,6 +23,7 @@ export const trpcRouter = router({
2223
fs: fsRouter,
2324
git: gitRouter,
2425
logs: logsRouter,
26+
oauth: oauthRouter,
2527
os: osRouter,
2628
secureStore: secureStoreRouter,
2729
shell: shellRouter,

0 commit comments

Comments
 (0)