Skip to content

Commit 2aeddcc

Browse files
committed
feat: move from oauth to first party auth support
1 parent d0c069e commit 2aeddcc

File tree

13 files changed

+936
-142
lines changed

13 files changed

+936
-142
lines changed

apps/twig/src/api/posthogClient.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,4 +373,62 @@ export class PostHogAPIClient {
373373
});
374374
return data.results ?? [];
375375
}
376+
377+
/**
378+
* Get details for multiple projects by their IDs.
379+
* Returns project info including organization details.
380+
*/
381+
async getProjectDetails(projectIds: number[]): Promise<
382+
Array<{
383+
id: number;
384+
name: string;
385+
organization: { id: string; name: string };
386+
}>
387+
> {
388+
const results = await Promise.all(
389+
projectIds.map(async (projectId) => {
390+
try {
391+
const project = await this.getProject(projectId);
392+
return {
393+
id: project.id,
394+
name: project.name ?? `Project ${project.id}`,
395+
organization: {
396+
id: project.organization?.toString() ?? "",
397+
name: project.organization?.toString() ?? "Unknown Organization",
398+
},
399+
};
400+
} catch (error) {
401+
log.warn(`Failed to fetch project ${projectId}:`, error);
402+
return null;
403+
}
404+
}),
405+
);
406+
return results.filter((r): r is NonNullable<typeof r> => r !== null);
407+
}
408+
409+
/**
410+
* Get all organizations the user belongs to.
411+
*/
412+
async getOrganizations(): Promise<
413+
Array<{ id: string; name: string; slug: string }>
414+
> {
415+
const url = new URL(`${this.api.baseUrl}/api/organizations/`);
416+
const response = await this.api.fetcher.fetch({
417+
method: "get",
418+
url,
419+
path: "/api/organizations/",
420+
});
421+
422+
if (!response.ok) {
423+
throw new Error(`Failed to fetch organizations: ${response.statusText}`);
424+
}
425+
426+
const data = await response.json();
427+
const orgs = data.results ?? data ?? [];
428+
return orgs.map((org: { id: string; name: string; slug?: string }) => ({
429+
id: org.id,
430+
name: org.name,
431+
slug: org.slug ?? org.id,
432+
}));
433+
}
376434
}

apps/twig/src/main/services/oauth/schemas.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const oAuthTokenResponse = z.object({
2222
access_token: z.string(),
2323
expires_in: z.number(),
2424
token_type: z.string(),
25-
scope: z.string(),
25+
scope: z.string().optional().default(""),
2626
refresh_token: z.string(),
2727
scoped_teams: z.array(z.number()).optional(),
2828
scoped_organizations: z.array(z.string()).optional(),
@@ -42,6 +42,9 @@ export const startFlowOutput = z.object({
4242
});
4343
export type StartFlowOutput = z.infer<typeof startFlowOutput>;
4444

45+
export const startSignupFlowInput = startFlowInput;
46+
export type StartSignupFlowInput = z.infer<typeof startSignupFlowInput>;
47+
4548
export const refreshTokenInput = z.object({
4649
refreshToken: z.string(),
4750
region: cloudRegion,
@@ -61,3 +64,8 @@ export const cancelFlowOutput = z.object({
6164
error: z.string().optional(),
6265
});
6366
export type CancelFlowOutput = z.infer<typeof cancelFlowOutput>;
67+
68+
export const openExternalUrlInput = z.object({
69+
url: z.string().url(),
70+
});
71+
export type OpenExternalUrlInput = z.infer<typeof openExternalUrlInput>;

apps/twig/src/main/services/oauth/service.ts

Lines changed: 83 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -109,44 +109,43 @@ export class OAuthService {
109109
};
110110

111111
const codeVerifier = this.generateCodeVerifier();
112-
const codeChallenge = this.generateCodeChallenge(codeVerifier);
113-
const redirectUri = this.getRedirectUri();
112+
const authUrl = this.buildAuthorizeUrl(region, codeVerifier);
114113

115-
// Build the authorization URL
116-
const cloudUrl = getCloudUrlFromRegion(region);
117-
const authUrl = new URL(`${cloudUrl}/oauth/authorize`);
118-
authUrl.searchParams.set("client_id", getOauthClientIdFromRegion(region));
119-
authUrl.searchParams.set("redirect_uri", redirectUri);
120-
authUrl.searchParams.set("response_type", "code");
121-
authUrl.searchParams.set("code_challenge", codeChallenge);
122-
authUrl.searchParams.set("code_challenge_method", "S256");
123-
authUrl.searchParams.set("scope", config.scopes.join(" "));
124-
authUrl.searchParams.set("required_access_level", "project");
125-
126-
// Create a promise that will be resolved when the callback arrives
127-
const code = IS_DEV
128-
? await this.waitForHttpCallback(
129-
codeVerifier,
130-
config,
131-
authUrl.toString(),
132-
)
133-
: await this.waitForDeepLinkCallback(
134-
codeVerifier,
135-
config,
136-
authUrl.toString(),
137-
);
138-
139-
// Exchange the code for tokens
140-
const tokenResponse = await this.exchangeCodeForToken(
141-
code,
142-
codeVerifier,
114+
return await this.startFlowWithUrl(
143115
config,
116+
codeVerifier,
117+
authUrl.toString(),
144118
);
145-
119+
} catch (error) {
146120
return {
147-
success: true,
148-
data: tokenResponse,
121+
success: false,
122+
error: error instanceof Error ? error.message : "Unknown error",
123+
};
124+
}
125+
}
126+
127+
/**
128+
* Start the OAuth flow from the signup page.
129+
*/
130+
public async startSignupFlow(region: CloudRegion): Promise<StartFlowOutput> {
131+
try {
132+
// Cancel any existing flow
133+
this.cancelFlow();
134+
135+
const config: OAuthConfig = {
136+
scopes: OAUTH_SCOPES,
137+
cloudRegion: region,
149138
};
139+
140+
const codeVerifier = this.generateCodeVerifier();
141+
const authUrl = this.buildAuthorizeUrl(region, codeVerifier);
142+
const signupUrl = this.buildSignupUrl(region, authUrl);
143+
144+
return await this.startFlowWithUrl(
145+
config,
146+
codeVerifier,
147+
signupUrl.toString(),
148+
);
150149
} catch (error) {
151150
return {
152151
success: false,
@@ -443,11 +442,62 @@ export class OAuthService {
443442
return response.json();
444443
}
445444

445+
private buildAuthorizeUrl(region: CloudRegion, codeVerifier: string): URL {
446+
const codeChallenge = this.generateCodeChallenge(codeVerifier);
447+
const redirectUri = this.getRedirectUri();
448+
const cloudUrl = getCloudUrlFromRegion(region);
449+
const authUrl = new URL(`${cloudUrl}/oauth/authorize`);
450+
authUrl.searchParams.set("client_id", getOauthClientIdFromRegion(region));
451+
authUrl.searchParams.set("redirect_uri", redirectUri);
452+
authUrl.searchParams.set("response_type", "code");
453+
authUrl.searchParams.set("code_challenge", codeChallenge);
454+
authUrl.searchParams.set("code_challenge_method", "S256");
455+
authUrl.searchParams.set("scope", OAUTH_SCOPES.join(" "));
456+
authUrl.searchParams.set("required_access_level", "project");
457+
return authUrl;
458+
}
459+
460+
private buildSignupUrl(region: CloudRegion, authUrl: URL): URL {
461+
const cloudUrl = getCloudUrlFromRegion(region);
462+
const signupUrl = new URL(`${cloudUrl}/signup`);
463+
const nextPath = `${authUrl.pathname}${authUrl.search}`;
464+
signupUrl.searchParams.set("next", nextPath);
465+
return signupUrl;
466+
}
467+
468+
private async startFlowWithUrl(
469+
config: OAuthConfig,
470+
codeVerifier: string,
471+
authUrl: string,
472+
): Promise<StartFlowOutput> {
473+
const code = IS_DEV
474+
? await this.waitForHttpCallback(codeVerifier, config, authUrl)
475+
: await this.waitForDeepLinkCallback(codeVerifier, config, authUrl);
476+
477+
const tokenResponse = await this.exchangeCodeForToken(
478+
code,
479+
codeVerifier,
480+
config,
481+
);
482+
483+
return {
484+
success: true,
485+
data: tokenResponse,
486+
};
487+
}
488+
446489
private generateCodeVerifier(): string {
447490
return crypto.randomBytes(32).toString("base64url");
448491
}
449492

450493
private generateCodeChallenge(verifier: string): string {
451494
return crypto.createHash("sha256").update(verifier).digest("base64url");
452495
}
496+
497+
/**
498+
* Open an external URL in the default browser.
499+
*/
500+
public async openExternalUrl(url: string): Promise<void> {
501+
await shell.openExternal(url);
502+
}
453503
}

apps/twig/src/main/trpc/routers/oauth.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { container } from "../../di/container.js";
22
import { MAIN_TOKENS } from "../../di/tokens.js";
33
import {
44
cancelFlowOutput,
5+
openExternalUrlInput,
56
refreshTokenInput,
67
refreshTokenOutput,
78
startFlowInput,
89
startFlowOutput,
10+
startSignupFlowInput,
911
} from "../../services/oauth/schemas.js";
1012
import type { OAuthService } from "../../services/oauth/service.js";
1113
import { publicProcedure, router } from "../trpc.js";
@@ -18,6 +20,11 @@ export const oauthRouter = router({
1820
.output(startFlowOutput)
1921
.mutation(({ input }) => getService().startFlow(input.region)),
2022

23+
startSignupFlow: publicProcedure
24+
.input(startSignupFlowInput)
25+
.output(startFlowOutput)
26+
.mutation(({ input }) => getService().startSignupFlow(input.region)),
27+
2128
refreshToken: publicProcedure
2229
.input(refreshTokenInput)
2330
.output(refreshTokenOutput)
@@ -28,4 +35,8 @@ export const oauthRouter = router({
2835
cancelFlow: publicProcedure
2936
.output(cancelFlowOutput)
3037
.mutation(() => getService().cancelFlow()),
38+
39+
openExternalUrl: publicProcedure
40+
.input(openExternalUrlInput)
41+
.mutation(({ input }) => getService().openExternalUrl(input.url)),
3142
});

apps/twig/src/renderer/App.tsx

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -138,29 +138,36 @@ function App() {
138138
);
139139
}
140140

141+
// Determine which screen to show
142+
const renderContent = () => {
143+
if (!isAuthenticated) {
144+
return (
145+
<motion.div
146+
key="auth"
147+
initial={{ opacity: 1 }}
148+
exit={{ opacity: 0 }}
149+
transition={{ duration: 0.5 }}
150+
>
151+
<AuthScreen />
152+
</motion.div>
153+
);
154+
}
155+
156+
return (
157+
<motion.div
158+
key="main"
159+
initial={{ opacity: 0 }}
160+
animate={{ opacity: 1 }}
161+
transition={{ duration: 0.5, delay: showTransition ? 1.5 : 0 }}
162+
>
163+
<MainLayout />
164+
</motion.div>
165+
);
166+
};
167+
141168
return (
142169
<ErrorBoundary name="App">
143-
<AnimatePresence mode="wait">
144-
{!isAuthenticated ? (
145-
<motion.div
146-
key="auth"
147-
initial={{ opacity: 1 }}
148-
exit={{ opacity: 0 }}
149-
transition={{ duration: 0.5 }}
150-
>
151-
<AuthScreen />
152-
</motion.div>
153-
) : (
154-
<motion.div
155-
key="main"
156-
initial={{ opacity: 0 }}
157-
animate={{ opacity: 1 }}
158-
transition={{ duration: 0.5, delay: showTransition ? 1.5 : 0 }}
159-
>
160-
<MainLayout />
161-
</motion.div>
162-
)}
163-
</AnimatePresence>
170+
<AnimatePresence mode="wait">{renderContent()}</AnimatePresence>
164171
<LoginTransition
165172
isAnimating={showTransition}
166173
onComplete={handleTransitionComplete}

0 commit comments

Comments
 (0)