Skip to content

Commit e649853

Browse files
committed
wip refresh oauth tokens
1 parent 6cc9d0b commit e649853

File tree

10 files changed

+143
-9
lines changed

10 files changed

+143
-9
lines changed

packages/web/src/app/[domain]/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
2424
import { GitHubStarToast } from "./components/githubStarToast";
2525
import { UpgradeToast } from "./components/upgradeToast";
2626
import { getIntegrationProviderStates } from "@/ee/features/permissionSyncing/actions";
27-
import { LinkAccounts } from "@/ee/features/permissionSyncing/linkAccounts";
27+
import { LinkAccounts } from "@/ee/features/permissionSyncing/components/linkAccounts";
2828

2929
interface LayoutProps {
3030
children: React.ReactNode,

packages/web/src/app/[domain]/settings/permission-syncing/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { hasEntitlement } from "@sourcebot/shared";
22
import { notFound } from "@/lib/serviceError";
3-
import { LinkedAccountsSettings } from "@/ee/features/permissionSyncing/linkedAccountsSettings";
3+
import { LinkedAccountsSettings } from "@/ee/features/permissionSyncing/components/linkedAccountsSettings";
44

55
export default async function PermissionSyncingPage() {
66
const hasPermissionSyncingEntitlement = await hasEntitlement("permission-syncing");

packages/web/src/auth.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { hasEntitlement } from '@sourcebot/shared';
1818
import { onCreateUser } from '@/lib/authUtils';
1919
import { getAuditService } from '@/ee/features/audit/factory';
2020
import { SINGLE_TENANT_ORG_ID } from './lib/constants';
21+
import { refreshOAuthToken } from '@/ee/features/permissionSyncing/actions';
2122

2223
const auditService = getAuditService();
2324
const eeIdentityProviders = hasEntitlement("sso") ? await getEEIdentityProviders() : [];
@@ -40,7 +41,12 @@ declare module 'next-auth' {
4041

4142
declare module 'next-auth/jwt' {
4243
interface JWT {
43-
userId: string
44+
userId: string;
45+
accessToken?: string;
46+
refreshToken?: string;
47+
expiresAt?: number;
48+
provider?: string;
49+
error?: string;
4450
}
4551
}
4652

@@ -179,13 +185,51 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
179185
}
180186
},
181187
callbacks: {
182-
async jwt({ token, user: _user }) {
188+
async jwt({ token, user: _user, account }) {
183189
const user = _user as User | undefined;
184190
// @note: `user` will be available on signUp or signIn triggers.
185191
// Cache the userId in the JWT for later use.
186192
if (user) {
187193
token.userId = user.id;
188194
}
195+
196+
if (account) {
197+
token.accessToken = account.access_token;
198+
token.refreshToken = account.refresh_token;
199+
token.expiresAt = account.expires_at;
200+
token.provider = account.provider;
201+
}
202+
203+
if (hasEntitlement('permission-syncing') &&
204+
token.provider &&
205+
['github', 'gitlab'].includes(token.provider) &&
206+
token.expiresAt &&
207+
token.refreshToken) {
208+
const now = Math.floor(Date.now() / 1000);
209+
const bufferTimeS = 5 * 60;
210+
211+
if (now >= (token.expiresAt - bufferTimeS)) {
212+
try {
213+
const refreshedTokens = await refreshOAuthToken(
214+
token.provider,
215+
token.refreshToken,
216+
token.userId
217+
);
218+
219+
if (refreshedTokens) {
220+
token.accessToken = refreshedTokens.accessToken;
221+
token.refreshToken = refreshedTokens.refreshToken ?? token.refreshToken;
222+
token.expiresAt = refreshedTokens.expiresAt;
223+
} else {
224+
token.error = 'RefreshTokenError';
225+
}
226+
} catch (error) {
227+
console.error('Error refreshing token:', error);
228+
token.error = 'RefreshTokenError';
229+
}
230+
}
231+
}
232+
189233
return token;
190234
},
191235
async session({ session, token }) {

packages/web/src/ee/features/permissionSyncing/actions.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { env } from "@/env.mjs";
88
import { OrgRole } from "@sourcebot/db";
99
import { cookies } from "next/headers";
1010
import { OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME } from "@/lib/constants";
11+
import { getTokenFromConfig } from '@sourcebot/crypto';
1112
import { IntegrationIdentityProviderState } from "@/ee/features/permissionSyncing/types";
13+
import { GitHubIdentityProviderConfig, GitLabIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type";
1214

1315
const logger = createLogger('web-ee-permission-syncing-actions');
1416

@@ -95,4 +97,92 @@ export const skipOptionalProvidersLink = async () => sew(async () => {
9597
maxAge: 365 * 24 * 60 * 60, // 1 year in seconds
9698
});
9799
return true;
98-
});
100+
});
101+
102+
export const refreshOAuthToken = async (
103+
provider: string,
104+
refreshToken: string,
105+
userId: string
106+
): Promise<{ accessToken: string; refreshToken: string | null; expiresAt: number } | null> => {
107+
try {
108+
// Load config and find the provider configuration
109+
const config = await loadConfig(env.CONFIG_PATH);
110+
const identityProviders = config?.identityProviders ?? [];
111+
112+
const providerConfig = identityProviders.find(
113+
idp => idp.provider === provider
114+
) as GitHubIdentityProviderConfig | GitLabIdentityProviderConfig;
115+
116+
if (!providerConfig || !('clientId' in providerConfig) || !('clientSecret' in providerConfig)) {
117+
logger.error(`Provider config not found or invalid for: ${provider}`);
118+
return null;
119+
}
120+
121+
// Get client credentials from config
122+
const clientId = await getTokenFromConfig(providerConfig.clientId);
123+
const clientSecret = await getTokenFromConfig(providerConfig.clientSecret);
124+
const baseUrl = 'baseUrl' in providerConfig && providerConfig.baseUrl
125+
? await getTokenFromConfig(providerConfig.baseUrl)
126+
: undefined;
127+
128+
let url: string;
129+
if (baseUrl) {
130+
url = provider === 'github'
131+
? `${baseUrl}/login/oauth/access_token`
132+
: `${baseUrl}/oauth/token`;
133+
} else if (provider === 'github') {
134+
url = 'https://github.com/login/oauth/access_token';
135+
} else if (provider === 'gitlab') {
136+
url = 'https://gitlab.com/oauth/token';
137+
} else {
138+
logger.error(`Unsupported provider for token refresh: ${provider}`);
139+
return null;
140+
}
141+
142+
const response = await fetch(url, {
143+
method: 'POST',
144+
headers: {
145+
'Content-Type': 'application/x-www-form-urlencoded',
146+
'Accept': 'application/json',
147+
},
148+
body: new URLSearchParams({
149+
client_id: clientId,
150+
client_secret: clientSecret,
151+
grant_type: 'refresh_token',
152+
refresh_token: refreshToken,
153+
}),
154+
});
155+
156+
if (!response.ok) {
157+
const errorText = await response.text();
158+
logger.error(`Failed to refresh ${provider} token: ${response.status} ${errorText}`);
159+
return null;
160+
}
161+
162+
const data = await response.json();
163+
164+
const result = {
165+
accessToken: data.access_token,
166+
refreshToken: data.refresh_token ?? null,
167+
expiresAt: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : 0,
168+
};
169+
170+
const { prisma } = await import('@/prisma');
171+
await prisma.account.updateMany({
172+
where: {
173+
userId: userId,
174+
provider: provider,
175+
},
176+
data: {
177+
access_token: result.accessToken,
178+
refresh_token: result.refreshToken,
179+
expires_at: result.expiresAt,
180+
},
181+
});
182+
183+
return result;
184+
} catch (error) {
185+
logger.error(`Error refreshing ${provider} token:`, error);
186+
return null;
187+
}
188+
};

packages/web/src/ee/features/permissionSyncing/integrationProviderCard.tsx renamed to packages/web/src/ee/features/permissionSyncing/components/integrationProviderCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { getAuthProviderInfo } from "@/lib/utils";
22
import { Check, X } from "lucide-react";
33
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4-
import { ProviderIcon } from "./components/providerIcon";
5-
import { ProviderInfo } from "./components/providerInfo";
4+
import { ProviderIcon } from "./providerIcon";
5+
import { ProviderInfo } from "./providerInfo";
66
import { UnlinkButton } from "./unlinkButton";
77
import { LinkButton } from "./linkButton";
88
import { IntegrationIdentityProviderState } from "@/ee/features/permissionSyncing/types"

packages/web/src/ee/features/permissionSyncing/unlinkButton.tsx renamed to packages/web/src/ee/features/permissionSyncing/components/unlinkButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useState } from "react";
44
import { Button } from "@/components/ui/button";
55
import { Unlink, Loader2 } from "lucide-react";
6-
import { unlinkIntegrationProvider } from "./actions";
6+
import { unlinkIntegrationProvider } from "../actions";
77
import { isServiceError } from "@/lib/utils";
88
import { useRouter } from "next/navigation";
99
import { useToast } from "@/components/hooks/use-toast";

packages/web/src/features/chat/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ export const updateChatName = async ({ chatId, name }: { chatId: string, name: s
189189

190190
export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }, domain: string) => sew(() =>
191191
withAuth((userId) =>
192-
withOrgMembership(userId, domain, async ({ org }) => {
192+
withOrgMembership(userId, domain, async () => {
193193
// From the language model ID, attempt to find the
194194
// corresponding config in `config.json`.
195195
const languageModelConfig =

0 commit comments

Comments
 (0)