Skip to content

Commit a41d5d0

Browse files
committed
feat(all): auto-detect GitHub installations for user accounts
1 parent d1aa233 commit a41d5d0

File tree

7 files changed

+459
-134
lines changed

7 files changed

+459
-134
lines changed

services/backend/src/routes/teams/deploy/github.ts

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -299,10 +299,51 @@ export default async function deployGitHubRoutes(server: FastifyInstance) {
299299
const installation = await credentialService.getInstallation(teamId, 'github');
300300

301301
if (!installation) {
302-
// No database record - team must install the GitHub App via the /install flow
303-
const response: ConnectionStatusResponse = { connected: false };
304-
const jsonString = JSON.stringify(response);
305-
return reply.status(200).type('application/json').send(jsonString);
302+
// No database record - try to auto-detect from the current user's personal GitHub account
303+
const user = request.user as { githubId?: string | null } | undefined;
304+
const githubId = user?.githubId;
305+
306+
if (!githubId) {
307+
// User authenticated via email, not GitHub - cannot auto-detect
308+
const response: ConnectionStatusResponse = { connected: false };
309+
const jsonString = JSON.stringify(response);
310+
return reply.status(200).type('application/json').send(jsonString);
311+
}
312+
313+
try {
314+
const userInstallations = await githubService.listUserInstallations(githubId);
315+
316+
if (userInstallations.length > 0) {
317+
const selectedInstallation = userInstallations[0];
318+
319+
await credentialService.storeInstallation({
320+
teamId,
321+
source: 'github',
322+
installationId: selectedInstallation.id.toString()
323+
});
324+
325+
server.log.info({
326+
teamId,
327+
githubId,
328+
installation_id: selectedInstallation.id,
329+
account_login: selectedInstallation.account.login,
330+
operation: 'auto_linked_user_installation'
331+
}, 'Auto-linked GitHub installation to team (scoped to user)');
332+
333+
const response: ConnectionStatusResponse = { connected: true };
334+
const jsonString = JSON.stringify(response);
335+
return reply.status(200).type('application/json').send(jsonString);
336+
}
337+
338+
const response: ConnectionStatusResponse = { connected: false };
339+
const jsonString = JSON.stringify(response);
340+
return reply.status(200).type('application/json').send(jsonString);
341+
} catch (error) {
342+
server.log.warn({ error, teamId, githubId }, 'Failed to auto-detect GitHub installation for user');
343+
const response: ConnectionStatusResponse = { connected: false };
344+
const jsonString = JSON.stringify(response);
345+
return reply.status(200).type('application/json').send(jsonString);
346+
}
306347
}
307348

308349
// Step 2: Verify installation still exists on GitHub
@@ -319,7 +360,40 @@ export default async function deployGitHubRoutes(server: FastifyInstance) {
319360
operation: 'stale_installation_cleaned'
320361
}, 'Cleaned up stale GitHub installation record');
321362

322-
// Team must re-install the GitHub App via the /install flow
363+
// After cleanup, try user-scoped auto-detection for a valid installation
364+
const user = request.user as { githubId?: string | null } | undefined;
365+
const githubId = user?.githubId;
366+
367+
if (githubId) {
368+
try {
369+
const userInstallations = await githubService.listUserInstallations(githubId);
370+
371+
if (userInstallations.length > 0) {
372+
const selectedInstallation = userInstallations[0];
373+
374+
await credentialService.storeInstallation({
375+
teamId,
376+
source: 'github',
377+
installationId: selectedInstallation.id.toString()
378+
});
379+
380+
server.log.info({
381+
teamId,
382+
githubId,
383+
installation_id: selectedInstallation.id,
384+
account_login: selectedInstallation.account.login,
385+
operation: 'auto_linked_user_installation_after_cleanup'
386+
}, 'Auto-linked GitHub installation to team after cleaning stale record');
387+
388+
const response: ConnectionStatusResponse = { connected: true };
389+
const jsonString = JSON.stringify(response);
390+
return reply.status(200).type('application/json').send(jsonString);
391+
}
392+
} catch (retryError) {
393+
server.log.warn({ error: retryError, teamId, githubId }, 'Failed to auto-detect installation after cleanup');
394+
}
395+
}
396+
323397
const response: ConnectionStatusResponse = { connected: false };
324398
const jsonString = JSON.stringify(response);
325399
return reply.status(200).type('application/json').send(jsonString);

services/backend/src/services/OAuthDiscoveryService.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,67 @@ export class OAuthDiscoveryService {
241241
// Check for WWW-Authenticate: Bearer header
242242
const wwwAuthenticate = response.headers.get('www-authenticate');
243243
if (!wwwAuthenticate || !wwwAuthenticate.toLowerCase().includes('bearer')) {
244+
// Server returned 401/403 but without WWW-Authenticate: Bearer header.
245+
// Some servers (e.g., Miro) don't include WWW-Authenticate but still expose
246+
// RFC 9728/8414 well-known endpoints. Try probing those as a fallback.
247+
this.logger.debug(
248+
{ url, method, status: response.status, wwwAuthenticate },
249+
'No Bearer authentication scheme in header, trying well-known endpoints as fallback'
250+
);
251+
252+
const serverOrigin = new URL(url);
253+
const origin = `${serverOrigin.protocol}//${serverOrigin.host}`;
254+
255+
// Try RFC 9728 protected resource metadata first
256+
const resourceMetadataUrl = `${origin}/.well-known/oauth-protected-resource`;
257+
try {
258+
const rmResponse = await fetch(resourceMetadataUrl, {
259+
method: 'GET',
260+
headers: { Accept: 'application/json', 'User-Agent': OAuthDiscoveryService.USER_AGENT },
261+
signal: AbortSignal.timeout(5000)
262+
});
263+
if (rmResponse.ok) {
264+
const rmData = await rmResponse.json();
265+
if (rmData.authorization_servers && rmData.authorization_servers.length > 0) {
266+
this.logger.info(
267+
{ url, method, resourceMetadataUrl },
268+
'OAuth detected via RFC 9728 protected resource metadata fallback (no WWW-Authenticate header)'
269+
);
270+
return {
271+
requiresOauth: true,
272+
resourceMetadataUrl
273+
};
274+
}
275+
}
276+
} catch { /* ignore, try next */ }
277+
278+
// Try RFC 8414 authorization server metadata
279+
const rfc8414Url = `${origin}/.well-known/oauth-authorization-server`;
280+
try {
281+
const asResponse = await fetch(rfc8414Url, {
282+
method: 'GET',
283+
headers: { Accept: 'application/json', 'User-Agent': OAuthDiscoveryService.USER_AGENT },
284+
signal: AbortSignal.timeout(5000)
285+
});
286+
if (asResponse.ok) {
287+
const asData = await asResponse.json();
288+
if (asData.authorization_endpoint && asData.token_endpoint) {
289+
this.logger.info(
290+
{ url, method, rfc8414Url },
291+
'OAuth detected via RFC 8414 authorization server metadata fallback (no WWW-Authenticate header)'
292+
);
293+
return {
294+
requiresOauth: true,
295+
discoveryUrl: rfc8414Url
296+
};
297+
}
298+
}
299+
} catch { /* ignore */ }
300+
301+
// All fallbacks failed — truly no OAuth
244302
this.logger.debug(
245303
{ url, method, wwwAuthenticate },
246-
'No Bearer authentication scheme found'
304+
'No Bearer authentication scheme found and no well-known OAuth endpoints detected'
247305
);
248306
return { requiresOauth: false };
249307
}

services/backend/src/services/deploymentGitHubService.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,62 @@ export class DeploymentGitHubService {
132132
}
133133
}
134134

135+
/**
136+
* List GitHub App installations belonging to a specific user's personal account.
137+
* Queries all app installations via App JWT auth, then filters to only those
138+
* where the installation account ID matches the given GitHub user ID.
139+
* This prevents cross-tenant leakage — org installations are excluded.
140+
*/
141+
async listUserInstallations(githubId: string): Promise<Array<{
142+
id: number;
143+
account: {
144+
login: string;
145+
id: number;
146+
};
147+
created_at: string;
148+
updated_at: string;
149+
}>> {
150+
if (!githubId) {
151+
return [];
152+
}
153+
154+
try {
155+
const config = await getGitHubAppConfig();
156+
157+
const appOctokit = new Octokit({
158+
authStrategy: createAppAuth,
159+
auth: {
160+
appId: config.appId,
161+
privateKey: config.privateKey
162+
}
163+
});
164+
165+
const { data: installations } = await appOctokit.apps.listInstallations({
166+
per_page: 100
167+
});
168+
169+
// Only return installations on this user's personal account
170+
return installations
171+
.filter(installation =>
172+
!installation.suspended_at &&
173+
installation.account?.id?.toString() === githubId
174+
)
175+
.map(installation => ({
176+
id: installation.id,
177+
account: {
178+
login: installation.account?.login || 'unknown',
179+
id: installation.account?.id || 0
180+
},
181+
created_at: installation.created_at,
182+
updated_at: installation.updated_at
183+
}))
184+
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
185+
} catch (error: unknown) {
186+
const message = error instanceof Error ? error.message : 'Unknown error';
187+
throw new Error(`Failed to list GitHub installations: ${message}`);
188+
}
189+
}
190+
135191
/**
136192
* Get repository details
137193
*/

services/satellite/src/config/nsjail.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
1+
/**
2+
* Parse human-readable size string to bytes for nsjail tmpfs mounts.
3+
* nsjail's -T flag hardcodes 4MB; we use -m with explicit byte size instead.
4+
*/
5+
function parseSizeToBytes(sizeStr: string): number {
6+
const match = sizeStr.match(/^(\d+)\s*([KMGT])?$/i);
7+
if (!match) {
8+
throw new Error(`Invalid size format: ${sizeStr}`);
9+
}
10+
const value = parseInt(match[1], 10);
11+
const unit = (match[2] || '').toUpperCase();
12+
const multipliers: Record<string, number> = {
13+
'': 1,
14+
'K': 1024,
15+
'M': 1024 * 1024,
16+
'G': 1024 * 1024 * 1024,
17+
'T': 1024 * 1024 * 1024 * 1024
18+
};
19+
return value * (multipliers[unit] || 1);
20+
}
21+
122
/**
223
* nsjail Resource Limits Configuration
324
* These limits apply only in production on Linux platforms when nsjail isolation is enabled
@@ -32,11 +53,14 @@ export const nsjailConfig = {
3253
/** Maximum file size in MB (default: 50, prevents oversized package downloads) */
3354
maxFileSizeMB: parseInt(process.env.NSJAIL_RLIMIT_FSIZE || '50', 10),
3455

35-
/** Tmpfs size for /tmp directory (default: 100M) */
36-
tmpfsSize: process.env.NSJAIL_TMPFS_SIZE || '100M',
56+
/** Tmpfs size for /tmp directory in bytes (default: 100M = 104857600) */
57+
tmpfsSizeBytes: parseSizeToBytes(process.env.NSJAIL_TMPFS_SIZE || '100M'),
58+
59+
/** Tmpfs size for GitHub deployment working directories (human-readable, for mount syscall) */
60+
deploymentTmpfsSize: process.env.NSJAIL_DEPLOYMENT_TMPFS_SIZE || '300M',
3761

38-
/** Tmpfs size for GitHub deployment working directories (default: 300M) */
39-
deploymentTmpfsSize: process.env.NSJAIL_DEPLOYMENT_TMPFS_SIZE || '300M'
62+
/** Tmpfs size for GitHub deployment working directories in bytes (for nsjail -m flag) */
63+
deploymentTmpfsSizeBytes: parseSizeToBytes(process.env.NSJAIL_DEPLOYMENT_TMPFS_SIZE || '300M')
4064
};
4165

4266
/**

0 commit comments

Comments
 (0)