Skip to content

Commit 4f3369b

Browse files
committed
support multiple token refresh
1 parent e649853 commit 4f3369b

File tree

5 files changed

+238
-152
lines changed

5 files changed

+238
-152
lines changed

packages/web/src/auth.ts

Lines changed: 37 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +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';
21+
import { refreshIntegrationTokens } from '@/ee/features/permissionSyncing/tokenRefresh';
2222

2323
const auditService = getAuditService();
2424
const eeIdentityProviders = hasEntitlement("sso") ? await getEEIdentityProviders() : [];
@@ -36,16 +36,19 @@ declare module 'next-auth' {
3636
user: {
3737
id: string;
3838
} & DefaultSession['user'];
39+
integrationProviderErrors?: Record<string, string>;
3940
}
4041
}
4142

4243
declare module 'next-auth/jwt' {
43-
interface JWT {
44+
interface JWT {
4445
userId: string;
45-
accessToken?: string;
46-
refreshToken?: string;
47-
expiresAt?: number;
48-
provider?: string;
46+
integrationTokens?: Record<string, {
47+
accessToken: string;
48+
refreshToken: string;
49+
expiresAt: number;
50+
error?: string;
51+
}>;
4952
error?: string;
5053
}
5154
}
@@ -193,41 +196,24 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
193196
token.userId = user.id;
194197
}
195198

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;
199+
// When a user links a new account, store the tokens if it's an integration provider
200+
if (account && hasEntitlement('permission-syncing')) {
201+
if (account.access_token && account.refresh_token && account.expires_at) {
202+
token.integrationTokens = token.integrationTokens || {};
203+
token.integrationTokens[account.provider] = {
204+
accessToken: account.access_token,
205+
refreshToken: account.refresh_token,
206+
expiresAt: account.expires_at,
207+
};
208+
}
201209
}
202210

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-
}
211+
// Refresh all integration provider tokens that are about to expire
212+
if (hasEntitlement('permission-syncing') && token.integrationTokens) {
213+
token.integrationTokens = await refreshIntegrationTokens(
214+
token.integrationTokens,
215+
token.userId
216+
);
231217
}
232218

233219
return token;
@@ -240,6 +226,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
240226
// Propagate the userId to the session.
241227
id: token.userId,
242228
}
229+
// Pass only integration provider errors to the session (not sensitive tokens)
230+
if (token.integrationTokens) {
231+
const errors: Record<string, string> = {};
232+
for (const [provider, tokenData] of Object.entries(token.integrationTokens)) {
233+
if (tokenData.error) {
234+
errors[provider] = tokenData.error;
235+
}
236+
}
237+
if (Object.keys(errors).length > 0) {
238+
session.integrationProviderErrors = errors;
239+
}
240+
}
243241
return session;
244242
},
245243
},

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

Lines changed: 9 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ 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';
1211
import { IntegrationIdentityProviderState } from "@/ee/features/permissionSyncing/types";
13-
import { GitHubIdentityProviderConfig, GitLabIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type";
12+
import { auth } from "@/auth";
1413

1514
const logger = createLogger('web-ee-permission-syncing-actions');
1615

@@ -32,6 +31,10 @@ export const getIntegrationProviderStates = async () => sew(() =>
3231
}
3332
});
3433

34+
// Fetch the session to get token errors
35+
const session = await auth();
36+
const providerErrors = session?.integrationProviderErrors;
37+
3538
const integrationProviderState: IntegrationIdentityProviderState[] = [];
3639
for (const integrationProviderConfig of integrationProviderConfigs) {
3740
if (integrationProviderConfig.purpose === "integration") {
@@ -41,11 +44,14 @@ export const getIntegrationProviderStates = async () => sew(() =>
4144

4245
const isLinked = !!linkedAccount;
4346
const isRequired = integrationProviderConfig.required ?? true;
47+
const providerError = providerErrors?.[integrationProviderConfig.provider];
48+
4449
integrationProviderState.push({
4550
id: integrationProviderConfig.provider,
4651
required: isRequired,
4752
isLinked,
48-
linkedAccountId: linkedAccount?.providerAccountId
53+
linkedAccountId: linkedAccount?.providerAccountId,
54+
error: providerError
4955
} as IntegrationIdentityProviderState);
5056
}
5157
}
@@ -99,90 +105,3 @@ export const skipOptionalProvidersLink = async () => sew(async () => {
99105
return true;
100106
});
101107

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/components/integrationProviderCard.tsx

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getAuthProviderInfo } from "@/lib/utils";
2-
import { Check, X } from "lucide-react";
2+
import { Check, X, AlertCircle } from "lucide-react";
33
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
44
import { ProviderIcon } from "./providerIcon";
55
import { ProviderInfo } from "./providerInfo";
@@ -41,31 +41,41 @@ export function IntegrationProviderCard({
4141
/>
4242
</CardTitle>
4343
<CardDescription className="text-xs">
44-
{integrationProviderState.isLinked? (
45-
<div className="flex items-center gap-2">
44+
<div className="flex flex-col gap-1.5">
45+
{integrationProviderState.isLinked? (
46+
<div className="flex items-center gap-2">
47+
<div className="flex items-center gap-1.5">
48+
<Check className="h-3.5 w-3.5 text-green-600 dark:text-green-500" />
49+
<span className="text-green-600 dark:text-green-500 font-medium">
50+
Connected
51+
</span>
52+
</div>
53+
{integrationProviderState.linkedAccountId && (
54+
<>
55+
<span className="text-muted-foreground"></span>
56+
<span className="text-muted-foreground font-mono truncate">
57+
{integrationProviderState.linkedAccountId}
58+
</span>
59+
</>
60+
)}
61+
</div>
62+
) : (
4663
<div className="flex items-center gap-1.5">
47-
<Check className="h-3.5 w-3.5 text-green-600 dark:text-green-500" />
48-
<span className="text-green-600 dark:text-green-500 font-medium">
49-
Connected
64+
<X className="h-3.5 w-3.5 text-muted-foreground" />
65+
<span className="text-muted-foreground">
66+
Not connected
5067
</span>
5168
</div>
52-
{integrationProviderState.linkedAccountId && (
53-
<>
54-
<span className="text-muted-foreground"></span>
55-
<span className="text-muted-foreground font-mono truncate">
56-
{integrationProviderState.linkedAccountId}
57-
</span>
58-
</>
59-
)}
60-
</div>
61-
) : (
62-
<div className="flex items-center gap-1.5">
63-
<X className="h-3.5 w-3.5 text-muted-foreground" />
64-
<span className="text-muted-foreground">
65-
Not connected
66-
</span>
67-
</div>
68-
)}
69+
)}
70+
{integrationProviderState.error && (
71+
<div className="flex items-center gap-1.5">
72+
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
73+
<span className="text-destructive font-medium">
74+
Token refresh failed - please reconnect
75+
</span>
76+
</div>
77+
)}
78+
</div>
6979
</CardDescription>
7080
</div>
7181
</div>

0 commit comments

Comments
 (0)