From cf470bfcc01b1cb790c484c98c7766becd9e4c54 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Wed, 3 Jun 2026 19:58:28 +0200 Subject: [PATCH 1/4] feat(git-token-service): add opaque SCM session capabilities --- .../gitlab/callback/route.test.ts | 8 +- .../integrations/gitlab/connect/route.test.ts | 4 +- .../lib/integrations/gitlab-service.test.ts | 30 +- .../src/lib/integrations/gitlab-service.ts | 9 +- .../oauth/platforms/gitlab-callback.ts | 8 +- .../platforms/gitlab/adapter.test.ts | 19 + .../platforms/gitlab/instance-url.test.ts | 1 + .../platforms/gitlab/instance-url.ts | 4 + .../platforms/gitlab/oauth-state.test.ts | 12 + .../platforms/gitlab/oauth-state.ts | 7 +- services/git-token-service/.dev.vars.example | 2 + .../src/github-session-capability.test.ts | 132 ++ .../src/github-session-capability.ts | 172 ++ .../src/gitlab-lookup-service.test.ts | 34 +- .../src/gitlab-lookup-service.ts | 153 +- .../src/gitlab-runtime-token-resolver.ts | 24 +- .../src/gitlab-session-capability.test.ts | 162 ++ .../src/gitlab-session-capability.ts | 152 ++ .../src/gitlab-token-service.test.ts | 89 + .../src/gitlab-token-service.ts | 35 +- services/git-token-service/src/gitlab-url.ts | 130 ++ services/git-token-service/src/index.test.ts | 1530 ++++++++++++++++- services/git-token-service/src/index.ts | 575 ++++++- .../worker-configuration.d.ts | 7 +- services/git-token-service/wrangler.jsonc | 10 + 25 files changed, 3071 insertions(+), 238 deletions(-) create mode 100644 services/git-token-service/src/github-session-capability.test.ts create mode 100644 services/git-token-service/src/github-session-capability.ts create mode 100644 services/git-token-service/src/gitlab-session-capability.test.ts create mode 100644 services/git-token-service/src/gitlab-session-capability.ts create mode 100644 services/git-token-service/src/gitlab-token-service.test.ts create mode 100644 services/git-token-service/src/gitlab-url.ts diff --git a/apps/web/src/app/api/integrations/gitlab/callback/route.test.ts b/apps/web/src/app/api/integrations/gitlab/callback/route.test.ts index 021869a5ae..d2591480f0 100644 --- a/apps/web/src/app/api/integrations/gitlab/callback/route.test.ts +++ b/apps/web/src/app/api/integrations/gitlab/callback/route.test.ts @@ -8,7 +8,7 @@ import { getGitLabOAuthCredentials } from '@/lib/integrations/platforms/gitlab/o jest.mock('@/lib/user/server'); jest.mock('@/lib/drizzle', () => ({ db: {} })); jest.mock('@/lib/integrations/gitlab-service', () => ({ - normalizeInstanceUrl: jest.fn(), + instanceUrlChanged: jest.fn(), })); jest.mock('@/routers/organizations/utils', () => ({ ensureOrganizationAccess: jest.fn(), @@ -164,11 +164,11 @@ describe('GET /api/integrations/gitlab/callback', () => { expect(mockedExchangeGitLabOAuthCode).not.toHaveBeenCalled(); }); - test('rejects signed state with an unsafe instance URL before exchanging an OAuth code', async () => { + test('rejects signed state with an http instance URL before exchanging an OAuth code', async () => { const state = createGitLabOAuthState( { owner: { type: 'user', id: USER_ID }, - instanceUrl: 'http://127.0.0.1:8080', + instanceUrl: 'http://gitlab.example.com', customCredentialsRef: 'cached-credentials-ref', }, USER_ID @@ -180,7 +180,7 @@ describe('GET /api/integrations/gitlab/callback', () => { ) ); - expectRedirectLocation(response, '/integrations/gitlab?error=connection_failed'); + expectRedirectLocation(response, '/integrations?error=invalid_state'); expect(mockedGetGitLabOAuthCredentials).not.toHaveBeenCalled(); expect(mockedExchangeGitLabOAuthCode).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/app/api/integrations/gitlab/connect/route.test.ts b/apps/web/src/app/api/integrations/gitlab/connect/route.test.ts index fcab2be185..49abd0bdda 100644 --- a/apps/web/src/app/api/integrations/gitlab/connect/route.test.ts +++ b/apps/web/src/app/api/integrations/gitlab/connect/route.test.ts @@ -181,10 +181,10 @@ describe('GET /api/integrations/gitlab/connect', () => { expect(responseBody.url).toBe('https://gitlab.com/oauth/authorize?state=signed'); }); - test('does not initialize self-hosted OAuth for unsafe instance URLs', async () => { + test('does not initialize self-hosted OAuth for http instance URLs', async () => { const response = await callGitLabConnectPost( makeJsonRequest('/api/integrations/gitlab/connect', { - instanceUrl: 'http://127.0.0.1:8080', + instanceUrl: 'http://gitlab.example.com', clientId: 'client-id', clientSecret: 'client-secret', }) diff --git a/apps/web/src/lib/integrations/gitlab-service.test.ts b/apps/web/src/lib/integrations/gitlab-service.test.ts index 86172f8cde..bcf03f4a3a 100644 --- a/apps/web/src/lib/integrations/gitlab-service.test.ts +++ b/apps/web/src/lib/integrations/gitlab-service.test.ts @@ -1,4 +1,4 @@ -import { normalizeInstanceUrl } from './gitlab-service'; +import { instanceUrlChanged, normalizeInstanceUrl } from './gitlab-service'; describe('normalizeInstanceUrl', () => { it('treats undefined as gitlab.com', () => { @@ -24,8 +24,10 @@ describe('normalizeInstanceUrl', () => { expect(normalizeInstanceUrl('https://gitlab.com')).toBe('https://gitlab.com'); }); - it('preserves self-hosted URLs', () => { - expect(normalizeInstanceUrl('http://selfhosted.test:3123')).toBe('http://selfhosted.test:3123'); + it('preserves https self-hosted URLs', () => { + expect(normalizeInstanceUrl('https://selfhosted.test:3123')).toBe( + 'https://selfhosted.test:3123' + ); }); it('preserves self-hosted base paths', () => { @@ -34,6 +36,10 @@ describe('normalizeInstanceUrl', () => { ); }); + it('rejects http self-hosted URLs', () => { + expect(() => normalizeInstanceUrl('http://selfhosted.test:3123')).toThrow('must use https'); + }); + it('rejects unsafe self-hosted URLs', () => { expect(() => normalizeInstanceUrl('http://127.0.0.1:8080')).toThrow('host is not allowed'); }); @@ -44,12 +50,24 @@ describe('normalizeInstanceUrl', () => { // different instances expect(normalizeInstanceUrl('https://gitlab.com')).not.toBe( - normalizeInstanceUrl('http://selfhosted.test:3123') + normalizeInstanceUrl('https://selfhosted.test:3123') ); // same self-hosted instance with trailing slash difference - expect(normalizeInstanceUrl('http://selfhosted.test:3123/')).toBe( - normalizeInstanceUrl('http://selfhosted.test:3123') + expect(normalizeInstanceUrl('https://selfhosted.test:3123/')).toBe( + normalizeInstanceUrl('https://selfhosted.test:3123') + ); + }); + + it('treats a legacy http URL as changed when reconnecting with https', () => { + expect(instanceUrlChanged('http://selfhosted.test:3123', 'https://selfhosted.test:3123')).toBe( + true ); }); + + it('still rejects a new http URL when checking for an instance change', () => { + expect(() => + instanceUrlChanged('https://selfhosted.test:3123', 'http://selfhosted.test:3123') + ).toThrow('must use https'); + }); }); diff --git a/apps/web/src/lib/integrations/gitlab-service.ts b/apps/web/src/lib/integrations/gitlab-service.ts index 639bb9ffc1..67a31b9824 100644 --- a/apps/web/src/lib/integrations/gitlab-service.ts +++ b/apps/web/src/lib/integrations/gitlab-service.ts @@ -53,8 +53,13 @@ export function normalizeInstanceUrl(url?: string): string { * Returns true if the GitLab instance URL has changed between * the existing integration and the new connection. */ -function instanceUrlChanged(existingUrl?: string, newUrl?: string): boolean { - return normalizeInstanceUrl(existingUrl) !== normalizeInstanceUrl(newUrl); +export function instanceUrlChanged(existingUrl: string | undefined, newUrl: string): boolean { + const normalizedNewUrl = normalizeInstanceUrl(newUrl); + try { + return normalizeInstanceUrl(existingUrl) !== normalizedNewUrl; + } catch { + return true; + } } /** diff --git a/apps/web/src/lib/integrations/oauth/platforms/gitlab-callback.ts b/apps/web/src/lib/integrations/oauth/platforms/gitlab-callback.ts index 41c0458241..7bb32b5d5c 100644 --- a/apps/web/src/lib/integrations/oauth/platforms/gitlab-callback.ts +++ b/apps/web/src/lib/integrations/oauth/platforms/gitlab-callback.ts @@ -13,7 +13,7 @@ import { fetchGitLabProjects, calculateTokenExpiry, } from '@/lib/integrations/platforms/gitlab/adapter'; -import { normalizeInstanceUrl } from '@/lib/integrations/gitlab-service'; +import { instanceUrlChanged } from '@/lib/integrations/gitlab-service'; import { isDefaultGitLabInstanceUrl, normalizeGitLabInstanceUrl, @@ -187,8 +187,10 @@ export async function handleGitLabOAuthCallback(request: NextRequest) { // Detect if the GitLab instance URL changed (e.g. gitlab.com -> self-hosted) const isInstanceChange = existing !== undefined && - normalizeInstanceUrl(existingMetadata?.gitlab_instance_url as string | undefined) !== - normalizeInstanceUrl(normalizedInstanceUrl); + instanceUrlChanged( + existingMetadata?.gitlab_instance_url as string | undefined, + normalizedInstanceUrl + ); const webhookSecret = isInstanceChange ? generateWebhookSecret() diff --git a/apps/web/src/lib/integrations/platforms/gitlab/adapter.test.ts b/apps/web/src/lib/integrations/platforms/gitlab/adapter.test.ts index d99d060763..3b05de05a7 100644 --- a/apps/web/src/lib/integrations/platforms/gitlab/adapter.test.ts +++ b/apps/web/src/lib/integrations/platforms/gitlab/adapter.test.ts @@ -350,6 +350,15 @@ describe('validateGitLabInstance', () => { expect(mockFetch).not.toHaveBeenCalled(); }); + it('should return invalid for http instances before fetching', async () => { + const result = await validateGitLabInstance('http://gitlab.example.com'); + + expect(result.valid).toBe(false); + expect(result.error).toContain('must use https'); + expect(mockLookup).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('should return invalid for unsafe hosts before fetching', async () => { const result = await validateGitLabInstance('http://127.0.0.1:8080'); @@ -871,6 +880,16 @@ describe('validatePersonalAccessToken', () => { mockFetch.mockReset(); }); + it('rejects http instance URLs before fetching', async () => { + const result = await validatePersonalAccessToken('pat-token', 'http://gitlab.example.com'); + + expect(result).toEqual({ + valid: false, + error: 'Invalid URL protocol. GitLab instance URLs must use https.', + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('rejects unsafe instance URLs before fetching', async () => { const result = await validatePersonalAccessToken('pat-token', 'http://169.254.169.254'); diff --git a/apps/web/src/lib/integrations/platforms/gitlab/instance-url.test.ts b/apps/web/src/lib/integrations/platforms/gitlab/instance-url.test.ts index e14cd9b179..85b8e6e455 100644 --- a/apps/web/src/lib/integrations/platforms/gitlab/instance-url.test.ts +++ b/apps/web/src/lib/integrations/platforms/gitlab/instance-url.test.ts @@ -39,6 +39,7 @@ describe('GitLab instance URL safety', () => { it.each([ ['ftp://gitlab.example.com', 'Invalid URL protocol'], + ['http://gitlab.example.com', 'must use https'], [urlWithCredentials.toString(), 'must not include credentials'], ['https://gitlab.example.com?next=/api', 'must not include query strings'], ['https://gitlab.example.com#fragment', 'must not include query strings'], diff --git a/apps/web/src/lib/integrations/platforms/gitlab/instance-url.ts b/apps/web/src/lib/integrations/platforms/gitlab/instance-url.ts index 7d770b4be2..ff79ac2fc9 100644 --- a/apps/web/src/lib/integrations/platforms/gitlab/instance-url.ts +++ b/apps/web/src/lib/integrations/platforms/gitlab/instance-url.ts @@ -42,6 +42,10 @@ export function normalizeGitLabInstanceUrl(instanceUrl?: string): string { throw new GitLabInstanceUrlError('GitLab instance URL host is not allowed.'); } + if (url.protocol !== 'https:') { + throw new GitLabInstanceUrlError('Invalid URL protocol. GitLab instance URLs must use https.'); + } + const path = normalizeBasePath(url.pathname); return `${url.protocol}//${url.host.toLowerCase()}${path}`; } diff --git a/apps/web/src/lib/integrations/platforms/gitlab/oauth-state.test.ts b/apps/web/src/lib/integrations/platforms/gitlab/oauth-state.test.ts index 48cadd2586..d450875ba3 100644 --- a/apps/web/src/lib/integrations/platforms/gitlab/oauth-state.test.ts +++ b/apps/web/src/lib/integrations/platforms/gitlab/oauth-state.test.ts @@ -38,6 +38,18 @@ describe('gitlab oauth state', () => { }); }); + test('rejects signed state for an http instance URL', () => { + const state = createGitLabOAuthState( + { + owner: { type: 'user', id: 'user_123' }, + instanceUrl: 'http://gitlab.example.com', + }, + 'user_123' + ); + + expect(verifyGitLabOAuthState(state)).toBeNull(); + }); + test('round-trips a validated return path', () => { const state = createGitLabOAuthState( { diff --git a/apps/web/src/lib/integrations/platforms/gitlab/oauth-state.ts b/apps/web/src/lib/integrations/platforms/gitlab/oauth-state.ts index 7c82d2d6fa..1224c509bc 100644 --- a/apps/web/src/lib/integrations/platforms/gitlab/oauth-state.ts +++ b/apps/web/src/lib/integrations/platforms/gitlab/oauth-state.ts @@ -8,10 +8,9 @@ const GITLAB_OAUTH_STATE_PREFIX = 'gitlab:'; export const DEFAULT_GITLAB_OAUTH_INSTANCE_URL = 'https://gitlab.com'; -function isHttpInstanceUrl(value: string): boolean { +function isHttpsInstanceUrl(value: string): boolean { try { - const protocol = new URL(value).protocol; - return protocol === 'http:' || protocol === 'https:'; + return new URL(value).protocol === 'https:'; } catch { return false; } @@ -22,7 +21,7 @@ const GitLabOAuthStatePayloadSchema = z.object({ z.object({ type: z.literal('user'), id: z.string().min(1) }), z.object({ type: z.literal('org'), id: z.string().min(1) }), ]), - instanceUrl: z.string().url().refine(isHttpInstanceUrl).optional(), + instanceUrl: z.string().url().refine(isHttpsInstanceUrl).optional(), customCredentialsRef: z.string().min(1).optional(), returnTo: z .string() diff --git a/services/git-token-service/.dev.vars.example b/services/git-token-service/.dev.vars.example index c1c0938735..3da8489b64 100644 --- a/services/git-token-service/.dev.vars.example +++ b/services/git-token-service/.dev.vars.example @@ -23,6 +23,8 @@ USER_GITHUB_APP_TOKEN_ACTIVE_PRIVATE_KEY= # @from NEXTAUTH_SECRET # Short-lived user-token verification for POST /internal/github-user-authorizations/disconnect NEXTAUTH_SECRET= +# Dedicated 32-byte base64 AES-GCM key for short-lived SCM session capabilities +SCM_SESSION_CAPABILITY_ENCRYPTION_KEY= # GitHub Lite App credentials (for OSS organizations with read-only permissions) # Same format as standard app credentials above diff --git a/services/git-token-service/src/github-session-capability.test.ts b/services/git-token-service/src/github-session-capability.test.ts new file mode 100644 index 0000000000..3d98c6f129 --- /dev/null +++ b/services/git-token-service/src/github-session-capability.test.ts @@ -0,0 +1,132 @@ +import { encryptWithSymmetricKey } from '@kilocode/encryption'; +import { describe, expect, it, vi } from 'vitest'; +import { + GitHubSessionCapabilityCodec, + GitHubSessionCapabilityError, +} from './github-session-capability.js'; + +const encryptionKey = Buffer.alloc(32, 7).toString('base64'); +const anotherEncryptionKey = Buffer.alloc(32, 8).toString('base64'); +const claims = { + userId: 'user_1', + outboundContainerId: 'outbound-container-1', + orgId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145b', + owner: 'acme', + repo: 'widgets', + source: 'user', + identity: { + installationId: 'installation_1', + accountLogin: 'acme', + appType: 'standard', + gitAuthor: { name: 'octocat', email: '1+octocat@users.noreply.github.com' }, + commitCoAuthor: { + name: 'kiloconnect[bot]', + email: '240665456+kiloconnect[bot]@users.noreply.github.com', + }, + }, +} as const; + +describe('GitHubSessionCapabilityCodec', () => { + it('produces an opaque prefixed capability with time-bounded bound claims', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-30T12:00:00.000Z')); + const codec = new GitHubSessionCapabilityCodec(encryptionKey); + + const capability = codec.issue(claims); + const decoded = codec.decode(capability); + + expect(capability).toMatch(/^kgh2\./); + expect(capability).not.toContain('user_1'); + expect(capability).not.toContain('acme'); + expect(decoded).toEqual({ + purpose: 'github_scm_session', + version: 2, + userId: 'user_1', + outboundContainerId: claims.outboundContainerId, + orgId: claims.orgId, + owner: 'acme', + repo: 'widgets', + source: 'user', + identity: claims.identity, + issuedAt: Date.parse('2026-05-30T12:00:00.000Z'), + expiresAt: Date.parse('2026-05-30T13:00:00.000Z'), + }); + vi.useRealTimers(); + }); + + it('produces and decodes a legacy unbound v1 capability when the container is omitted', () => { + const codec = new GitHubSessionCapabilityCodec(encryptionKey); + const { outboundContainerId: _outboundContainerId, ...legacyClaims } = claims; + + const capability = codec.issue(legacyClaims); + + expect(capability).toMatch(/^kgh1\./); + expect(codec.decode(capability)).toMatchObject({ + version: 1, + userId: 'user_1', + owner: 'acme', + repo: 'widgets', + }); + expect(codec.decode(capability)).not.toHaveProperty('outboundContainerId'); + }); + + it('rejects expired and tampered capabilities', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-30T12:00:00.000Z')); + const codec = new GitHubSessionCapabilityCodec(encryptionKey); + const capability = codec.issue(claims); + + vi.setSystemTime(new Date('2026-05-30T12:59:59.999Z')); + expect(codec.decode(capability)).toMatchObject({ source: 'user' }); + + vi.setSystemTime(new Date('2026-05-30T13:00:00.000Z')); + expect(() => codec.decode(capability)).toThrowError( + expect.objectContaining({ reason: 'expired_capability' }) + ); + const changedOffset = capability.lastIndexOf('.') + 4; + const changedCharacter = capability[changedOffset] === 'A' ? 'B' : 'A'; + const tampered = `${capability.slice(0, changedOffset)}${changedCharacter}${capability.slice(changedOffset + 1)}`; + expect(() => codec.decode(tampered)).toThrowError(GitHubSessionCapabilityError); + expect(() => codec.decode(`${capability}corrupted`)).toThrowError(GitHubSessionCapabilityError); + vi.useRealTimers(); + }); + + it('does not decode a capability with a different valid encryption key', () => { + const capability = new GitHubSessionCapabilityCodec(encryptionKey).issue(claims); + + expect(() => + new GitHubSessionCapabilityCodec(anotherEncryptionKey).decode(capability) + ).toThrowError(expect.objectContaining({ reason: 'invalid_capability' })); + }); + + it.each([ + ['another purpose', 'kgh2.', { purpose: 'another_use', version: 2 }], + ['a v2 claim under the legacy marker', 'kgh1.', { purpose: 'github_scm_session', version: 2 }], + ])('rejects decrypted claims with %s', (_description, prefix, boundClaims) => { + const codec = new GitHubSessionCapabilityCodec(encryptionKey); + const serializedClaims = JSON.stringify({ + ...boundClaims, + userId: 'user_1', + outboundContainerId: claims.outboundContainerId, + owner: 'acme', + repo: 'widgets', + source: 'installation', + identity: { + installationId: 'installation_1', + accountLogin: 'acme', + appType: 'standard', + gitAuthor: { + name: 'kiloconnect[bot]', + email: '240665456+kiloconnect[bot]@users.noreply.github.com', + }, + }, + issuedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + const capability = `${prefix}${encryptWithSymmetricKey(serializedClaims, encryptionKey)}`; + + expect(() => codec.decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + }); +}); diff --git a/services/git-token-service/src/github-session-capability.ts b/services/git-token-service/src/github-session-capability.ts new file mode 100644 index 0000000000..83cdeb46ab --- /dev/null +++ b/services/git-token-service/src/github-session-capability.ts @@ -0,0 +1,172 @@ +import { decryptWithSymmetricKey, encryptWithSymmetricKey } from '@kilocode/encryption'; +import { Buffer } from 'node:buffer'; +import { z } from 'zod'; + +const LEGACY_CAPABILITY_PREFIX = 'kgh1.'; +const BOUND_CAPABILITY_PREFIX = 'kgh2.'; +const CAPABILITY_PURPOSE = 'github_scm_session'; +const MAX_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS = 60 * 60 * 1000; +const GitHubPathPartSchema = z + .string() + .trim() + .min(1) + .max(100) + .regex(/^[a-z0-9_.-]+$/) + .refine(value => value === value.toLowerCase()); + +const GitAuthorSchema = z + .object({ + name: z.string().min(1), + email: z.string().min(1), + }) + .strict(); +const GitHubSessionIdentitySchema = z + .object({ + installationId: z.string().min(1), + accountLogin: z.string().min(1), + appType: z.enum(['standard', 'lite']), + gitAuthor: GitAuthorSchema, + commitCoAuthor: GitAuthorSchema.optional(), + }) + .strict(); +const GitHubSessionCapabilityClaimsBaseSchema = z.object({ + purpose: z.literal(CAPABILITY_PURPOSE), + userId: z.string().min(1), + orgId: z.string().uuid().optional(), + owner: GitHubPathPartSchema, + repo: GitHubPathPartSchema, + source: z.enum(['user', 'installation']), + identity: GitHubSessionIdentitySchema, + issuedAt: z.number().int().nonnegative(), + expiresAt: z.number().int().positive(), +}); +const GitHubLegacySessionCapabilityClaimsSchema = GitHubSessionCapabilityClaimsBaseSchema.extend({ + version: z.literal(1), +}).strict(); +const GitHubBoundSessionCapabilityClaimsSchema = GitHubSessionCapabilityClaimsBaseSchema.extend({ + version: z.literal(2), + outboundContainerId: z.string().min(1), +}).strict(); +const GitHubSessionCapabilityClaimsSchema = z + .discriminatedUnion('version', [ + GitHubLegacySessionCapabilityClaimsSchema, + GitHubBoundSessionCapabilityClaimsSchema, + ]) + .refine(claims => claims.expiresAt > claims.issuedAt) + .refine( + claims => claims.expiresAt - claims.issuedAt <= MAX_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS + ); + +export type GitHubAuthSource = 'user' | 'installation'; +export type GitHubSessionIdentity = z.infer; +export type GitHubSessionCapabilitySubject = { + userId: string; + outboundContainerId?: string; + orgId?: string; + owner: string; + repo: string; + source: GitHubAuthSource; + identity: GitHubSessionIdentity; +}; +export type GitHubSessionCapabilityClaims = z.infer; +export type GitHubSessionCapabilityFailureReason = + | 'invalid_capability' + | 'expired_capability' + | 'capability_configuration_error'; + +export class GitHubSessionCapabilityError extends Error { + constructor(readonly reason: GitHubSessionCapabilityFailureReason) { + super(reason); + this.name = 'GitHubSessionCapabilityError'; + } +} + +export function hasCanonicalEncryptedValueFormat(encrypted: string): boolean { + const parts = encrypted.split(':'); + if (parts.length !== 3) return false; + const [iv, authTag, ciphertext] = parts; + if (!iv || !authTag || !ciphertext) return false; + return [ + [iv, 16], + [authTag, 16], + [ciphertext, null], + ].every(([encoded, expectedLength]) => { + if (typeof encoded !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return false; + const decoded = Buffer.from(encoded, 'base64'); + if (decoded.toString('base64') !== encoded) return false; + return expectedLength === null || decoded.length === expectedLength; + }); +} + +export function normalizeGitHubRepository( + githubRepo: string +): { owner: string; repo: string } | null { + const parts = githubRepo.split('/'); + if (parts.length !== 2) return null; + const owner = parts[0]?.trim().toLowerCase(); + const repo = parts[1]?.trim().toLowerCase(); + const parsed = z.object({ owner: GitHubPathPartSchema, repo: GitHubPathPartSchema }).safeParse({ + owner, + repo, + }); + return parsed.success ? parsed.data : null; +} + +export class GitHubSessionCapabilityCodec { + constructor(private readonly encryptionKey: string) {} + + issue(subject: GitHubSessionCapabilitySubject): string { + const issuedAt = Date.now(); + const bound = subject.outboundContainerId !== undefined; + const parsed = GitHubSessionCapabilityClaimsSchema.safeParse({ + purpose: CAPABILITY_PURPOSE, + version: bound ? 2 : 1, + ...subject, + issuedAt, + expiresAt: issuedAt + MAX_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS, + }); + if (!parsed.success) throw new GitHubSessionCapabilityError('invalid_capability'); + + try { + const prefix = bound ? BOUND_CAPABILITY_PREFIX : LEGACY_CAPABILITY_PREFIX; + return `${prefix}${encryptWithSymmetricKey(JSON.stringify(parsed.data), this.encryptionKey)}`; + } catch { + throw new GitHubSessionCapabilityError('capability_configuration_error'); + } + } + + decode(capability: string): GitHubSessionCapabilityClaims { + const format = capability.startsWith(LEGACY_CAPABILITY_PREFIX) + ? { prefix: LEGACY_CAPABILITY_PREFIX, version: 1 as const } + : capability.startsWith(BOUND_CAPABILITY_PREFIX) + ? { prefix: BOUND_CAPABILITY_PREFIX, version: 2 as const } + : null; + if (!format) throw new GitHubSessionCapabilityError('invalid_capability'); + + const encrypted = capability.slice(format.prefix.length); + if (!hasCanonicalEncryptedValueFormat(encrypted)) { + throw new GitHubSessionCapabilityError('invalid_capability'); + } + let serialized: string; + try { + serialized = decryptWithSymmetricKey(encrypted, this.encryptionKey); + } catch { + throw new GitHubSessionCapabilityError('invalid_capability'); + } + + let value: unknown; + try { + value = JSON.parse(serialized); + } catch { + throw new GitHubSessionCapabilityError('invalid_capability'); + } + const parsed = GitHubSessionCapabilityClaimsSchema.safeParse(value); + if (!parsed.success || parsed.data.version !== format.version) { + throw new GitHubSessionCapabilityError('invalid_capability'); + } + if (parsed.data.expiresAt <= Date.now()) { + throw new GitHubSessionCapabilityError('expired_capability'); + } + return parsed.data; + } +} diff --git a/services/git-token-service/src/gitlab-lookup-service.test.ts b/services/git-token-service/src/gitlab-lookup-service.test.ts index e0a2aaeb63..b7dc4ee85c 100644 --- a/services/git-token-service/src/gitlab-lookup-service.test.ts +++ b/services/git-token-service/src/gitlab-lookup-service.test.ts @@ -15,6 +15,9 @@ function integration( ): AuthorizedGitLabIntegration { return { integrationId, + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', metadata: { gitlab_instance_url: instanceUrl }, }; } @@ -40,6 +43,16 @@ describe('buildAuthorizedGitLabIntegrationQuery', () => { expect(query.params).toContain(orgId); expect(query.params).toContain(params.userId); }); + + it('filters a pinned integration while retaining requester ownership', () => { + const integrationId = '123e4567-e89b-12d3-a456-426614174011'; + const query = buildAuthorizedGitLabIntegrationQuery(db, params, integrationId).toSQL(); + + expect(query.sql).toMatch(/"platform_integrations"\."id" = \$\d+/); + expect(query.sql).toMatch(/"platform_integrations"\."owned_by_user_id" = \$\d+/); + expect(query.params).toContain(integrationId); + expect(query.params).toContain(params.userId); + }); }); describe('matchGitLabRepositoryToIntegration', () => { @@ -52,19 +65,28 @@ describe('matchGitLabRepositoryToIntegration', () => { it('extracts a project path below a self-hosted instance base path', () => { expect( matchGitLabRepositoryToIntegration( - 'https://gitlab.example.com/gitlab/platform/backend.git', - integration('https://gitlab.example.com/gitlab/') + 'https://gitlab.example.com:8443/gitlab/platform/backend.git', + integration('https://gitlab.example.com:8443/gitlab/') + ) + ).toMatchObject({ + instanceUrl: 'https://gitlab.example.com:8443/gitlab', + projectPath: 'platform/backend', + }); + expect( + matchGitLabRepositoryToIntegration( + 'https://gitlab.example.com:8443/gitlab//platform/backend.git', + integration('https://gitlab.example.com:8443/gitlab/') ) ).toMatchObject({ - instanceUrl: 'https://gitlab.example.com/gitlab', + instanceUrl: 'https://gitlab.example.com:8443/gitlab', projectPath: 'platform/backend', }); expect( matchGitLabRepositoryToIntegration( - 'https://gitlab.example.com/gitlab//platform/backend.git', - integration('https://gitlab.example.com/gitlab/') + 'https://gitlab.example.com:8443/gitlab/platform//backend.git', + integration('https://gitlab.example.com:8443/gitlab/') ) - ).toMatchObject({ projectPath: 'platform/backend' }); + ).toBeNull(); }); it('does not match another instance or a base-path prefix collision', () => { diff --git a/services/git-token-service/src/gitlab-lookup-service.ts b/services/git-token-service/src/gitlab-lookup-service.ts index 2c76259020..2f74484a9e 100644 --- a/services/git-token-service/src/gitlab-lookup-service.ts +++ b/services/git-token-service/src/gitlab-lookup-service.ts @@ -7,6 +7,8 @@ import { } from '@kilocode/db/schema'; import { eq, and, isNull, isNotNull, sql } from 'drizzle-orm'; import { DEFAULT_GITLAB_INSTANCE_URL } from './gitlab-constants.js'; +import { parseGitLabCloneUrl } from './gitlab-url.js'; +export { isValidGitLabRepositoryUrl, normalizeGitLabInstanceUrl } from './gitlab-url.js'; export type GitLabLookupParams = { userId: string; @@ -26,13 +28,14 @@ export type GitLabIntegrationMetadata = { export type AuthorizedGitLabIntegration = { integrationId: string; + integrationType: string; + accountId: string | null; + accountLogin: string | null; metadata: GitLabIntegrationMetadata; }; -type GitLabLookupSuccess = { +export type GitLabLookupSuccess = AuthorizedGitLabIntegration & { success: true; - integrationId: string; - metadata: GitLabIntegrationMetadata; }; export type GitLabLookupFailure = { @@ -66,103 +69,33 @@ const GitLabMetadataSchema = z }) .passthrough(); -type ParsedGitLabInstanceUrl = { - origin: string; - basePath: string; - instanceUrl: string; -}; - -function parseSecureUrl(value: string): URL | null { - try { - const parsed = new URL(value); - if ( - parsed.protocol !== 'https:' || - parsed.username !== '' || - parsed.password !== '' || - parsed.search !== '' || - parsed.hash !== '' - ) { - return null; - } - return parsed; - } catch { - return null; - } -} - -function parseGitLabInstanceUrl(instanceUrl: string): ParsedGitLabInstanceUrl | null { - const parsed = parseSecureUrl(instanceUrl); - if (!parsed) { - return null; - } - - const basePath = parsed.pathname.replace(/\/+$/, ''); - return { - origin: parsed.origin, - basePath, - instanceUrl: `${parsed.origin}${basePath}`, - }; -} - -export function isValidGitLabRepositoryUrl(repositoryUrl: string): boolean { - const parsed = parseSecureUrl(repositoryUrl); - return parsed !== null && parsed.pathname !== '/' && !parsed.pathname.endsWith('/'); -} - export function matchGitLabRepositoryToIntegration( repositoryUrl: string, integration: AuthorizedGitLabIntegration ): GitLabRepositoryMatch | null { - const repository = parseSecureUrl(repositoryUrl); - const instance = parseGitLabInstanceUrl( + const repository = parseGitLabCloneUrl( + repositoryUrl, integration.metadata.gitlab_instance_url || DEFAULT_GITLAB_INSTANCE_URL ); - - if (!repository || !instance || repository.origin !== instance.origin) { - return null; - } - - if (repository.pathname === '/' || repository.pathname.endsWith('/')) { - return null; - } - - const repositoryPrefix = instance.basePath === '' ? '/' : `${instance.basePath}/`; - if (!repository.pathname.startsWith(repositoryPrefix)) { - return null; - } - - const encodedProjectPath = repository.pathname.slice(repositoryPrefix.length).replace(/^\/+/, ''); - let projectPath: string; - try { - projectPath = decodeURIComponent(encodedProjectPath); - } catch { - return null; - } - - if (projectPath.endsWith('.git')) { - projectPath = projectPath.slice(0, -4); - } - - const pathSegments = projectPath.split('/'); - if ( - pathSegments.length < 2 || - pathSegments.some(segment => segment === '') || - pathSegments.includes('-') - ) { - return null; - } - + if (!repository.success) return null; return { ...integration, - instanceUrl: instance.instanceUrl, - projectPath, + instanceUrl: repository.instanceOrigin, + projectPath: repository.projectPath, }; } -export function buildAuthorizedGitLabIntegrationQuery(db: WorkerDb, params: GitLabLookupParams) { +export function buildAuthorizedGitLabIntegrationQuery( + db: WorkerDb, + params: GitLabLookupParams, + integrationId?: string +) { return db .select({ id: platform_integrations.id, + integration_type: platform_integrations.integration_type, + platform_account_id: platform_integrations.platform_account_id, + platform_account_login: platform_integrations.platform_account_login, metadata: platform_integrations.metadata, }) .from(platform_integrations) @@ -184,6 +117,7 @@ export function buildAuthorizedGitLabIntegrationQuery(db: WorkerDb, params: GitL and( eq(platform_integrations.platform, 'gitlab'), eq(platform_integrations.integration_status, 'active'), + ...(integrationId !== undefined ? [eq(platform_integrations.id, integrationId)] : []), params.orgId ? and( eq(platform_integrations.owned_by_organization_id, sql`${params.orgId}::uuid`), @@ -197,9 +131,23 @@ export function buildAuthorizedGitLabIntegrationQuery(db: WorkerDb, params: GitL ); } -export class GitLabLookupService { - private db: WorkerDb | null = null; +function parseAuthorizedGitLabIntegration(row: { + id: string; + integration_type: string; + platform_account_id: string | null; + platform_account_login: string | null; + metadata: unknown; +}): AuthorizedGitLabIntegration { + return { + integrationId: row.id, + integrationType: row.integration_type, + accountId: row.platform_account_id, + accountLogin: row.platform_account_login, + metadata: GitLabMetadataSchema.parse(row.metadata ?? {}), + }; +} +export class GitLabLookupService { constructor(private env: CloudflareEnv) {} isConfigured(): boolean { @@ -207,13 +155,10 @@ export class GitLabLookupService { } private getDb(): WorkerDb { - if (!this.db) { - if (!this.env.HYPERDRIVE) { - throw new Error('Hyperdrive not configured'); - } - this.db = getWorkerDb(this.env.HYPERDRIVE.connectionString, { statement_timeout: 10_000 }); + if (!this.env.HYPERDRIVE) { + throw new Error('Hyperdrive not configured'); } - return this.db; + return getWorkerDb(this.env.HYPERDRIVE.connectionString, { statement_timeout: 10_000 }); } private validateLookup(params: GitLabLookupParams): GitLabLookupFailure | undefined { @@ -226,22 +171,27 @@ export class GitLabLookupService { } } - async findGitLabIntegration(params: GitLabLookupParams): Promise { + async findGitLabIntegration( + params: GitLabLookupParams, + integrationId?: string + ): Promise { const validationFailure = this.validateLookup(params); if (validationFailure) { return validationFailure; } - const rows = await buildAuthorizedGitLabIntegrationQuery(this.getDb(), params).limit(1); + const rows = await buildAuthorizedGitLabIntegrationQuery( + this.getDb(), + params, + integrationId + ).limit(1); if (rows.length === 0) { return { success: false, reason: 'no_integration_found' }; } - const row = rows[0]; return { success: true, - integrationId: row.id, - metadata: GitLabMetadataSchema.parse(row.metadata ?? {}), + ...parseAuthorizedGitLabIntegration(rows[0]), }; } @@ -260,10 +210,7 @@ export class GitLabLookupService { return { success: true, - integrations: rows.map(row => ({ - integrationId: row.id, - metadata: GitLabMetadataSchema.parse(row.metadata ?? {}), - })), + integrations: rows.map(parseAuthorizedGitLabIntegration), }; } } diff --git a/services/git-token-service/src/gitlab-runtime-token-resolver.ts b/services/git-token-service/src/gitlab-runtime-token-resolver.ts index fc576e264d..9ca0fe37fe 100644 --- a/services/git-token-service/src/gitlab-runtime-token-resolver.ts +++ b/services/git-token-service/src/gitlab-runtime-token-resolver.ts @@ -5,6 +5,10 @@ import { type GitLabLookupService, type GitLabRepositoryMatch, } from './gitlab-lookup-service.js'; +import { + sha256Digest, + type GitLabCapabilityCredentialSource, +} from './gitlab-session-capability.js'; import type { GitLabTokenService } from './gitlab-token-service.js'; export type GetGitLabTokenParams = { @@ -19,6 +23,8 @@ export type GetGitLabTokenSuccess = { token: string; instanceUrl: string; glabIsOAuth2: boolean; + integrationId: string; + source: GitLabCapabilityCredentialSource; }; export type GetGitLabTokenFailure = { @@ -30,6 +36,7 @@ export type GetGitLabTokenFailure = { | 'no_token' | 'token_refresh_failed' | 'token_expired_no_refresh' + | 'invalid_instance_url' | 'repository_url_required' | 'invalid_repository_url' | 'no_matching_integration' @@ -51,6 +58,8 @@ type GitLabRuntimeTokenDependencies = { type GitLabProjectTokenCandidate = { token: string; instanceUrl: string; + integrationId: string; + projectId: number; }; type GitLabCandidateEvaluation = @@ -112,6 +121,8 @@ async function evaluateGitLabProjectTokenCandidate( candidate: { token: projectToken.token, instanceUrl: match.instanceUrl, + integrationId: match.integrationId, + projectId, }, }; } @@ -134,7 +145,12 @@ export async function resolveGitLabRuntimeToken( return tokenResult; } - return { ...tokenResult, glabIsOAuth2: true }; + return { + ...tokenResult, + glabIsOAuth2: true, + integrationId: integration.integrationId, + source: { type: 'integration' }, + }; } if (!params.repositoryUrl) { @@ -191,5 +207,11 @@ export async function resolveGitLabRuntimeToken( token: candidate.token, instanceUrl: candidate.instanceUrl, glabIsOAuth2: false, + integrationId: candidate.integrationId, + source: { + type: 'project', + projectId: candidate.projectId, + tokenDigest: await sha256Digest(candidate.token), + }, }; } diff --git a/services/git-token-service/src/gitlab-session-capability.test.ts b/services/git-token-service/src/gitlab-session-capability.test.ts new file mode 100644 index 0000000000..85724b94d1 --- /dev/null +++ b/services/git-token-service/src/gitlab-session-capability.test.ts @@ -0,0 +1,162 @@ +import { encryptWithSymmetricKey } from '@kilocode/encryption'; +import { describe, expect, it, vi } from 'vitest'; +import { + GitLabSessionCapabilityCodec, + GitLabSessionCapabilityError, + parseGitLabCloneUrl, +} from './gitlab-session-capability.js'; + +const encryptionKey = Buffer.alloc(32, 7).toString('base64'); +const anotherEncryptionKey = Buffer.alloc(32, 8).toString('base64'); +const claims = { + userId: 'user_1', + outboundContainerId: 'outbound-container-1', + orgId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145b', + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + instanceOrigin: 'https://gitlab.example.com:8443/gitlab', + projectPath: 'Acme/platform/widgets', + authType: 'oauth', + identity: { accountId: '42', accountLogin: 'octocat' }, + source: { + type: 'project', + projectId: 42, + tokenDigest: 'f30b0bf364d41460c0119e521d2af8ae7eeacca9745981678d58b07b13c94edf', + }, +} as const; + +describe('GitLabSessionCapabilityCodec', () => { + it('produces an opaque one-hour prefixed capability with GitLab-bound claims', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-31T12:00:00.000Z')); + const codec = new GitLabSessionCapabilityCodec(encryptionKey); + + const capability = codec.issue(claims); + + expect(capability).toMatch(/^kgl2\./); + expect(capability).not.toContain('user_1'); + expect(capability).not.toContain('gitlab.example.com'); + expect(codec.decode(capability)).toEqual({ + purpose: 'gitlab_scm_session', + version: 2, + ...claims, + issuedAt: Date.parse('2026-05-31T12:00:00.000Z'), + expiresAt: Date.parse('2026-05-31T13:00:00.000Z'), + }); + vi.useRealTimers(); + }); + + it('produces and decodes a legacy unbound v1 capability when the container is omitted', () => { + const codec = new GitLabSessionCapabilityCodec(encryptionKey); + const { outboundContainerId: _outboundContainerId, ...legacyClaims } = claims; + + const capability = codec.issue(legacyClaims); + + expect(capability).toMatch(/^kgl1\./); + expect(codec.decode(capability)).toMatchObject({ + version: 1, + userId: 'user_1', + projectPath: 'Acme/platform/widgets', + }); + expect(codec.decode(capability)).not.toHaveProperty('outboundContainerId'); + }); + + it('rejects expiry, tampering, and another encryption key', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-31T12:00:00.000Z')); + const codec = new GitLabSessionCapabilityCodec(encryptionKey); + const capability = codec.issue(claims); + const changedOffset = capability.lastIndexOf('.') + 4; + const changedCharacter = capability[changedOffset] === 'A' ? 'B' : 'A'; + const tampered = `${capability.slice(0, changedOffset)}${changedCharacter}${capability.slice(changedOffset + 1)}`; + + expect(() => codec.decode(tampered)).toThrowError(GitLabSessionCapabilityError); + expect(() => + new GitLabSessionCapabilityCodec(anotherEncryptionKey).decode(capability) + ).toThrowError(expect.objectContaining({ reason: 'invalid_capability' })); + vi.setSystemTime(new Date('2026-05-31T13:00:00.000Z')); + expect(() => codec.decode(capability)).toThrowError( + expect.objectContaining({ reason: 'expired_capability' }) + ); + vi.useRealTimers(); + }); + + it.each([ + ['another purpose', { purpose: 'github_scm_session' }], + [ + 'a malformed project-token digest', + { source: { type: 'project', projectId: 42, tokenDigest: 'not-a-sha256-digest' } }, + ], + ])('rejects encrypted claims with %s', (_description, overriddenClaims) => { + const serializedClaims = JSON.stringify({ + purpose: 'gitlab_scm_session', + version: 2, + ...claims, + ...overriddenClaims, + issuedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + const capability = `kgl2.${encryptWithSymmetricKey(serializedClaims, encryptionKey)}`; + + expect(() => new GitLabSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + }); + + it('rejects a v2 claim under the legacy marker', () => { + const serializedClaims = JSON.stringify({ + purpose: 'gitlab_scm_session', + version: 2, + ...claims, + issuedAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + const capability = `kgl1.${encryptWithSymmetricKey(serializedClaims, encryptionKey)}`; + + expect(() => new GitLabSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + }); +}); + +describe('parseGitLabCloneUrl', () => { + const urlWithCredentials = new URL('https://gitlab.example.com/acme/widgets.git'); + urlWithCredentials.username = 'user'; + urlWithCredentials.password = 'pass'; + + it.each([ + [ + 'https://gitlab.com/acme/widgets.git', + undefined, + { + instanceOrigin: 'https://gitlab.com', + instanceHost: 'gitlab.com', + projectPath: 'acme/widgets', + }, + ], + [ + 'https://gitlab.example.com:8443/gitlab/Acme/platform/widgets.git', + 'https://gitlab.example.com:8443/gitlab/', + { + instanceOrigin: 'https://gitlab.example.com:8443/gitlab', + instanceHost: 'gitlab.example.com:8443', + projectPath: 'Acme/platform/widgets', + }, + ], + ])('accepts canonical nested GitLab clone URL %s', (gitUrl, instanceUrl, expected) => { + expect(parseGitLabCloneUrl(gitUrl, instanceUrl)).toEqual({ success: true, ...expected }); + }); + + it.each([ + ['http://gitlab.com/acme/widgets.git', undefined], + ['https://gitlab.example.com:8443/acme/widgets.git', 'https://gitlab.example.com:9443'], + ['https://gitlab.example.com/acme/widgets.git', 'https://gitlab.example.com/gitlab'], + ['https://gitlab.example.com/acme/widgets.git', 'https://other.example.com'], + ['https://gitlab.example.com/acme//widgets.git', 'https://gitlab.example.com'], + ['https://gitlab.example.com/acme/../widgets.git', 'https://gitlab.example.com'], + ['https://gitlab.example.com/acme%2Fwidgets.git', 'https://gitlab.example.com'], + [urlWithCredentials.toString(), 'https://gitlab.example.com'], + ['https://gitlab.example.com/acme/widgets.git?token=secret', 'https://gitlab.example.com'], + ])('rejects unsafe or unsupported clone URL %s', (gitUrl, instanceUrl) => { + expect(parseGitLabCloneUrl(gitUrl, instanceUrl).success).toBe(false); + }); +}); diff --git a/services/git-token-service/src/gitlab-session-capability.ts b/services/git-token-service/src/gitlab-session-capability.ts new file mode 100644 index 0000000000..4e4bec94e0 --- /dev/null +++ b/services/git-token-service/src/gitlab-session-capability.ts @@ -0,0 +1,152 @@ +import { decryptWithSymmetricKey, encryptWithSymmetricKey } from '@kilocode/encryption'; +import { z } from 'zod'; +import { hasCanonicalEncryptedValueFormat } from './github-session-capability.js'; +import { GitLabProjectPathSchema, normalizeGitLabInstanceUrl } from './gitlab-url.js'; +export { parseGitLabCloneUrl } from './gitlab-url.js'; +export type { GitLabCloneUrlFailureReason, GitLabCloneUrlResult } from './gitlab-url.js'; + +const LEGACY_CAPABILITY_PREFIX = 'kgl1.'; +const BOUND_CAPABILITY_PREFIX = 'kgl2.'; +const CAPABILITY_PURPOSE = 'gitlab_scm_session'; +const MAX_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS = 60 * 60 * 1000; +const GitLabSessionIdentitySchema = z + .object({ + accountId: z.string().min(1).nullable(), + accountLogin: z.string().min(1).nullable(), + }) + .strict() + .refine(identity => identity.accountId !== null || identity.accountLogin !== null); +const GitLabProjectTokenDigestSchema = z.string().regex(/^[a-f0-9]{64}$/); +const GitLabCapabilityCredentialSourceSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('integration') }).strict(), + z + .object({ + type: z.literal('project'), + projectId: z.number().int().positive(), + tokenDigest: GitLabProjectTokenDigestSchema, + }) + .strict(), +]); +const GitLabSessionCapabilityClaimsBaseSchema = z.object({ + purpose: z.literal(CAPABILITY_PURPOSE), + userId: z.string().min(1), + orgId: z.string().uuid().optional(), + integrationId: z.string().uuid(), + instanceOrigin: z.string().url().refine(isCanonicalGitLabInstanceUrl), + projectPath: GitLabProjectPathSchema, + authType: z.enum(['oauth', 'pat']), + identity: GitLabSessionIdentitySchema, + source: GitLabCapabilityCredentialSourceSchema, + issuedAt: z.number().int().nonnegative(), + expiresAt: z.number().int().positive(), +}); +const GitLabLegacySessionCapabilityClaimsSchema = GitLabSessionCapabilityClaimsBaseSchema.extend({ + version: z.literal(1), +}).strict(); +const GitLabBoundSessionCapabilityClaimsSchema = GitLabSessionCapabilityClaimsBaseSchema.extend({ + version: z.literal(2), + outboundContainerId: z.string().min(1), +}).strict(); +const GitLabSessionCapabilityClaimsSchema = z + .discriminatedUnion('version', [ + GitLabLegacySessionCapabilityClaimsSchema, + GitLabBoundSessionCapabilityClaimsSchema, + ]) + .refine(claims => claims.expiresAt > claims.issuedAt) + .refine( + claims => claims.expiresAt - claims.issuedAt <= MAX_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS + ); + +export type GitLabAuthType = 'oauth' | 'pat'; +export type GitLabSessionIdentity = z.infer; +export type GitLabCapabilityCredentialSource = z.infer< + typeof GitLabCapabilityCredentialSourceSchema +>; +export type GitLabSessionCapabilitySubject = { + userId: string; + outboundContainerId?: string; + orgId?: string; + integrationId: string; + instanceOrigin: string; + projectPath: string; + authType: GitLabAuthType; + identity: GitLabSessionIdentity; + source: GitLabCapabilityCredentialSource; +}; +export type GitLabSessionCapabilityClaims = z.infer; +export type GitLabSessionCapabilityFailureReason = + | 'invalid_capability' + | 'expired_capability' + | 'capability_configuration_error'; +export class GitLabSessionCapabilityError extends Error { + constructor(readonly reason: GitLabSessionCapabilityFailureReason) { + super(reason); + this.name = 'GitLabSessionCapabilityError'; + } +} + +function isCanonicalGitLabInstanceUrl(instanceUrl: string): boolean { + return normalizeGitLabInstanceUrl(instanceUrl) === instanceUrl; +} + +export async function sha256Digest(value: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); +} + +export class GitLabSessionCapabilityCodec { + constructor(private readonly encryptionKey: string) {} + + issue(subject: GitLabSessionCapabilitySubject): string { + const issuedAt = Date.now(); + const bound = subject.outboundContainerId !== undefined; + const parsed = GitLabSessionCapabilityClaimsSchema.safeParse({ + purpose: CAPABILITY_PURPOSE, + version: bound ? 2 : 1, + ...subject, + issuedAt, + expiresAt: issuedAt + MAX_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS, + }); + if (!parsed.success) throw new GitLabSessionCapabilityError('invalid_capability'); + try { + const prefix = bound ? BOUND_CAPABILITY_PREFIX : LEGACY_CAPABILITY_PREFIX; + return `${prefix}${encryptWithSymmetricKey(JSON.stringify(parsed.data), this.encryptionKey)}`; + } catch { + throw new GitLabSessionCapabilityError('capability_configuration_error'); + } + } + + decode(capability: string): GitLabSessionCapabilityClaims { + const format = capability.startsWith(LEGACY_CAPABILITY_PREFIX) + ? { prefix: LEGACY_CAPABILITY_PREFIX, version: 1 as const } + : capability.startsWith(BOUND_CAPABILITY_PREFIX) + ? { prefix: BOUND_CAPABILITY_PREFIX, version: 2 as const } + : null; + if (!format) throw new GitLabSessionCapabilityError('invalid_capability'); + + const encrypted = capability.slice(format.prefix.length); + if (!hasCanonicalEncryptedValueFormat(encrypted)) { + throw new GitLabSessionCapabilityError('invalid_capability'); + } + let serialized: string; + try { + serialized = decryptWithSymmetricKey(encrypted, this.encryptionKey); + } catch { + throw new GitLabSessionCapabilityError('invalid_capability'); + } + let value: unknown; + try { + value = JSON.parse(serialized); + } catch { + throw new GitLabSessionCapabilityError('invalid_capability'); + } + const parsed = GitLabSessionCapabilityClaimsSchema.safeParse(value); + if (!parsed.success || parsed.data.version !== format.version) { + throw new GitLabSessionCapabilityError('invalid_capability'); + } + if (parsed.data.expiresAt <= Date.now()) { + throw new GitLabSessionCapabilityError('expired_capability'); + } + return parsed.data; + } +} diff --git a/services/git-token-service/src/gitlab-token-service.test.ts b/services/git-token-service/src/gitlab-token-service.test.ts new file mode 100644 index 0000000000..1016ac1f2c --- /dev/null +++ b/services/git-token-service/src/gitlab-token-service.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { GitLabTokenService } from './gitlab-token-service.js'; + +describe('GitLabTokenService', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('refreshes OAuth tokens against a safe instance base path', async () => { + const fetch = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'refreshed-access-token', + refresh_token: 'refreshed-refresh-token', + token_type: 'bearer', + expires_in: 3600, + created_at: 1_800_000_000, + scope: 'api', + }) + ); + vi.stubGlobal('fetch', fetch); + const service = new GitLabTokenService({ + GITLAB_CLIENT_ID: 'client-id', + GITLAB_CLIENT_SECRET: 'client-secret', + } as unknown as CloudflareEnv); + vi.spyOn(service as any, 'updateIntegrationMetadata').mockResolvedValue(undefined); + + await expect( + service.getToken('integration_1', { + access_token: 'expired-access-token', + refresh_token: 'refresh-token', + token_expires_at: '2020-01-01T00:00:00.000Z', + auth_type: 'oauth', + gitlab_instance_url: 'https://gitlab.example.com:8443/gitlab/', + }) + ).resolves.toEqual({ + success: true, + token: 'refreshed-access-token', + instanceUrl: 'https://gitlab.example.com:8443/gitlab', + }); + expect(fetch).toHaveBeenCalledWith('https://gitlab.example.com:8443/gitlab/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: 'client-id', + client_secret: 'client-secret', + refresh_token: 'refresh-token', + grant_type: 'refresh_token', + }), + }); + }); + + it('rejects unsafe refresh targets before sending OAuth credentials', async () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + + await expect( + new GitLabTokenService({} as CloudflareEnv).getToken('integration_1', { + access_token: 'expired-access-token', + refresh_token: 'refresh-token', + token_expires_at: '2020-01-01T00:00:00.000Z', + auth_type: 'oauth', + gitlab_instance_url: + 'https://gitlab.example.com/gitlab?redirect=https://attacker.example.com', + }) + ).resolves.toEqual({ success: false, reason: 'invalid_instance_url' }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('does not log provider response bodies when refresh fails', async () => { + const text = vi.fn().mockResolvedValue('body includes token secret'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 502, text })); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect( + new GitLabTokenService({ + GITLAB_CLIENT_ID: 'client-id', + GITLAB_CLIENT_SECRET: 'client-secret', + } as unknown as CloudflareEnv).getToken('integration_1', { + access_token: 'expired-access-token', + refresh_token: 'refresh-token', + token_expires_at: '2020-01-01T00:00:00.000Z', + auth_type: 'oauth', + }) + ).resolves.toEqual({ success: false, reason: 'token_refresh_failed' }); + expect(JSON.stringify(consoleError.mock.calls)).not.toContain('body includes token secret'); + expect(text).not.toHaveBeenCalled(); + }); +}); diff --git a/services/git-token-service/src/gitlab-token-service.ts b/services/git-token-service/src/gitlab-token-service.ts index de4dd60e02..e2da484323 100644 --- a/services/git-token-service/src/gitlab-token-service.ts +++ b/services/git-token-service/src/gitlab-token-service.ts @@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm'; import * as z from 'zod'; import { DEFAULT_GITLAB_INSTANCE_URL } from './gitlab-constants.js'; import type { GitLabIntegrationMetadata } from './gitlab-lookup-service.js'; +import { normalizeGitLabInstanceUrl } from './gitlab-url.js'; const GitLabOAuthTokenResponseSchema = z.object({ access_token: z.string(), @@ -24,11 +25,16 @@ export type GitLabTokenSuccess = { export type GitLabTokenFailure = { success: false; - reason: 'no_token' | 'token_refresh_failed' | 'token_expired_no_refresh'; + reason: 'no_token' | 'token_refresh_failed' | 'token_expired_no_refresh' | 'invalid_instance_url'; }; export type GitLabTokenResult = GitLabTokenSuccess | GitLabTokenFailure; +type GitLabTokenEnv = CloudflareEnv & { + GITLAB_CLIENT_ID?: string; + GITLAB_CLIENT_SECRET?: string; +}; + function isTokenExpired(expiresAt: string | null | undefined): boolean { if (!expiresAt) return true; const expiryTime = new Date(expiresAt).getTime(); @@ -42,15 +48,16 @@ function calculateTokenExpiry(createdAt: number, expiresIn: number): string { } export class GitLabTokenService { - private db: WorkerDb | null = null; - - constructor(private env: CloudflareEnv) {} + constructor(private env: GitLabTokenEnv) {} async getToken( integrationId: string, metadata: GitLabIntegrationMetadata ): Promise { - const instanceUrl = metadata.gitlab_instance_url || DEFAULT_GITLAB_INSTANCE_URL; + const instanceUrl = normalizeGitLabInstanceUrl( + metadata.gitlab_instance_url || DEFAULT_GITLAB_INSTANCE_URL + ); + if (!instanceUrl) return { success: false, reason: 'invalid_instance_url' }; if (!metadata.access_token) { return { success: false, reason: 'no_token' }; @@ -111,19 +118,18 @@ export class GitLabTokenService { }); if (!response.ok) { - const error = await response.text(); - console.error('GitLab OAuth token refresh failed:', { status: response.status, error }); + console.error('GitLab OAuth token refresh failed:', { status: response.status }); return null; } const parsed = GitLabOAuthTokenResponseSchema.safeParse(await response.json()); if (!parsed.success) { - console.error('Unexpected GitLab token response shape:', parsed.error); + console.error('Unexpected GitLab token response shape'); return null; } return parsed.data; - } catch (error) { - console.error('GitLab OAuth token refresh error:', error); + } catch { + console.error('GitLab OAuth token refresh request failed'); return null; } } @@ -151,12 +157,9 @@ export class GitLabTokenService { } private getDb(): WorkerDb { - if (!this.db) { - if (!this.env.HYPERDRIVE) { - throw new Error('Hyperdrive not configured'); - } - this.db = getWorkerDb(this.env.HYPERDRIVE.connectionString, { statement_timeout: 10_000 }); + if (!this.env.HYPERDRIVE) { + throw new Error('Hyperdrive not configured'); } - return this.db; + return getWorkerDb(this.env.HYPERDRIVE.connectionString, { statement_timeout: 10_000 }); } } diff --git a/services/git-token-service/src/gitlab-url.ts b/services/git-token-service/src/gitlab-url.ts new file mode 100644 index 0000000000..76d1d59bca --- /dev/null +++ b/services/git-token-service/src/gitlab-url.ts @@ -0,0 +1,130 @@ +import { z } from 'zod'; +import { DEFAULT_GITLAB_INSTANCE_URL } from './gitlab-constants.js'; + +export const GitLabProjectPathSchema = z + .string() + .min(3) + .refine(path => path.split('/').length >= 2) + .refine(path => path.split('/').every(part => /^[A-Za-z0-9_.-]+$/.test(part))) + .refine(path => path.split('/').every(part => part !== '.' && part !== '..' && part !== '-')); + +export type GitLabBaseUrl = { + instanceUrl: string; + origin: string; + host: string; + basePath: string; +}; + +export type GitLabCloneUrlFailureReason = 'invalid_gitlab_url' | 'unsupported_gitlab_instance'; +export type GitLabCloneUrlResult = + | { + success: true; + instanceOrigin: string; + instanceHost: string; + projectPath: string; + } + | { success: false; reason: GitLabCloneUrlFailureReason }; + +function rawPath(value: string): string { + return /^https:\/\/[^/]*(\/[^?#]*)?/i.exec(value)?.[1] ?? ''; +} + +function hasUnsafePath(value: string, allowRepeatedSlashes = false): boolean { + const path = rawPath(value); + return ( + path.includes('\\') || + /%2f|%5c/i.test(path) || + (!allowRepeatedSlashes && /\/\//.test(path)) || + /\/(?:(?:\.|%2e){1,2})(?:\/|$)/i.test(path) + ); +} + +function parseSafeHttpsUrl( + value: string, + allowQuery = false, + allowRepeatedSlashes = false +): URL | null { + try { + const parsed = new URL(value); + if ( + parsed.protocol !== 'https:' || + !parsed.hostname || + parsed.username !== '' || + parsed.password !== '' || + (!allowQuery && parsed.search !== '') || + parsed.hash !== '' || + hasUnsafePath(value, allowRepeatedSlashes) + ) { + return null; + } + return parsed; + } catch { + return null; + } +} + +function normalizeProjectPath(value: string): string | null { + let decodedPath: string; + try { + decodedPath = decodeURIComponent(value); + } catch { + return null; + } + if (decodedPath.includes('\\')) return null; + const parts = decodedPath.split('/'); + const terminal = parts.at(-1); + if (!terminal) return null; + if (terminal.endsWith('.git')) parts[parts.length - 1] = terminal.slice(0, -4); + const projectPath = parts.join('/'); + return GitLabProjectPathSchema.safeParse(projectPath).success ? projectPath : null; +} + +export function parseGitLabBaseUrl(instanceUrl: string): GitLabBaseUrl | null { + const parsed = parseSafeHttpsUrl(instanceUrl); + if (!parsed) return null; + const basePath = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/$/, ''); + return { + instanceUrl: `${parsed.origin}${basePath}`, + origin: parsed.origin, + host: parsed.host, + basePath, + }; +} + +export function normalizeGitLabInstanceUrl(instanceUrl: string): string | null { + return parseGitLabBaseUrl(instanceUrl)?.instanceUrl ?? null; +} + +export function isValidGitLabRepositoryUrl(repositoryUrl: string): boolean { + const parsed = parseSafeHttpsUrl(repositoryUrl, false, true); + if (!parsed || parsed.pathname === '/' || parsed.pathname.endsWith('/')) return false; + return normalizeProjectPath(parsed.pathname.slice(1).replace(/^\/+/, '')) !== null; +} + +export function parseGitLabCloneUrl( + gitUrl: string, + instanceUrl = DEFAULT_GITLAB_INSTANCE_URL +): GitLabCloneUrlResult { + const repository = parseSafeHttpsUrl(gitUrl, false, true); + if (!repository || repository.pathname === '/' || repository.pathname.endsWith('/')) { + return { success: false, reason: 'invalid_gitlab_url' }; + } + const instance = parseGitLabBaseUrl(instanceUrl); + if (!instance || repository.origin !== instance.origin) { + return { success: false, reason: 'unsupported_gitlab_instance' }; + } + const repositoryPrefix = instance.basePath === '' ? '/' : `${instance.basePath}/`; + if (!repository.pathname.startsWith(repositoryPrefix)) { + return { success: false, reason: 'unsupported_gitlab_instance' }; + } + const projectPath = normalizeProjectPath( + repository.pathname.slice(repositoryPrefix.length).replace(/^\/+/, '') + ); + if (!projectPath) return { success: false, reason: 'invalid_gitlab_url' }; + return { + success: true, + instanceOrigin: instance.instanceUrl, + instanceHost: instance.host, + projectPath, + }; +} diff --git a/services/git-token-service/src/index.test.ts b/services/git-token-service/src/index.test.ts index 8c04263c47..172ded024d 100644 --- a/services/git-token-service/src/index.test.ts +++ b/services/git-token-service/src/index.test.ts @@ -1,19 +1,79 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as GitLabLookupServiceModule from './gitlab-lookup-service.js'; + +const serviceMocks = vi.hoisted(() => ({ + findInstallationId: vi.fn(), + findManagedInstallationForRepo: vi.fn(), + findRefreshCandidates: vi.fn(), + updateAccountLogin: vi.fn(), + getToken: vi.fn(), + getTokenForRepo: vi.fn(), + refreshInstallationAccountLoginIfDue: vi.fn(), + selectUserAuthorization: vi.fn(), + findGitLabIntegration: vi.fn(), + findAuthorizedGitLabIntegrations: vi.fn(), + getGitLabToken: vi.fn(), +})); vi.mock('cloudflare:workers', () => ({ WorkerEntrypoint: class WorkerEntrypoint { - constructor(_ctx: unknown, _env: unknown) {} + env: unknown; + + constructor(_ctx: unknown, env: unknown) { + this.env = env; + } + }, +})); + +vi.mock('./github-token-service.js', () => ({ + GitHubTokenService: class GitHubTokenService { + getToken = serviceMocks.getToken; + getTokenForRepo = serviceMocks.getTokenForRepo; + refreshInstallationAccountLoginIfDue = serviceMocks.refreshInstallationAccountLoginIfDue; + }, +})); + +vi.mock('./installation-lookup-service.js', () => ({ + InstallationLookupService: class InstallationLookupService { + findInstallationId = serviceMocks.findInstallationId; + findManagedInstallationForRepo = serviceMocks.findManagedInstallationForRepo; + findRefreshCandidates = serviceMocks.findRefreshCandidates; + updateAccountLogin = serviceMocks.updateAccountLogin; + }, +})); + +vi.mock('./github-user-authorization-service.js', () => ({ + GitHubUserAuthorizationService: class GitHubUserAuthorizationService { + selectUserAuthorization = serviceMocks.selectUserAuthorization; + }, +})); + +vi.mock('./gitlab-lookup-service.js', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + GitLabLookupService: class GitLabLookupService { + findGitLabIntegration = serviceMocks.findGitLabIntegration; + findAuthorizedGitLabIntegrations = serviceMocks.findAuthorizedGitLabIntegrations; + }, + }; +}); + +vi.mock('./gitlab-token-service.js', () => ({ + GitLabTokenService: class GitLabTokenService { + getToken = serviceMocks.getGitLabToken; }, })); -import { GitHubTokenService } from './github-token-service.js'; import type { AuthorizedGitLabIntegration } from './gitlab-lookup-service.js'; import { resolveGitLabRuntimeToken } from './gitlab-runtime-token-resolver.js'; import { GitTokenRPCEntrypoint } from './index.js'; -import { InstallationLookupService } from './installation-lookup-service.js'; const integration: AuthorizedGitLabIntegration = { integrationId: '123e4567-e89b-12d3-a456-426614174011', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', metadata: { access_token: 'human-integration-token', gitlab_instance_url: 'https://gitlab.example.com/gitlab', @@ -39,6 +99,17 @@ function createDependencies(options: { integrations?: AuthorizedGitLabIntegratio return { lookupService, tokenService }; } +function createService(): GitTokenRPCEntrypoint { + return new GitTokenRPCEntrypoint( + {} as ExecutionContext, + { + GITHUB_APP_SLUG: 'kiloconnect', + GITHUB_APP_BOT_USER_ID: '240665456', + SCM_SESSION_CAPABILITY_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64'), + } as unknown as CloudflareEnv + ); +} + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -52,6 +123,8 @@ describe('resolveGitLabRuntimeToken', () => { success: true, token: 'human-integration-token', instanceUrl: 'https://gitlab.example.com/gitlab', + integrationId: integration.integrationId, + source: { type: 'integration' }, glabIsOAuth2: true, }); expect(dependencies.lookupService.findGitLabIntegration).toHaveBeenCalledWith({ @@ -79,6 +152,12 @@ describe('resolveGitLabRuntimeToken', () => { success: true, token: 'project-bot-token', instanceUrl: 'https://gitlab.example.com/gitlab', + integrationId: integration.integrationId, + source: { + type: 'project', + projectId: 42, + tokenDigest: '3f4dff81e5f3e75d64343bfe237db23397715d8fbccbb1e035fb20a6d15f4603', + }, glabIsOAuth2: false, }); expect(dependencies.tokenService.getToken).toHaveBeenCalledWith( @@ -164,6 +243,12 @@ describe('resolveGitLabRuntimeToken', () => { success: true, token: 'project-bot-token', instanceUrl: 'https://gitlab.example.com/gitlab', + integrationId: integration.integrationId, + source: { + type: 'project', + projectId: 42, + tokenDigest: '3f4dff81e5f3e75d64343bfe237db23397715d8fbccbb1e035fb20a6d15f4603', + }, glabIsOAuth2: false, }); expect(dependencies.tokenService.getToken).toHaveBeenCalledTimes(2); @@ -209,6 +294,12 @@ describe('resolveGitLabRuntimeToken', () => { success: true, token: 'project-bot-token', instanceUrl: 'https://gitlab.example.com/gitlab', + integrationId: integration.integrationId, + source: { + type: 'project', + projectId: 42, + tokenDigest: '3f4dff81e5f3e75d64343bfe237db23397715d8fbccbb1e035fb20a6d15f4603', + }, glabIsOAuth2: false, }); }); @@ -241,6 +332,12 @@ describe('resolveGitLabRuntimeToken', () => { success: true, token: 'project-bot-token', instanceUrl: 'https://gitlab.example.com/gitlab', + integrationId: integration.integrationId, + source: { + type: 'project', + projectId: 42, + tokenDigest: '3f4dff81e5f3e75d64343bfe237db23397715d8fbccbb1e035fb20a6d15f4603', + }, glabIsOAuth2: false, }); expect(dependencies.tokenService.getToken).toHaveBeenCalledOnce(); @@ -300,23 +397,22 @@ describe('resolveGitLabRuntimeToken', () => { }); }); -describe('GitTokenRPCEntrypoint', () => { +describe('GitTokenRPCEntrypoint.getTokenForRepo', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('mints repository-scoped tokens after resolving an authorized installation', async () => { - vi.spyOn(InstallationLookupService.prototype, 'findInstallationId').mockResolvedValue({ + serviceMocks.findInstallationId.mockResolvedValue({ success: true, installationId: '123', accountLogin: 'old-owner', githubAppType: 'lite', }); - const getTokenForRepo = vi - .spyOn(GitHubTokenService.prototype, 'getTokenForRepo') - .mockResolvedValue('scoped-token'); - const getToken = vi - .spyOn(GitHubTokenService.prototype, 'getToken') - .mockResolvedValue('installation-wide-token'); - const rpc = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); + serviceMocks.getTokenForRepo.mockResolvedValue('scoped-token'); + serviceMocks.getToken.mockResolvedValue('installation-wide-token'); - const result = await rpc.getTokenForRepo({ + const result = await createService().getTokenForRepo({ githubRepo: 'renamed-owner/repository', userId: 'user-1', }); @@ -328,13 +424,12 @@ describe('GitTokenRPCEntrypoint', () => { accountLogin: 'old-owner', appType: 'lite', }); - expect(getTokenForRepo).toHaveBeenCalledWith('123', 'repository', 'lite'); - expect(getToken).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledWith('123', 'repository', 'lite'); + expect(serviceMocks.getToken).not.toHaveBeenCalled(); }); it('repairs stale login metadata after a lookup miss before minting a token', async () => { - const findInstallationId = vi - .spyOn(InstallationLookupService.prototype, 'findInstallationId') + serviceMocks.findInstallationId .mockResolvedValueOnce({ success: false, reason: 'no_installation_found' }) .mockResolvedValueOnce({ success: true, @@ -342,7 +437,7 @@ describe('GitTokenRPCEntrypoint', () => { accountLogin: 'renamed-owner', githubAppType: 'standard', }); - vi.spyOn(InstallationLookupService.prototype, 'findRefreshCandidates').mockResolvedValue({ + serviceMocks.findRefreshCandidates.mockResolvedValue({ success: true, candidates: [ { @@ -353,26 +448,18 @@ describe('GitTokenRPCEntrypoint', () => { }, ], }); - const updateAccountLogin = vi - .spyOn(InstallationLookupService.prototype, 'updateAccountLogin') - .mockResolvedValue(true); + serviceMocks.updateAccountLogin.mockResolvedValue(true); + serviceMocks.refreshInstallationAccountLoginIfDue.mockResolvedValue('renamed-owner'); + serviceMocks.getTokenForRepo.mockResolvedValue('scoped-token'); const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.spyOn( - GitHubTokenService.prototype, - 'refreshInstallationAccountLoginIfDue' - ).mockResolvedValue('renamed-owner'); - const getTokenForRepo = vi - .spyOn(GitHubTokenService.prototype, 'getTokenForRepo') - .mockResolvedValue('scoped-token'); - const rpc = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); - - const result = await rpc.getTokenForRepo({ + + const result = await createService().getTokenForRepo({ githubRepo: 'renamed-owner/repository', userId: 'user-1', }); expect(result).toMatchObject({ success: true, token: 'scoped-token' }); - expect(updateAccountLogin).toHaveBeenCalledWith('integration-1', 'renamed-owner'); + expect(serviceMocks.updateAccountLogin).toHaveBeenCalledWith('integration-1', 'renamed-owner'); expect(consoleLog).toHaveBeenCalledWith( JSON.stringify({ message: 'Repaired GitHub installation account login after token lookup miss', @@ -383,16 +470,16 @@ describe('GitTokenRPCEntrypoint', () => { ); expect(JSON.stringify(consoleLog.mock.calls)).not.toContain('old-owner'); expect(JSON.stringify(consoleLog.mock.calls)).not.toContain('renamed-owner'); - expect(findInstallationId).toHaveBeenCalledTimes(2); - expect(getTokenForRepo).toHaveBeenCalledWith('123', 'repository', 'standard'); + expect(serviceMocks.findInstallationId).toHaveBeenCalledTimes(2); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledWith('123', 'repository', 'standard'); }); it('warns instead of reporting success when a repaired integration no longer exists', async () => { - vi.spyOn(InstallationLookupService.prototype, 'findInstallationId').mockResolvedValue({ + serviceMocks.findInstallationId.mockResolvedValue({ success: false, reason: 'no_installation_found', }); - vi.spyOn(InstallationLookupService.prototype, 'findRefreshCandidates').mockResolvedValue({ + serviceMocks.findRefreshCandidates.mockResolvedValue({ success: true, candidates: [ { @@ -403,16 +490,12 @@ describe('GitTokenRPCEntrypoint', () => { }, ], }); - vi.spyOn(InstallationLookupService.prototype, 'updateAccountLogin').mockResolvedValue(false); - vi.spyOn( - GitHubTokenService.prototype, - 'refreshInstallationAccountLoginIfDue' - ).mockResolvedValue('renamed-owner'); + serviceMocks.updateAccountLogin.mockResolvedValue(false); + serviceMocks.refreshInstallationAccountLoginIfDue.mockResolvedValue('renamed-owner'); const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const rpc = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); - const result = await rpc.getTokenForRepo({ + const result = await createService().getTokenForRepo({ githubRepo: 'renamed-owner/repository', userId: 'user-1', }); @@ -432,11 +515,11 @@ describe('GitTokenRPCEntrypoint', () => { }); it('does not mint when refreshed metadata identifies a different repository owner', async () => { - vi.spyOn(InstallationLookupService.prototype, 'findInstallationId').mockResolvedValue({ + serviceMocks.findInstallationId.mockResolvedValue({ success: false, reason: 'no_installation_found', }); - vi.spyOn(InstallationLookupService.prototype, 'findRefreshCandidates').mockResolvedValue({ + serviceMocks.findRefreshCandidates.mockResolvedValue({ success: true, candidates: [ { @@ -447,83 +530,1358 @@ describe('GitTokenRPCEntrypoint', () => { }, ], }); - const updateAccountLogin = vi - .spyOn(InstallationLookupService.prototype, 'updateAccountLogin') - .mockResolvedValue(true); + serviceMocks.updateAccountLogin.mockResolvedValue(true); + serviceMocks.refreshInstallationAccountLoginIfDue.mockResolvedValue('different-owner'); vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.spyOn( - GitHubTokenService.prototype, - 'refreshInstallationAccountLoginIfDue' - ).mockResolvedValue('different-owner'); - const getTokenForRepo = vi.spyOn(GitHubTokenService.prototype, 'getTokenForRepo'); - const rpc = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); - - const result = await rpc.getTokenForRepo({ + + const result = await createService().getTokenForRepo({ githubRepo: 'requested-owner/repository', userId: 'user-1', }); expect(result).toEqual({ success: false, reason: 'no_installation_found' }); - expect(updateAccountLogin).toHaveBeenCalledWith('integration-1', 'different-owner'); - expect(getTokenForRepo).not.toHaveBeenCalled(); + expect(serviceMocks.updateAccountLogin).toHaveBeenCalledWith( + 'integration-1', + 'different-owner' + ); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); }); it('fails closed without metadata repair when exact owner selection is ambiguous', async () => { - vi.spyOn(InstallationLookupService.prototype, 'findInstallationId').mockResolvedValue({ + serviceMocks.findInstallationId.mockResolvedValue({ success: false, reason: 'ambiguous_installation', }); - const findRefreshCandidates = vi.spyOn( - InstallationLookupService.prototype, - 'findRefreshCandidates' - ); - const getTokenForRepo = vi.spyOn(GitHubTokenService.prototype, 'getTokenForRepo'); - const rpc = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); - const result = await rpc.getTokenForRepo({ + const result = await createService().getTokenForRepo({ githubRepo: 'requested-owner/repository', userId: 'user-1', }); expect(result).toEqual({ success: false, reason: 'no_installation_found' }); - expect(findRefreshCandidates).not.toHaveBeenCalled(); - expect(getTokenForRepo).not.toHaveBeenCalled(); + expect(serviceMocks.findRefreshCandidates).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); }); it('does not mint a token for an invalid repository path', async () => { - const getTokenForRepo = vi.spyOn(GitHubTokenService.prototype, 'getTokenForRepo'); - const rpc = new GitTokenRPCEntrypoint( - {} as ExecutionContext, - { - HYPERDRIVE: { connectionString: 'postgres://test' }, - } as CloudflareEnv - ); + serviceMocks.findInstallationId.mockResolvedValue({ + success: false, + reason: 'invalid_repo_format', + }); - const result = await rpc.getTokenForRepo({ + const result = await createService().getTokenForRepo({ githubRepo: 'owner/repository/extra', userId: 'user-1', }); expect(result).toEqual({ success: false, reason: 'invalid_repo_format' }); - expect(getTokenForRepo).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); }); it('does not fall back to an installation-wide token when scoped minting fails', async () => { - vi.spyOn(InstallationLookupService.prototype, 'findInstallationId').mockResolvedValue({ + serviceMocks.findInstallationId.mockResolvedValue({ success: true, installationId: '123', accountLogin: 'old-owner', githubAppType: 'standard', }); - vi.spyOn(GitHubTokenService.prototype, 'getTokenForRepo').mockRejectedValue( - new Error('repository not accessible') - ); - const getToken = vi.spyOn(GitHubTokenService.prototype, 'getToken'); - const rpc = new GitTokenRPCEntrypoint({} as ExecutionContext, {} as CloudflareEnv); + serviceMocks.getTokenForRepo.mockRejectedValueOnce(new Error('repository not accessible')); await expect( - rpc.getTokenForRepo({ githubRepo: 'renamed-owner/repository', userId: 'user-1' }) + createService().getTokenForRepo({ githubRepo: 'renamed-owner/repository', userId: 'user-1' }) ).rejects.toThrow('repository not accessible'); - expect(getToken).not.toHaveBeenCalled(); + expect(serviceMocks.getToken).not.toHaveBeenCalled(); + }); +}); + +const outboundContainerId = 'outbound-container-1'; + +describe('GitTokenRPCEntrypoint GitHub session capability RPCs', () => { + beforeEach(() => { + vi.clearAllMocks(); + serviceMocks.findManagedInstallationForRepo.mockResolvedValue({ + success: true, + installationId: '123', + accountLogin: 'acme', + githubAppType: 'standard', + repoName: 'repo', + permissions: { contents: 'write', pull_requests: 'write' }, + }); + serviceMocks.getTokenForRepo.mockResolvedValue('installation-token'); + serviceMocks.selectUserAuthorization.mockResolvedValue({ + selected: true, + token: 'user-token', + gitAuthor: { name: 'octocat', email: '1+octocat@users.noreply.github.com' }, + }); + }); + + it('issues an opaque GitHub capability while preserving non-secret attribution metadata', async () => { + const result = await createService().issueGitHubSessionCapability({ + githubRepo: 'Acme/Repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + + expect(result).toMatchObject({ + success: true, + source: 'user', + installationId: '123', + accountLogin: 'acme', + appType: 'standard', + gitAuthor: { name: 'octocat' }, + }); + if (!result.success) throw new Error('Expected successful issuance'); + expect(result.capability).toMatch(/^kgh2\./); + expect(JSON.stringify(result)).not.toContain('user-token'); + expect(result).not.toHaveProperty('githubToken'); + }); + + it('does not expose an installation token in an installation-source issuance result', async () => { + const result = await createService().issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + + expect(result).toMatchObject({ success: true, source: 'installation' }); + if (!result.success) throw new Error('Expected successful issuance'); + expect(JSON.stringify(result)).not.toContain('installation-token'); + expect(result.capability).not.toContain('installation-token'); + expect(result).not.toHaveProperty('githubToken'); + expect(result).not.toHaveProperty('token'); + }); + + it('returns a sanitized declared failure when capability key configuration is invalid', async () => { + const service = new GitTokenRPCEntrypoint( + {} as ExecutionContext, + { + GITHUB_APP_SLUG: 'kiloconnect', + GITHUB_APP_BOT_USER_ID: '240665456', + SCM_SESSION_CAPABILITY_ENCRYPTION_KEY: 'not-a-valid-key', + } as unknown as CloudflareEnv + ); + + await expect( + service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }) + ).resolves.toEqual({ success: false, reason: 'capability_configuration_error' }); + }); + + it('does not redeem a capability from another outbound container or resolve authorization', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findManagedInstallationForRepo.mockClear(); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId: 'another-outbound-container', + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ success: false, reason: 'container_mismatch' }); + expect(serviceMocks.findManagedInstallationForRepo).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it('does not redeem a bound capability without an outbound container or resolve authorization', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findManagedInstallationForRepo.mockClear(); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ success: false, reason: 'container_mismatch' }); + expect(serviceMocks.findManagedInstallationForRepo).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it('temporarily issues and redeems a legacy unbound GitHub capability for an old caller', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + expect(issued.capability).toMatch(/^kgh1\./); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ + success: true, + authorization: `Basic ${Buffer.from('x-access-token:installation-token').toString('base64')}`, + }); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledOnce(); + }); + + it('rejects tampered capabilities before resolving any upstream authorization', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findManagedInstallationForRepo.mockClear(); + serviceMocks.getTokenForRepo.mockClear(); + + const changedOffset = issued.capability.lastIndexOf('.') + 4; + const changedCharacter = issued.capability[changedOffset] === 'A' ? 'B' : 'A'; + const tamperedCapability = `${issued.capability.slice(0, changedOffset)}${changedCharacter}${issued.capability.slice(changedOffset + 1)}`; + await expect( + service.redeemGitHubSessionCapability({ + capability: tamperedCapability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ success: false, reason: 'invalid_capability' }); + expect(serviceMocks.findManagedInstallationForRepo).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it.each([ + ['GET', 'https://github.com/Acme/Repo.git/info/refs?service=git-upload-pack'], + ['GET', 'https://github.com/acme/repo.git/info/refs?service=git-receive-pack'], + ['POST', 'https://github.com/acme/repo.git/git-upload-pack'], + ['POST', 'https://github.com/acme/repo.git/git-receive-pack'], + ] as const)( + 'redeems an installation-pinned capability for %s Git URL %s', + async (requestMethod, requestUrl) => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'Acme/Repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + const redemption = await service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }); + + expect(redemption).toEqual({ + success: true, + authorization: `Basic ${Buffer.from('x-access-token:installation-token').toString('base64')}`, + }); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledOnce(); + } + ); + + it('returns a sanitized failure when installation token generation fails during redemption', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockRejectedValueOnce( + new Error('provider rejected installation token: raw-provider-detail') + ); + + const redemption = await service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + }); + + expect(redemption).toEqual({ success: false, reason: 'source_unavailable' }); + expect(JSON.stringify(redemption)).not.toContain('raw-provider-detail'); + expect(JSON.stringify(redemption)).not.toContain('provider rejected'); + }); + + it.each([ + 'https://github.com/acme/repo.git/info/lfs/objects/batch', + 'https://github.com/acme/repo.git/info/lfs/locks/verify', + ])('redeems an installation-pinned capability for exact LFS control URL %s', async requestUrl => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + const redemption = await service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'POST', + requestUrl, + }); + + expect(redemption).toEqual({ + success: true, + authorization: `Basic ${Buffer.from('x-access-token:installation-token').toString('base64')}`, + }); + expect(serviceMocks.getTokenForRepo).toHaveBeenCalledOnce(); + }); + + it.each([ + ['GET', 'https://github.com/acme/repo.git/info/lfs/objects/batch', 'invalid_upstream_request'], + [ + 'POST', + 'https://github.com/acme/repo.git/info/lfs/objects/batch?operation=upload', + 'invalid_upstream_request', + ], + ['POST', 'https://github.com/acme/other.git/info/lfs/objects/batch', 'repository_mismatch'], + ['POST', 'https://github.com/acme/repo.git/info/lfs/locks', 'invalid_upstream_request'], + ] as const)( + 'rejects unsupported LFS control request %s %s', + async (requestMethod, requestUrl, reason) => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['POST', 'https://api.github.com/repos/acme/repo/issues/42/comments'], + ['PATCH', 'https://api.github.com/repos/acme/repo/issues/comments/123'], + ['POST', 'https://api.github.com/repos/acme/repo/pulls/42/reviews'], + ] as const)( + 'redeems a user-pinned capability for review API request %s %s', + async (requestMethod, requestUrl) => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.selectUserAuthorization.mockClear(); + serviceMocks.selectUserAuthorization.mockResolvedValueOnce({ + selected: true, + token: 'refreshed-user-token', + gitAuthor: { name: 'octocat', email: '1+octocat@users.noreply.github.com' }, + }); + + const redemption = await service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }); + + expect(redemption).toEqual({ success: true, authorization: 'Bearer refreshed-user-token' }); + expect(serviceMocks.selectUserAuthorization).toHaveBeenCalledOnce(); + } + ); + + it('redeems a user-pinned capability for its pull-request REST diff endpoint', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.selectUserAuthorization.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ success: true, authorization: 'Bearer user-token' }); + expect(serviceMocks.selectUserAuthorization).toHaveBeenCalledOnce(); + }); + + it.each([ + 'https://api.github.com/user/repos', + 'https://api.github.com/repos/acme/other/issues/42/comments', + 'https://api.github.com/graphql', + ])( + 'does not redeem a GitHub capability for an API request outside its repository: %s', + async requestUrl => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.selectUserAuthorization.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'POST', + requestUrl, + }) + ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['GET', 'https://github.com/acme/other.git/info/refs?service=git-upload-pack'], + ['POST', 'https://github.com/acme/other.git/git-receive-pack'], + ] as const)( + 'does not redeem a selected-user capability for another Git repository via %s %s', + async (requestMethod, requestUrl) => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + expect(issued.source).toBe('user'); + serviceMocks.selectUserAuthorization.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason: 'repository_mismatch' }); + expect(serviceMocks.selectUserAuthorization).not.toHaveBeenCalled(); + } + ); + + it.each([ + [ + 'GET', + 'http://github.com/acme/repo.git/info/refs?service=git-upload-pack', + 'invalid_upstream_url', + ], + [ + 'GET', + 'https://attacker@github.com/acme/repo.git/info/refs?service=git-upload-pack', + 'invalid_upstream_url', + ], + [ + 'GET', + 'https://github.com.evil.example/acme/repo.git/info/refs?service=git-upload-pack', + 'upstream_host_not_allowed', + ], + [ + 'GET', + 'https://gitlab.com/acme/repo.git/info/refs?service=git-upload-pack', + 'upstream_host_not_allowed', + ], + [ + 'GET', + 'https://github.com/acme/other.git/info/refs?service=git-upload-pack', + 'repository_mismatch', + ], + ['GET', 'https://github.com/acme/repo/settings', 'invalid_upstream_request'], + ['GET', 'https://github.com/acme/repo.git/info/refs', 'invalid_upstream_request'], + [ + 'POST', + 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + 'invalid_upstream_request', + ], + ['GET', 'https://github.com/acme/repo.git/git-receive-pack', 'invalid_upstream_request'], + ['CONNECT', 'https://api.github.com/user/repos', 'invalid_upstream_request'], + ['PATCH', 'https://api.github.com/repos/acme/repo/pulls/42', 'invalid_upstream_request'], + ['PUT', 'https://api.github.com/repos/acme/repo/pulls/42/merge', 'invalid_upstream_request'], + ['GET', 'https://api.github.com/repos/acme/repo/actions/variables', 'invalid_upstream_request'], + [ + 'POST', + 'https://api.github.com/repos/acme/repo/issues/42%2F..%2F43/comments', + 'invalid_upstream_url', + ], + ] as const)( + 'rejects unsafe upstream request %s %s without forwarding authorization', + async (requestMethod, requestUrl, reason) => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + } + ); + + it('rejects user-source redemption rather than falling back to installation authorization', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.selectUserAuthorization.mockResolvedValueOnce({ + selected: false, + reason: 'no_user_authorization', + }); + serviceMocks.getTokenForRepo.mockClear(); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ success: false, reason: 'source_unavailable' }); + expect(serviceMocks.getTokenForRepo).not.toHaveBeenCalled(); + }); + + it('rejects a user capability if selected attribution identity changes before redemption', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + allowUserAuthorization: true, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.selectUserAuthorization.mockResolvedValueOnce({ + selected: true, + token: 'refreshed-other-user-token', + gitAuthor: { name: 'another-user', email: '2+another-user@users.noreply.github.com' }, + }); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://api.github.com/repos/acme/repo/pulls/42', + }) + ).resolves.toEqual({ success: false, reason: 'identity_mismatch' }); + }); + + it('rejects an installation capability if the resolved installation identity changes', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findManagedInstallationForRepo.mockResolvedValueOnce({ + success: true, + installationId: '456', + accountLogin: 'acme', + githubAppType: 'standard', + repoName: 'repo', + permissions: { contents: 'write', pull_requests: 'write' }, + }); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://github.com/acme/repo.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ success: false, reason: 'identity_mismatch' }); + }); + + it('requires the outbound handler to redeem redirected requests again before forwarding auth', async () => { + const service = createService(); + const issued = await service.issueGitHubSessionCapability({ + githubRepo: 'acme/repo', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + + await expect( + service.redeemGitHubSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://redirect.example.com/acme/repo.git/info/refs?service=git-upload-pack', + }) + ).resolves.toEqual({ success: false, reason: 'upstream_host_not_allowed' }); + }); +}); + +describe('GitTokenRPCEntrypoint GitLab session capability RPCs', () => { + beforeEach(() => { + vi.clearAllMocks(); + serviceMocks.findGitLabIntegration.mockResolvedValue({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'gitlab-oauth-token', + auth_type: 'oauth', + }, + }); + serviceMocks.getGitLabToken.mockResolvedValue({ + success: true, + token: 'gitlab-oauth-token', + instanceUrl: 'https://gitlab.com', + }); + }); + + it.each([ + ['https://gitlab.com/acme/widgets.git', 'https://gitlab.com', 'gitlab.com', 'acme/widgets'], + [ + 'https://gitlab.example.com/acme/platform/widgets.git', + 'https://gitlab.example.com', + 'gitlab.example.com', + 'acme/platform/widgets', + ], + ])( + 'issues an opaque GitLab capability for %s', + async (gitUrl, instanceUrl, instanceHost, projectPath) => { + serviceMocks.findGitLabIntegration.mockResolvedValueOnce({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'gitlab-oauth-token', + auth_type: 'oauth', + ...(instanceUrl !== 'https://gitlab.com' ? { gitlab_instance_url: instanceUrl } : {}), + }, + }); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'gitlab-oauth-token', + instanceUrl, + }); + + const result = await createService().issueGitLabSessionCapability({ + gitUrl, + userId: 'user_1', + outboundContainerId, + }); + + expect(result).toMatchObject({ + success: true, + instanceOrigin: instanceUrl, + instanceHost, + projectPath, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + authType: 'oauth', + identity: { accountId: '42', accountLogin: 'octocat' }, + }); + if (!result.success) throw new Error('Expected successful issuance'); + expect(result.capability).toMatch(/^kgl2\./); + expect(JSON.stringify(result)).not.toContain('gitlab-oauth-token'); + expect(result).not.toHaveProperty('token'); + } + ); + + it('does not redeem a capability from another outbound container or resolve its source', async () => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockClear(); + serviceMocks.getGitLabToken.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId: 'another-outbound-container', + requestMethod: 'GET', + requestUrl: 'https://gitlab.com/api/v4/projects', + }) + ).resolves.toEqual({ success: false, reason: 'container_mismatch' }); + expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); + expect(serviceMocks.getGitLabToken).not.toHaveBeenCalled(); + }); + + it('does not redeem a bound capability without an outbound container or resolve its source', async () => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockClear(); + serviceMocks.getGitLabToken.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + requestMethod: 'GET', + requestUrl: 'https://gitlab.com/api/v4/projects', + }) + ).resolves.toEqual({ success: false, reason: 'container_mismatch' }); + expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); + expect(serviceMocks.getGitLabToken).not.toHaveBeenCalled(); + }); + + it('temporarily issues and redeems a legacy unbound GitLab capability for an old caller', async () => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + expect(issued.capability).toMatch(/^kgl1\./); + serviceMocks.findGitLabIntegration.mockClear(); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'refreshed-gitlab-token', + instanceUrl: 'https://gitlab.com', + }); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + requestMethod: 'GET', + requestUrl: 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/merge_requests/42/changes', + }) + ).resolves.toEqual({ + success: true, + headers: { authorization: 'Bearer refreshed-gitlab-token' }, + }); + expect(serviceMocks.findGitLabIntegration).toHaveBeenCalledOnce(); + expect(serviceMocks.getGitLabToken).toHaveBeenCalledTimes(2); + }); + + it('issues an opaque project-source capability for a code-review repository without exposing its token', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ id: 42 }))); + serviceMocks.findAuthorizedGitLabIntegrations.mockResolvedValueOnce({ + success: true, + integrations: [ + { + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + metadata: { + access_token: 'gitlab-oauth-token', + auth_type: 'oauth', + project_tokens: { '42': { token: 'project-access-token' } }, + }, + }, + ], + }); + serviceMocks.findGitLabIntegration.mockResolvedValueOnce({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'gitlab-oauth-token', + auth_type: 'oauth', + project_tokens: { '42': { token: 'project-access-token' } }, + }, + }); + + const result = await createService().issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + createdOnPlatform: 'code-review', + }); + + expect(result).toMatchObject({ + success: true, + source: { + type: 'project', + projectId: 42, + tokenDigest: 'f30b0bf364d41460c0119e521d2af8ae7eeacca9745981678d58b07b13c94edf', + }, + glabIsOAuth2: false, + }); + if (!result.success) throw new Error('Expected successful issuance'); + expect(result.capability).toMatch(/^kgl2\./); + expect(JSON.stringify(result)).not.toContain('project-access-token'); + expect(result).not.toHaveProperty('token'); + }); + + it.each([ + [ + 'GET', + 'https://gitlab.com/api/v4/projects/42/merge_requests/42/changes', + { 'PRIVATE-TOKEN': 'project-access-token' }, + ], + [ + 'GET', + 'https://gitlab.com/acme/widgets.git/info/refs?service=git-upload-pack', + { authorization: `Basic ${Buffer.from('oauth2:project-access-token').toString('base64')}` }, + ], + ] as const)( + 'redeems a project-source capability server-side for %s %s', + async (requestMethod, requestUrl, headers) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ id: 42 }))); + const projectIntegration = { + success: true as const, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'gitlab-oauth-token', + auth_type: 'oauth' as const, + project_tokens: { '42': { token: 'project-access-token' } }, + }, + }; + serviceMocks.findAuthorizedGitLabIntegrations.mockResolvedValueOnce({ + success: true, + integrations: [projectIntegration], + }); + serviceMocks.findGitLabIntegration.mockResolvedValue(projectIntegration); + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + createdOnPlatform: 'code-review', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getGitLabToken.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: true, headers }); + expect(serviceMocks.getGitLabToken).not.toHaveBeenCalled(); + } + ); + + it('fails closed when a project-source capability token is rotated', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ id: 42 }))); + const projectIntegration = { + success: true as const, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'gitlab-oauth-token', + auth_type: 'oauth' as const, + project_tokens: { '42': { token: 'project-access-token' } }, + }, + }; + serviceMocks.findAuthorizedGitLabIntegrations.mockResolvedValueOnce({ + success: true, + integrations: [projectIntegration], + }); + serviceMocks.findGitLabIntegration.mockResolvedValueOnce(projectIntegration); + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + createdOnPlatform: 'code-review', + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockResolvedValueOnce({ + ...projectIntegration, + metadata: { + ...projectIntegration.metadata, + project_tokens: { '42': { token: 'rotated-project-access-token' } }, + }, + }); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://gitlab.com/api/v4/projects/42/merge_requests/42/changes', + }) + ).resolves.toEqual({ success: false, reason: 'source_unavailable' }); + }); + + it.each([ + [ + 'GET', + 'https://gitlab.com/acme/widgets.git/info/refs?service=git-upload-pack', + { authorization: `Basic ${Buffer.from('oauth2:refreshed-gitlab-pat').toString('base64')}` }, + ], + [ + 'GET', + 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/merge_requests/42/changes', + { authorization: 'Bearer refreshed-gitlab-pat' }, + ], + ] as const)( + 'redeems an ordinary PAT-source capability server-side for %s %s', + async (requestMethod, requestUrl, headers) => { + serviceMocks.findGitLabIntegration.mockResolvedValue({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'pat', + accountId: '42', + accountLogin: 'octocat', + metadata: { access_token: 'gitlab-pat-token', auth_type: 'pat' }, + }); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'gitlab-pat-token', + instanceUrl: 'https://gitlab.com', + }); + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'refreshed-gitlab-pat', + instanceUrl: 'https://gitlab.com', + }); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: true, headers }); + } + ); + + it.each([ + [ + 'GET', + 'https://gitlab.example.com:8443/gitlab/acme/platform/widgets.git/info/refs?service=git-upload-pack', + { + authorization: `Basic ${Buffer.from('oauth2:refreshed-self-managed-token').toString('base64')}`, + }, + ], + [ + 'GET', + 'https://gitlab.example.com:8443/gitlab/api/v4/projects/acme%2Fplatform%2Fwidgets/merge_requests/42/changes', + { authorization: 'Bearer refreshed-self-managed-token' }, + ], + ] as const)( + 'issues and redeems a nested self-managed GitLab capability for %s %s', + async (requestMethod, requestUrl, headers) => { + serviceMocks.findGitLabIntegration.mockResolvedValue({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'self-managed-token', + auth_type: 'oauth', + gitlab_instance_url: 'https://gitlab.example.com:8443/gitlab', + }, + }); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'self-managed-token', + instanceUrl: 'https://gitlab.example.com:8443/gitlab', + }); + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.example.com:8443/gitlab/acme/platform/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'refreshed-self-managed-token', + instanceUrl: 'https://gitlab.example.com:8443/gitlab', + }); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: true, headers }); + } + ); + + it.each([ + ['https://gitlab.example.com:8443/api/v4/projects/42/issues', 'invalid_upstream_request'], + [ + 'https://gitlab.example.com:8443/acme/platform/widgets.git/info/refs?service=git-upload-pack', + 'repository_mismatch', + ], + ] as const)('rejects self-managed base-path escape %s', async (requestUrl, reason) => { + serviceMocks.findGitLabIntegration.mockResolvedValue({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'self-managed-token', + auth_type: 'oauth', + gitlab_instance_url: 'https://gitlab.example.com:8443/gitlab', + }, + }); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'self-managed-token', + instanceUrl: 'https://gitlab.example.com:8443/gitlab', + }); + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.example.com:8443/gitlab/acme/platform/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'https://sibling.example.com/acme/platform/widgets.git/info/refs?service=git-upload-pack', + 'upstream_origin_not_allowed', + ], + [ + 'https://gitlab.example.com/acme/platform/sibling.git/info/refs?service=git-upload-pack', + 'repository_mismatch', + ], + ] as const)('rejects self-managed sibling scope %s', async (requestUrl, reason) => { + serviceMocks.findGitLabIntegration.mockResolvedValue({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'oauth', + accountId: '42', + accountLogin: 'octocat', + metadata: { + access_token: 'self-managed-token', + auth_type: 'oauth', + gitlab_instance_url: 'https://gitlab.example.com', + }, + }); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'self-managed-token', + instanceUrl: 'https://gitlab.example.com', + }); + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.example.com/acme/platform/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); + }); + + it('returns a sanitized declared failure when the capability key is invalid', async () => { + const service = new GitTokenRPCEntrypoint( + {} as ExecutionContext, + { + SCM_SESSION_CAPABILITY_ENCRYPTION_KEY: 'not-a-valid-key', + } as unknown as CloudflareEnv + ); + + await expect( + service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }) + ).resolves.toEqual({ success: false, reason: 'capability_configuration_error' }); + }); + + it('does not expose a PAT during issuance', async () => { + serviceMocks.findGitLabIntegration.mockResolvedValue({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'pat', + accountId: '42', + accountLogin: 'octocat', + metadata: { access_token: 'gitlab-pat-token', auth_type: 'pat' }, + }); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'gitlab-pat-token', + instanceUrl: 'https://gitlab.com', + }); + + const result = await createService().issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + + expect(result).toMatchObject({ success: true, authType: 'pat' }); + expect(JSON.stringify(result)).not.toContain('gitlab-pat-token'); + }); + + it.each([ + ['GET', 'https://gitlab.com/acme/widgets.git/info/refs?service=git-upload-pack', 'Basic'], + ['GET', 'https://gitlab.com/acme/widgets.git/info/refs?service=git-receive-pack', 'Basic'], + ['POST', 'https://gitlab.com/acme/widgets.git/git-upload-pack', 'Basic'], + ['POST', 'https://gitlab.com/acme/widgets.git/git-receive-pack', 'Basic'], + ['POST', 'https://gitlab.com/acme/widgets.git/info/lfs/objects/batch', 'Basic'], + ['POST', 'https://gitlab.com/acme/widgets.git/info/lfs/locks/verify', 'Basic'], + [ + 'GET', + 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/merge_requests/42/changes', + 'Bearer', + ], + ['POST', 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/merge_requests/42/notes', 'Bearer'], + [ + 'PUT', + 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/merge_requests/42/notes/123', + 'Bearer', + ], + [ + 'POST', + 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/merge_requests/42/discussions', + 'Bearer', + ], + ] as const)('redeems allowed GitLab request %s %s', async (requestMethod, requestUrl, scheme) => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockClear(); + serviceMocks.getGitLabToken.mockResolvedValueOnce({ + success: true, + token: 'refreshed-gitlab-token', + instanceUrl: 'https://gitlab.com', + }); + + const result = await service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }); + + const authorization = + scheme === 'Basic' + ? `Basic ${Buffer.from('oauth2:refreshed-gitlab-token').toString('base64')}` + : 'Bearer refreshed-gitlab-token'; + expect(result).toEqual({ success: true, headers: { authorization } }); + expect(serviceMocks.findGitLabIntegration).toHaveBeenCalledWith( + { userId: 'user_1' }, + 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c' + ); + }); + + it.each([ + ['GET', 'https://gitlab.com/api/v4/projects?membership=true', 'invalid_upstream_request'], + ['POST', 'https://gitlab.com/api/graphql', 'invalid_upstream_request'], + ['GET', 'https://gitlab.com/api/v4/projects/acme%2Fother/issues', 'repository_mismatch'], + ] as const)( + 'does not redeem a GitLab capability for an API request outside its project: %s %s', + async (requestMethod, requestUrl, reason) => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); + } + ); + + it.each([ + [ + 'GET', + 'https://other.example.com/acme/widgets.git/info/refs?service=git-upload-pack', + 'upstream_origin_not_allowed', + ], + [ + 'GET', + 'https://gitlab.com/acme/other.git/info/refs?service=git-upload-pack', + 'repository_mismatch', + ], + ['GET', 'https://gitlab.com/acme/widgets/settings', 'invalid_upstream_request'], + ['GET', 'https://gitlab.com/oauth/authorize', 'invalid_upstream_request'], + ['GET', 'https://gitlab.com/users/sign_in', 'invalid_upstream_request'], + [ + 'GET', + 'https://gitlab.com/acme%2Fwidgets.git/info/refs?service=git-upload-pack', + 'invalid_upstream_url', + ], + ['CONNECT', 'https://gitlab.com/api/v4/projects', 'invalid_upstream_request'], + [ + 'GET', + 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/variables', + 'invalid_upstream_request', + ], + [ + 'PUT', + 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/merge_requests/42/merge', + 'invalid_upstream_request', + ], + ] as const)('rejects unsafe GitLab request %s %s', async (requestMethod, requestUrl, reason) => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod, + requestUrl, + }) + ).resolves.toEqual({ success: false, reason }); + expect(serviceMocks.findGitLabIntegration).not.toHaveBeenCalled(); + }); + + it('fails closed if the pinned GitLab integration disappears', async () => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockResolvedValueOnce({ + success: false, + reason: 'no_integration_found', + }); + serviceMocks.getGitLabToken.mockClear(); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/merge_requests/42/changes', + }) + ).resolves.toEqual({ success: false, reason: 'source_unavailable' }); + expect(serviceMocks.getGitLabToken).not.toHaveBeenCalled(); + }); + + it('fails closed if the pinned GitLab integration source identity drifts', async () => { + const service = createService(); + const issued = await service.issueGitLabSessionCapability({ + gitUrl: 'https://gitlab.com/acme/widgets.git', + userId: 'user_1', + outboundContainerId, + }); + if (!issued.success) throw new Error('Expected successful issuance'); + serviceMocks.findGitLabIntegration.mockResolvedValueOnce({ + success: true, + integrationId: 'ef2eb5c7-27ce-4f43-b6d3-8f282abc145c', + integrationType: 'pat', + accountId: '42', + accountLogin: 'octocat', + metadata: { access_token: 'gitlab-pat-token', auth_type: 'pat' }, + }); + + await expect( + service.redeemGitLabSessionCapability({ + capability: issued.capability, + outboundContainerId, + requestMethod: 'GET', + requestUrl: 'https://gitlab.com/api/v4/projects/acme%2Fwidgets/merge_requests/42/changes', + }) + ).resolves.toEqual({ success: false, reason: 'identity_mismatch' }); + expect(serviceMocks.getGitLabToken).toHaveBeenCalledOnce(); }); }); diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index ac0e53fc0f..33ab20a96c 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -1,14 +1,39 @@ +import { timingSafeEqual } from '@kilocode/encryption'; import { extractBearerToken, verifyKiloToken } from '@kilocode/worker-utils'; import { WorkerEntrypoint } from 'cloudflare:workers'; import { GitHubTokenService, type GitHubAppType } from './github-token-service.js'; -import { GitLabLookupService } from './gitlab-lookup-service.js'; +import { GitLabLookupService, type GitLabLookupSuccess } from './gitlab-lookup-service.js'; import { resolveGitLabRuntimeToken, type GetGitLabTokenParams, + type GetGitLabTokenFailure, type GetGitLabTokenResult, } from './gitlab-runtime-token-resolver.js'; +import { DEFAULT_GITLAB_INSTANCE_URL } from './gitlab-constants.js'; +import { + GitLabSessionCapabilityCodec, + GitLabSessionCapabilityError, + sha256Digest, + type GitLabAuthType, + type GitLabCapabilityCredentialSource, + type GitLabSessionCapabilityFailureReason, + type GitLabSessionIdentity, +} from './gitlab-session-capability.js'; +import { + normalizeGitLabInstanceUrl, + parseGitLabBaseUrl, + parseGitLabCloneUrl, + type GitLabCloneUrlFailureReason, +} from './gitlab-url.js'; import { GitLabTokenService } from './gitlab-token-service.js'; import { InstallationLookupService } from './installation-lookup-service.js'; +import { + GitHubSessionCapabilityCodec, + GitHubSessionCapabilityError, + normalizeGitHubRepository, + type GitHubSessionCapabilityFailureReason, + type GitHubSessionIdentity, +} from './github-session-capability.js'; import { GitHubUserAuthorizationService, type GitAuthorConfig, @@ -69,16 +94,267 @@ export type GetCloudAgentAuthForRepoResult = | GetCloudAgentAuthForRepoSuccess | GetTokenForRepoFailure; +export type IssueGitHubSessionCapabilityParams = GetCloudAgentAuthForRepoParams & { + outboundContainerId?: string; +}; +export type IssueGitHubSessionCapabilitySuccess = Omit< + GetCloudAgentAuthForRepoSuccess, + 'githubToken' +> & { + capability: string; +}; +export type IssueGitHubSessionCapabilityResult = + | IssueGitHubSessionCapabilitySuccess + | GetTokenForRepoFailure + | { success: false; reason: 'capability_configuration_error' }; + +export type RedeemGitHubSessionCapabilityParams = { + capability: string; + outboundContainerId?: string; + requestMethod: string; + requestUrl: string; +}; +export type RedeemGitHubSessionCapabilitySuccess = { + success: true; + authorization: string; +}; +export type RedeemGitHubSessionCapabilityFailureReason = + | GitHubSessionCapabilityFailureReason + | 'container_mismatch' + | 'invalid_upstream_url' + | 'upstream_host_not_allowed' + | 'repository_mismatch' + | 'invalid_upstream_request' + | 'source_unavailable' + | 'identity_mismatch'; +export type RedeemGitHubSessionCapabilityResult = + | RedeemGitHubSessionCapabilitySuccess + | { success: false; reason: RedeemGitHubSessionCapabilityFailureReason }; + +export type IssueGitLabSessionCapabilityParams = GetGitLabTokenParams & { + gitUrl: string; + outboundContainerId?: string; +}; +export type IssueGitLabSessionCapabilitySuccess = { + success: true; + capability: string; + instanceOrigin: string; + instanceHost: string; + projectPath: string; + integrationId: string; + authType: GitLabAuthType; + identity: GitLabSessionIdentity; + source: GitLabCapabilityCredentialSource; + glabIsOAuth2: boolean; +}; +export type IssueGitLabSessionCapabilityResult = + | IssueGitLabSessionCapabilitySuccess + | GetGitLabTokenFailure + | { success: false; reason: GitLabCloneUrlFailureReason | 'capability_configuration_error' }; +export type RedeemGitLabSessionCapabilityParams = { + capability: string; + outboundContainerId?: string; + requestMethod: string; + requestUrl: string; +}; +export type RedeemGitLabSessionCapabilityFailureReason = + | GitLabSessionCapabilityFailureReason + | 'container_mismatch' + | 'invalid_upstream_url' + | 'upstream_origin_not_allowed' + | 'repository_mismatch' + | 'invalid_upstream_request' + | 'source_unavailable' + | 'identity_mismatch'; +export type RedeemGitLabSessionCapabilityResult = + | { + success: true; + headers: + | { authorization: string; 'PRIVATE-TOKEN'?: never } + | { authorization?: never; 'PRIVATE-TOKEN': string }; + } + | { success: false; reason: RedeemGitLabSessionCapabilityFailureReason }; + const DISCONNECT_PATH = '/internal/github-user-authorizations/disconnect'; type DisconnectEnv = CloudflareEnv & { NEXTAUTH_SECRET: SecretsStoreSecret | string; }; -async function resolveJwtSecret(secret: SecretsStoreSecret | string): Promise { +async function resolveSecret(secret: SecretsStoreSecret | string): Promise { return typeof secret === 'string' ? secret : secret.get(); } +function validateGitHubCapabilityUpstream( + requestMethod: string, + requestUrl: string, + repository: { owner: string; repo: string } +): RedeemGitHubSessionCapabilityFailureReason | null { + if (/%2f|%5c/i.test(requestUrl) || /\/(?:(?:\.|%2e){1,2})(?:\/|$)/i.test(requestUrl)) { + return 'invalid_upstream_url'; + } + let url: URL; + try { + url = new URL(requestUrl); + } catch { + return 'invalid_upstream_url'; + } + if (url.protocol !== 'https:') return 'invalid_upstream_url'; + if (url.username || url.password || url.hash) return 'invalid_upstream_url'; + const method = requestMethod.toUpperCase(); + if (url.hostname === 'api.github.com' && url.port === '') { + if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(method)) { + return 'invalid_upstream_request'; + } + const repositoryApiPath = `/repos/${repository.owner}/${repository.repo}`; + const path = url.pathname.toLowerCase(); + if (path !== repositoryApiPath && !path.startsWith(`${repositoryApiPath}/`)) { + return 'repository_mismatch'; + } + const relativePath = path.slice(repositoryApiPath.length); + if ( + ['GET', 'HEAD'].includes(method) && + (/^\/pulls\/[1-9]\d*$/.test(relativePath) || + /^\/issues\/[1-9]\d*\/comments$/.test(relativePath) || + /^\/pulls\/[1-9]\d*\/(?:comments|reviews)$/.test(relativePath)) + ) { + return null; + } + if (method === 'POST' && /^\/issues\/[1-9]\d*\/comments$/.test(relativePath)) return null; + if (method === 'PATCH' && /^\/issues\/comments\/[1-9]\d*$/.test(relativePath)) return null; + if (method === 'POST' && /^\/pulls\/[1-9]\d*\/reviews$/.test(relativePath)) return null; + return 'invalid_upstream_request'; + } + if (url.hostname !== 'github.com' || url.port !== '') return 'upstream_host_not_allowed'; + + const repositoryPath = `/${repository.owner}/${repository.repo}.git`; + const path = url.pathname.toLowerCase(); + if (!path.startsWith(`/${repository.owner}/${repository.repo}`)) return 'repository_mismatch'; + + if (method === 'GET' && path === `${repositoryPath}/info/refs`) { + const entries = [...url.searchParams.entries()]; + const service = url.searchParams.get('service'); + if (entries.length === 1 && (service === 'git-upload-pack' || service === 'git-receive-pack')) { + return null; + } + } + if ( + method === 'POST' && + url.search === '' && + (path === `${repositoryPath}/git-upload-pack` || + path === `${repositoryPath}/git-receive-pack` || + path === `${repositoryPath}/info/lfs/objects/batch` || + path === `${repositoryPath}/info/lfs/locks/verify`) + ) { + return null; + } + return 'invalid_upstream_request'; +} + +function validateGitLabCapabilityUpstream( + requestMethod: string, + requestUrl: string, + session: { + instanceOrigin: string; + projectPath: string; + source: GitLabCapabilityCredentialSource; + } +): { failure: RedeemGitLabSessionCapabilityFailureReason | null; authSurface: 'git' | 'api' } { + if (/%5c/i.test(requestUrl) || /\/(?:(?:\.|%2e){1,2})(?:\/|$)/i.test(requestUrl)) { + return { failure: 'invalid_upstream_url', authSurface: 'git' }; + } + const base = parseGitLabBaseUrl(session.instanceOrigin); + if (!base) return { failure: 'invalid_upstream_url', authSurface: 'git' }; + let url: URL; + try { + url = new URL(requestUrl); + } catch { + return { failure: 'invalid_upstream_url', authSurface: 'git' }; + } + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.hash || + url.origin !== base.origin + ) { + return { + failure: url.origin !== base.origin ? 'upstream_origin_not_allowed' : 'invalid_upstream_url', + authSurface: 'git', + }; + } + const method = requestMethod.toUpperCase(); + const apiPrefix = `${base.basePath}/api/v4/`; + const projectApiPrefix = `${base.basePath}/api/v4/projects/`; + if (url.pathname.startsWith(projectApiPrefix)) { + const projectApiPath = url.pathname.slice(projectApiPrefix.length); + const [projectSelector, ...remainingSegments] = projectApiPath.split('/'); + if (!projectSelector || remainingSegments.some(segment => /%2f|%5c/i.test(segment))) { + return { failure: 'invalid_upstream_url', authSurface: 'api' }; + } + let decodedProjectSelector: string; + try { + decodedProjectSelector = decodeURIComponent(projectSelector); + } catch { + return { failure: 'invalid_upstream_url', authSurface: 'api' }; + } + const selectorMatches = + decodedProjectSelector === session.projectPath || + (session.source.type === 'project' && + decodedProjectSelector === String(session.source.projectId)); + const relativePath = remainingSegments.join('/'); + const allowed = + (['GET', 'HEAD'].includes(method) && + /^merge_requests\/[1-9]\d*(?:\/(?:changes|diffs|notes|discussions))?$/.test( + relativePath + )) || + (method === 'POST' && + /^merge_requests\/[1-9]\d*\/(?:notes|discussions)$/.test(relativePath)) || + (method === 'PUT' && /^merge_requests\/[1-9]\d*\/notes\/[1-9]\d*$/.test(relativePath)); + return { + failure: selectorMatches + ? allowed + ? null + : 'invalid_upstream_request' + : 'repository_mismatch', + authSurface: 'api', + }; + } + if (url.pathname === `${base.basePath}/api/graphql` || url.pathname.startsWith(apiPrefix)) { + return { failure: 'invalid_upstream_request', authSurface: 'api' }; + } + if (/%2f/i.test(requestUrl)) { + return { failure: 'invalid_upstream_url', authSurface: 'git' }; + } + + const repositoryPath = `${base.basePath}/${session.projectPath}.git`; + if (method === 'GET' && url.pathname === `${repositoryPath}/info/refs`) { + const entries = [...url.searchParams.entries()]; + const service = url.searchParams.get('service'); + if (entries.length === 1 && (service === 'git-upload-pack' || service === 'git-receive-pack')) { + return { failure: null, authSurface: 'git' }; + } + } + if ( + method === 'POST' && + url.search === '' && + (url.pathname === `${repositoryPath}/git-upload-pack` || + url.pathname === `${repositoryPath}/git-receive-pack` || + url.pathname === `${repositoryPath}/info/lfs/objects/batch` || + url.pathname === `${repositoryPath}/info/lfs/locks/verify`) + ) { + return { failure: null, authSurface: 'git' }; + } + const repositoryPrefix = `${base.basePath}/${session.projectPath}`; + return { + failure: + url.pathname.startsWith(repositoryPrefix) || !url.pathname.includes('.git/') + ? 'invalid_upstream_request' + : 'repository_mismatch', + authSurface: 'git', + }; +} + export class GitTokenRPCEntrypoint extends WorkerEntrypoint { private githubService: GitHubTokenService; private installationLookupService: InstallationLookupService; @@ -252,6 +528,148 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { }; } + async issueGitHubSessionCapability( + params: IssueGitHubSessionCapabilityParams + ): Promise { + const repository = normalizeGitHubRepository(params.githubRepo); + if (!repository) return { success: false, reason: 'invalid_repo_format' }; + + const auth = await this.getCloudAgentAuthForRepo({ + ...params, + githubRepo: `${repository.owner}/${repository.repo}`, + }); + if (!auth.success) return auth; + + let capability: string; + try { + const encryptionKey = await resolveSecret(this.env.SCM_SESSION_CAPABILITY_ENCRYPTION_KEY); + capability = new GitHubSessionCapabilityCodec(encryptionKey).issue({ + userId: params.userId, + ...(params.outboundContainerId !== undefined + ? { outboundContainerId: params.outboundContainerId } + : {}), + ...(params.orgId !== undefined ? { orgId: params.orgId } : {}), + ...repository, + source: auth.source, + identity: this.getSessionIdentity(auth), + }); + } catch { + return { success: false, reason: 'capability_configuration_error' }; + } + return { + success: true, + capability, + installationId: auth.installationId, + accountLogin: auth.accountLogin, + appType: auth.appType, + source: auth.source, + gitAuthor: auth.gitAuthor, + ...(auth.commitCoAuthor !== undefined ? { commitCoAuthor: auth.commitCoAuthor } : {}), + ...(auth.fallbackReason !== undefined ? { fallbackReason: auth.fallbackReason } : {}), + }; + } + + async redeemGitHubSessionCapability( + params: RedeemGitHubSessionCapabilityParams + ): Promise { + let claims; + try { + const encryptionKey = await resolveSecret(this.env.SCM_SESSION_CAPABILITY_ENCRYPTION_KEY); + claims = new GitHubSessionCapabilityCodec(encryptionKey).decode(params.capability); + } catch (error) { + if (error instanceof GitHubSessionCapabilityError) { + return { success: false, reason: error.reason }; + } + return { success: false, reason: 'capability_configuration_error' }; + } + + if (claims.version === 2 && claims.outboundContainerId !== params.outboundContainerId) { + return { success: false, reason: 'container_mismatch' }; + } + + const upstreamFailure = validateGitHubCapabilityUpstream( + params.requestMethod, + params.requestUrl, + claims + ); + if (upstreamFailure) return { success: false, reason: upstreamFailure }; + + const authParams = { + userId: claims.userId, + ...(claims.orgId !== undefined ? { orgId: claims.orgId } : {}), + githubRepo: `${claims.owner}/${claims.repo}`, + }; + let auth: GetCloudAgentAuthForRepoResult | null; + if (claims.source === 'user') { + auth = await this.redeemPinnedUserAuthorization(authParams); + } else { + try { + auth = await this.getCloudAgentAuthForRepo(authParams); + } catch { + return { success: false, reason: 'source_unavailable' }; + } + } + if (!auth || !auth.success || auth.source !== claims.source) { + return { success: false, reason: 'source_unavailable' }; + } + if (!this.matchesSessionIdentity(claims.identity, auth)) { + return { success: false, reason: 'identity_mismatch' }; + } + return { + success: true, + authorization: this.formatUpstreamAuthorization(params.requestUrl, auth.githubToken), + }; + } + + private getSessionIdentity(auth: GetCloudAgentAuthForRepoSuccess): GitHubSessionIdentity { + return { + installationId: auth.installationId, + accountLogin: auth.accountLogin, + appType: auth.appType, + gitAuthor: auth.gitAuthor, + ...(auth.commitCoAuthor !== undefined ? { commitCoAuthor: auth.commitCoAuthor } : {}), + }; + } + + private matchesSessionIdentity( + issuedIdentity: GitHubSessionIdentity, + auth: GetCloudAgentAuthForRepoSuccess + ): boolean { + return JSON.stringify(issuedIdentity) === JSON.stringify(this.getSessionIdentity(auth)); + } + + private formatUpstreamAuthorization(requestUrl: string, token: string): string { + return new URL(requestUrl).hostname === 'github.com' + ? `Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}` + : `Bearer ${token}`; + } + + private async redeemPinnedUserAuthorization( + params: GetTokenForRepoParams + ): Promise { + const installation = + await this.installationLookupService.findManagedInstallationForRepo(params); + if (!installation.success || installation.githubAppType === 'lite') return null; + if ( + installation.permissions?.contents !== 'write' || + installation.permissions?.pull_requests !== 'write' + ) { + return null; + } + const selection = await this.githubUserAuthorizationService.selectUserAuthorization(params); + if (!selection.selected) return null; + return { + success: true, + githubToken: selection.token, + installationId: installation.installationId, + accountLogin: installation.accountLogin, + appType: installation.githubAppType, + source: 'user', + gitAuthor: selection.gitAuthor, + commitCoAuthor: this.getInstallationAuthor(installation.githubAppType), + }; + } + private getInstallationAuthor(appType: GitHubAppType): GitAuthorConfig { const slug = appType === 'lite' @@ -295,6 +713,157 @@ export class GitTokenRPCEntrypoint extends WorkerEntrypoint { tokenService: this.gitlabTokenService, }); } + + async issueGitLabSessionCapability( + params: IssueGitLabSessionCapabilityParams + ): Promise { + const runtimeToken = await resolveGitLabRuntimeToken( + { ...params, repositoryUrl: params.gitUrl }, + { + lookupService: this.gitlabLookupService, + tokenService: this.gitlabTokenService, + } + ); + if (!runtimeToken.success) return runtimeToken; + + const integration = await this.gitlabLookupService.findGitLabIntegration( + params, + runtimeToken.integrationId + ); + if (!integration.success) return integration; + const authType = this.getGitLabAuthType(integration); + if (!authType) return { success: false, reason: 'no_token' }; + const instanceOrigin = normalizeGitLabInstanceUrl(runtimeToken.instanceUrl); + if (!instanceOrigin) return { success: false, reason: 'unsupported_gitlab_instance' }; + const repository = parseGitLabCloneUrl(params.gitUrl, instanceOrigin); + if (!repository.success) return repository; + const identity = this.getGitLabSessionIdentity(integration); + if (!identity) return { success: false, reason: 'no_token' }; + + let capability: string; + try { + const encryptionKey = await resolveSecret(this.env.SCM_SESSION_CAPABILITY_ENCRYPTION_KEY); + capability = new GitLabSessionCapabilityCodec(encryptionKey).issue({ + userId: params.userId, + ...(params.outboundContainerId !== undefined + ? { outboundContainerId: params.outboundContainerId } + : {}), + ...(params.orgId !== undefined ? { orgId: params.orgId } : {}), + integrationId: integration.integrationId, + instanceOrigin: repository.instanceOrigin, + projectPath: repository.projectPath, + authType, + identity, + source: runtimeToken.source, + }); + } catch { + return { success: false, reason: 'capability_configuration_error' }; + } + return { + success: true, + capability, + instanceOrigin: repository.instanceOrigin, + instanceHost: repository.instanceHost, + projectPath: repository.projectPath, + integrationId: integration.integrationId, + authType, + identity, + source: runtimeToken.source, + glabIsOAuth2: runtimeToken.glabIsOAuth2, + }; + } + + async redeemGitLabSessionCapability( + params: RedeemGitLabSessionCapabilityParams + ): Promise { + let claims; + try { + const encryptionKey = await resolveSecret(this.env.SCM_SESSION_CAPABILITY_ENCRYPTION_KEY); + claims = new GitLabSessionCapabilityCodec(encryptionKey).decode(params.capability); + } catch (error) { + if (error instanceof GitLabSessionCapabilityError) { + return { success: false, reason: error.reason }; + } + return { success: false, reason: 'capability_configuration_error' }; + } + + if (claims.version === 2 && claims.outboundContainerId !== params.outboundContainerId) { + return { success: false, reason: 'container_mismatch' }; + } + + const upstream = validateGitLabCapabilityUpstream( + params.requestMethod, + params.requestUrl, + claims + ); + if (upstream.failure) return { success: false, reason: upstream.failure }; + const context = { + userId: claims.userId, + ...(claims.orgId !== undefined ? { orgId: claims.orgId } : {}), + }; + const integration = await this.gitlabLookupService.findGitLabIntegration( + context, + claims.integrationId + ); + if (!integration.success) return { success: false, reason: 'source_unavailable' }; + const authType = this.getGitLabAuthType(integration); + const identity = this.getGitLabSessionIdentity(integration); + if ( + authType !== claims.authType || + !identity || + JSON.stringify(identity) !== JSON.stringify(claims.identity) + ) { + return { success: false, reason: 'identity_mismatch' }; + } + const currentInstanceOrigin = normalizeGitLabInstanceUrl( + integration.metadata.gitlab_instance_url ?? DEFAULT_GITLAB_INSTANCE_URL + ); + if (currentInstanceOrigin !== claims.instanceOrigin) { + return { success: false, reason: 'identity_mismatch' }; + } + + let token: string; + if (claims.source.type === 'integration') { + const integrationToken = await this.gitlabTokenService.getToken( + integration.integrationId, + integration.metadata + ); + if (!integrationToken.success) return { success: false, reason: 'source_unavailable' }; + token = integrationToken.token; + } else { + const projectToken = integration.metadata.project_tokens?.[String(claims.source.projectId)]; + if (!projectToken) return { success: false, reason: 'source_unavailable' }; + const currentTokenDigest = await sha256Digest(projectToken.token); + if (!timingSafeEqual(currentTokenDigest, claims.source.tokenDigest)) { + return { success: false, reason: 'source_unavailable' }; + } + token = projectToken.token; + } + + if (upstream.authSurface === 'git') { + return { + success: true, + headers: { authorization: `Basic ${Buffer.from(`oauth2:${token}`).toString('base64')}` }, + }; + } + if (claims.source.type === 'project') { + return { success: true, headers: { 'PRIVATE-TOKEN': token } }; + } + return { success: true, headers: { authorization: `Bearer ${token}` } }; + } + + private getGitLabAuthType(integration: GitLabLookupSuccess): GitLabAuthType | null { + if (integration.metadata.auth_type) return integration.metadata.auth_type; + if (integration.integrationType === 'oauth' || integration.integrationType === 'pat') { + return integration.integrationType; + } + return null; + } + + private getGitLabSessionIdentity(integration: GitLabLookupSuccess): GitLabSessionIdentity | null { + if (integration.accountId === null && integration.accountLogin === null) return null; + return { accountId: integration.accountId, accountLogin: integration.accountLogin }; + } } export default { @@ -308,7 +877,7 @@ export default { let secret: string; try { - secret = await resolveJwtSecret(env.NEXTAUTH_SECRET); + secret = await resolveSecret(env.NEXTAUTH_SECRET); } catch { return Response.json({ error: 'authentication_unavailable' }, { status: 503 }); } diff --git a/services/git-token-service/worker-configuration.d.ts b/services/git-token-service/worker-configuration.d.ts index c0740b945a..a7e4a86425 100644 --- a/services/git-token-service/worker-configuration.d.ts +++ b/services/git-token-service/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv worker-configuration.d.ts` (hash: 17f2708d42b72c79fdef7a53b8c646bf) +// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv worker-configuration.d.ts` (hash: 845f65551893477d27bd8781d3b8d187) // Runtime types generated with workerd@1.20260508.1 2025-09-15 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -9,6 +9,7 @@ declare namespace Cloudflare { TOKEN_CACHE: KVNamespace; HYPERDRIVE: Hyperdrive; NEXTAUTH_SECRET: SecretsStoreSecret; + SCM_SESSION_CAPABILITY_ENCRYPTION_KEY: SecretsStoreSecret; GITHUB_APP_SLUG: "kiloconnect-development"; GITHUB_APP_BOT_USER_ID: "242397087"; GITHUB_LITE_APP_SLUG: ""; @@ -25,11 +26,13 @@ declare namespace Cloudflare { USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY: string; USER_GITHUB_APP_TOKEN_ACTIVE_PRIVATE_KEY: string; NEXTAUTH_SECRET: string; + SCM_SESSION_CAPABILITY_ENCRYPTION_KEY: string; } interface Env { TOKEN_CACHE: KVNamespace; HYPERDRIVE: Hyperdrive; NEXTAUTH_SECRET: SecretsStoreSecret | string; + SCM_SESSION_CAPABILITY_ENCRYPTION_KEY: SecretsStoreSecret | string; GITHUB_APP_SLUG: "kiloconnect-development" | "kiloconnect"; GITHUB_APP_BOT_USER_ID: "242397087" | "240665456"; GITHUB_LITE_APP_SLUG: "" | "kiloconnect-lite"; @@ -52,7 +55,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/services/git-token-service/wrangler.jsonc b/services/git-token-service/wrangler.jsonc index 40c87e5176..2a4cef6e44 100644 --- a/services/git-token-service/wrangler.jsonc +++ b/services/git-token-service/wrangler.jsonc @@ -37,6 +37,11 @@ "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", "secret_name": "NEXTAUTH_SECRET_PROD", }, + { + "binding": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY", + "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", + "secret_name": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY_PROD", + }, ], "dev": { "port": 8802, @@ -69,6 +74,11 @@ "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", "secret_name": "NEXTAUTH_SECRET_DEV", }, + { + "binding": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY", + "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", + "secret_name": "SCM_SESSION_CAPABILITY_ENCRYPTION_KEY_DEV", + }, ], }, }, From a67a4a8214e4b4fa6bf332f33be1f81e5e955292 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Wed, 3 Jun 2026 16:45:46 +0200 Subject: [PATCH 2/4] test(git-token-service): expose capability RPC smoke routes --- .../git-token-service/test/test-worker.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/services/git-token-service/test/test-worker.ts b/services/git-token-service/test/test-worker.ts index 9901f2204d..12b23dec7b 100644 --- a/services/git-token-service/test/test-worker.ts +++ b/services/git-token-service/test/test-worker.ts @@ -12,6 +12,10 @@ * POST /getTokenForRepo - { githubRepo, userId, orgId? } * POST /getToken - { installationId, appType? } * POST /getGitLabToken - { userId, orgId?, repositoryUrl?, createdOnPlatform? } + * POST /issueGitHubSessionCapability + * POST /redeemGitHubSessionCapability + * POST /issueGitLabSessionCapability + * POST /redeemGitLabSessionCapability */ import type { GitTokenRPCEntrypoint, @@ -19,6 +23,14 @@ import type { GetTokenForRepoResult, GetGitLabTokenParams, GetGitLabTokenResult, + IssueGitHubSessionCapabilityParams, + IssueGitHubSessionCapabilityResult, + RedeemGitHubSessionCapabilityParams, + RedeemGitHubSessionCapabilityResult, + IssueGitLabSessionCapabilityParams, + IssueGitLabSessionCapabilityResult, + RedeemGitLabSessionCapabilityParams, + RedeemGitLabSessionCapabilityResult, } from '../src/index.js'; import type { GitHubAppType } from '../src/github-token-service.js'; @@ -58,6 +70,34 @@ export default { return Response.json(result); } + if (url.pathname === '/issueGitHubSessionCapability' && request.method === 'POST') { + const body = (await request.json()) as IssueGitHubSessionCapabilityParams; + const result: IssueGitHubSessionCapabilityResult = + await env.GIT_TOKEN_SERVICE.issueGitHubSessionCapability(body); + return Response.json(result, { status: result.success ? 200 : 400 }); + } + + if (url.pathname === '/redeemGitHubSessionCapability' && request.method === 'POST') { + const body = (await request.json()) as RedeemGitHubSessionCapabilityParams; + const result: RedeemGitHubSessionCapabilityResult = + await env.GIT_TOKEN_SERVICE.redeemGitHubSessionCapability(body); + return Response.json(result, { status: result.success ? 200 : 400 }); + } + + if (url.pathname === '/issueGitLabSessionCapability' && request.method === 'POST') { + const body = (await request.json()) as IssueGitLabSessionCapabilityParams; + const result: IssueGitLabSessionCapabilityResult = + await env.GIT_TOKEN_SERVICE.issueGitLabSessionCapability(body); + return Response.json(result, { status: result.success ? 200 : 400 }); + } + + if (url.pathname === '/redeemGitLabSessionCapability' && request.method === 'POST') { + const body = (await request.json()) as RedeemGitLabSessionCapabilityParams; + const result: RedeemGitLabSessionCapabilityResult = + await env.GIT_TOKEN_SERVICE.redeemGitLabSessionCapability(body); + return Response.json(result, { status: result.success ? 200 : 400 }); + } + return Response.json( { error: 'Not Found', @@ -65,6 +105,10 @@ export default { 'POST /getTokenForRepo - { githubRepo, userId, orgId? }', 'POST /getToken - { installationId, appType? }', 'POST /getGitLabToken - { userId, orgId?, repositoryUrl?, createdOnPlatform? }', + 'POST /issueGitHubSessionCapability', + 'POST /redeemGitHubSessionCapability', + 'POST /issueGitLabSessionCapability', + 'POST /redeemGitLabSessionCapability', ], }, { status: 404 } From c9080e226f2f37f23bb11505b4df05932ebce487 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Wed, 3 Jun 2026 20:12:27 +0200 Subject: [PATCH 3/4] fix(git-token-service): narrow GitHub REST method allowlist --- services/git-token-service/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 33ab20a96c..84f0f00ab8 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -203,7 +203,7 @@ function validateGitHubCapabilityUpstream( if (url.username || url.password || url.hash) return 'invalid_upstream_url'; const method = requestMethod.toUpperCase(); if (url.hostname === 'api.github.com' && url.port === '') { - if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(method)) { + if (!['GET', 'POST', 'PATCH', 'HEAD'].includes(method)) { return 'invalid_upstream_request'; } const repositoryApiPath = `/repos/${repository.owner}/${repository.repo}`; From 7df3d07aff8d13e167a12cb547cf72b169a0dfc6 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Wed, 3 Jun 2026 21:32:11 +0200 Subject: [PATCH 4/4] feat(git-token-service): extend SCM capability lifetimes --- .../src/github-session-capability.test.ts | 36 ++++++++++++++++-- .../src/github-session-capability.ts | 18 +++++++-- .../src/gitlab-session-capability.test.ts | 38 +++++++++++++++++-- .../src/gitlab-session-capability.ts | 18 +++++++-- 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/services/git-token-service/src/github-session-capability.test.ts b/services/git-token-service/src/github-session-capability.test.ts index 3d98c6f129..0cdf9ac328 100644 --- a/services/git-token-service/src/github-session-capability.test.ts +++ b/services/git-token-service/src/github-session-capability.test.ts @@ -49,12 +49,14 @@ describe('GitHubSessionCapabilityCodec', () => { source: 'user', identity: claims.identity, issuedAt: Date.parse('2026-05-30T12:00:00.000Z'), - expiresAt: Date.parse('2026-05-30T13:00:00.000Z'), + expiresAt: Date.parse('2026-05-30T16:00:00.000Z'), }); vi.useRealTimers(); }); - it('produces and decodes a legacy unbound v1 capability when the container is omitted', () => { + it('produces and decodes a two-hour legacy unbound v1 capability when the container is omitted', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-30T12:00:00.000Z')); const codec = new GitHubSessionCapabilityCodec(encryptionKey); const { outboundContainerId: _outboundContainerId, ...legacyClaims } = claims; @@ -66,20 +68,46 @@ describe('GitHubSessionCapabilityCodec', () => { userId: 'user_1', owner: 'acme', repo: 'widgets', + issuedAt: Date.parse('2026-05-30T12:00:00.000Z'), + expiresAt: Date.parse('2026-05-30T14:00:00.000Z'), }); expect(codec.decode(capability)).not.toHaveProperty('outboundContainerId'); + vi.useRealTimers(); }); + it.each([ + ['legacy unbound v1', 'kgh1.', 1, 2 * 60 * 60 * 1000, false], + ['container-bound v2', 'kgh2.', 2, 4 * 60 * 60 * 1000, true], + ] as const)( + 'rejects an overlong %s capability', + (_description, prefix, version, maximumLifetimeMs, bound) => { + const { outboundContainerId: _outboundContainerId, ...legacyClaims } = claims; + const issuedAt = Date.now(); + const serializedClaims = JSON.stringify({ + purpose: 'github_scm_session', + version, + ...(bound ? claims : legacyClaims), + issuedAt, + expiresAt: issuedAt + maximumLifetimeMs + 1, + }); + const capability = `${prefix}${encryptWithSymmetricKey(serializedClaims, encryptionKey)}`; + + expect(() => new GitHubSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + } + ); + it('rejects expired and tampered capabilities', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-30T12:00:00.000Z')); const codec = new GitHubSessionCapabilityCodec(encryptionKey); const capability = codec.issue(claims); - vi.setSystemTime(new Date('2026-05-30T12:59:59.999Z')); + vi.setSystemTime(new Date('2026-05-30T15:59:59.999Z')); expect(codec.decode(capability)).toMatchObject({ source: 'user' }); - vi.setSystemTime(new Date('2026-05-30T13:00:00.000Z')); + vi.setSystemTime(new Date('2026-05-30T16:00:00.000Z')); expect(() => codec.decode(capability)).toThrowError( expect.objectContaining({ reason: 'expired_capability' }) ); diff --git a/services/git-token-service/src/github-session-capability.ts b/services/git-token-service/src/github-session-capability.ts index 83cdeb46ab..0a682ede35 100644 --- a/services/git-token-service/src/github-session-capability.ts +++ b/services/git-token-service/src/github-session-capability.ts @@ -5,7 +5,15 @@ import { z } from 'zod'; const LEGACY_CAPABILITY_PREFIX = 'kgh1.'; const BOUND_CAPABILITY_PREFIX = 'kgh2.'; const CAPABILITY_PURPOSE = 'github_scm_session'; -const MAX_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS = 60 * 60 * 1000; +const MAX_LEGACY_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS = 2 * 60 * 60 * 1000; +const MAX_BOUND_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS = 4 * 60 * 60 * 1000; + +function getGitHubSessionCapabilityLifetimeMs(version: 1 | 2): number { + return version === 1 + ? MAX_LEGACY_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS + : MAX_BOUND_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS; +} + const GitHubPathPartSchema = z .string() .trim() @@ -54,7 +62,8 @@ const GitHubSessionCapabilityClaimsSchema = z ]) .refine(claims => claims.expiresAt > claims.issuedAt) .refine( - claims => claims.expiresAt - claims.issuedAt <= MAX_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS + claims => + claims.expiresAt - claims.issuedAt <= getGitHubSessionCapabilityLifetimeMs(claims.version) ); export type GitHubAuthSource = 'user' | 'installation'; @@ -118,12 +127,13 @@ export class GitHubSessionCapabilityCodec { issue(subject: GitHubSessionCapabilitySubject): string { const issuedAt = Date.now(); const bound = subject.outboundContainerId !== undefined; + const version = bound ? 2 : 1; const parsed = GitHubSessionCapabilityClaimsSchema.safeParse({ purpose: CAPABILITY_PURPOSE, - version: bound ? 2 : 1, + version, ...subject, issuedAt, - expiresAt: issuedAt + MAX_GITHUB_SCM_SESSION_CAPABILITY_LIFETIME_MS, + expiresAt: issuedAt + getGitHubSessionCapabilityLifetimeMs(version), }); if (!parsed.success) throw new GitHubSessionCapabilityError('invalid_capability'); diff --git a/services/git-token-service/src/gitlab-session-capability.test.ts b/services/git-token-service/src/gitlab-session-capability.test.ts index 85724b94d1..7aee422bad 100644 --- a/services/git-token-service/src/gitlab-session-capability.test.ts +++ b/services/git-token-service/src/gitlab-session-capability.test.ts @@ -25,7 +25,7 @@ const claims = { } as const; describe('GitLabSessionCapabilityCodec', () => { - it('produces an opaque one-hour prefixed capability with GitLab-bound claims', () => { + it('produces an opaque four-hour prefixed capability with GitLab-bound claims', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-31T12:00:00.000Z')); const codec = new GitLabSessionCapabilityCodec(encryptionKey); @@ -40,12 +40,14 @@ describe('GitLabSessionCapabilityCodec', () => { version: 2, ...claims, issuedAt: Date.parse('2026-05-31T12:00:00.000Z'), - expiresAt: Date.parse('2026-05-31T13:00:00.000Z'), + expiresAt: Date.parse('2026-05-31T16:00:00.000Z'), }); vi.useRealTimers(); }); - it('produces and decodes a legacy unbound v1 capability when the container is omitted', () => { + it('produces and decodes a two-hour legacy unbound v1 capability when the container is omitted', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-31T12:00:00.000Z')); const codec = new GitLabSessionCapabilityCodec(encryptionKey); const { outboundContainerId: _outboundContainerId, ...legacyClaims } = claims; @@ -56,10 +58,36 @@ describe('GitLabSessionCapabilityCodec', () => { version: 1, userId: 'user_1', projectPath: 'Acme/platform/widgets', + issuedAt: Date.parse('2026-05-31T12:00:00.000Z'), + expiresAt: Date.parse('2026-05-31T14:00:00.000Z'), }); expect(codec.decode(capability)).not.toHaveProperty('outboundContainerId'); + vi.useRealTimers(); }); + it.each([ + ['legacy unbound v1', 'kgl1.', 1, 2 * 60 * 60 * 1000, false], + ['container-bound v2', 'kgl2.', 2, 4 * 60 * 60 * 1000, true], + ] as const)( + 'rejects an overlong %s capability', + (_description, prefix, version, maximumLifetimeMs, bound) => { + const { outboundContainerId: _outboundContainerId, ...legacyClaims } = claims; + const issuedAt = Date.now(); + const serializedClaims = JSON.stringify({ + purpose: 'gitlab_scm_session', + version, + ...(bound ? claims : legacyClaims), + issuedAt, + expiresAt: issuedAt + maximumLifetimeMs + 1, + }); + const capability = `${prefix}${encryptWithSymmetricKey(serializedClaims, encryptionKey)}`; + + expect(() => new GitLabSessionCapabilityCodec(encryptionKey).decode(capability)).toThrowError( + expect.objectContaining({ reason: 'invalid_capability' }) + ); + } + ); + it('rejects expiry, tampering, and another encryption key', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-31T12:00:00.000Z')); @@ -73,7 +101,9 @@ describe('GitLabSessionCapabilityCodec', () => { expect(() => new GitLabSessionCapabilityCodec(anotherEncryptionKey).decode(capability) ).toThrowError(expect.objectContaining({ reason: 'invalid_capability' })); - vi.setSystemTime(new Date('2026-05-31T13:00:00.000Z')); + vi.setSystemTime(new Date('2026-05-31T15:59:59.999Z')); + expect(codec.decode(capability)).toMatchObject({ source: claims.source }); + vi.setSystemTime(new Date('2026-05-31T16:00:00.000Z')); expect(() => codec.decode(capability)).toThrowError( expect.objectContaining({ reason: 'expired_capability' }) ); diff --git a/services/git-token-service/src/gitlab-session-capability.ts b/services/git-token-service/src/gitlab-session-capability.ts index 4e4bec94e0..a25ba3f08e 100644 --- a/services/git-token-service/src/gitlab-session-capability.ts +++ b/services/git-token-service/src/gitlab-session-capability.ts @@ -8,7 +8,15 @@ export type { GitLabCloneUrlFailureReason, GitLabCloneUrlResult } from './gitlab const LEGACY_CAPABILITY_PREFIX = 'kgl1.'; const BOUND_CAPABILITY_PREFIX = 'kgl2.'; const CAPABILITY_PURPOSE = 'gitlab_scm_session'; -const MAX_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS = 60 * 60 * 1000; +const MAX_LEGACY_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS = 2 * 60 * 60 * 1000; +const MAX_BOUND_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS = 4 * 60 * 60 * 1000; + +function getGitLabSessionCapabilityLifetimeMs(version: 1 | 2): number { + return version === 1 + ? MAX_LEGACY_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS + : MAX_BOUND_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS; +} + const GitLabSessionIdentitySchema = z .object({ accountId: z.string().min(1).nullable(), @@ -54,7 +62,8 @@ const GitLabSessionCapabilityClaimsSchema = z ]) .refine(claims => claims.expiresAt > claims.issuedAt) .refine( - claims => claims.expiresAt - claims.issuedAt <= MAX_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS + claims => + claims.expiresAt - claims.issuedAt <= getGitLabSessionCapabilityLifetimeMs(claims.version) ); export type GitLabAuthType = 'oauth' | 'pat'; @@ -100,12 +109,13 @@ export class GitLabSessionCapabilityCodec { issue(subject: GitLabSessionCapabilitySubject): string { const issuedAt = Date.now(); const bound = subject.outboundContainerId !== undefined; + const version = bound ? 2 : 1; const parsed = GitLabSessionCapabilityClaimsSchema.safeParse({ purpose: CAPABILITY_PURPOSE, - version: bound ? 2 : 1, + version, ...subject, issuedAt, - expiresAt: issuedAt + MAX_GITLAB_SCM_SESSION_CAPABILITY_LIFETIME_MS, + expiresAt: issuedAt + getGitLabSessionCapabilityLifetimeMs(version), }); if (!parsed.success) throw new GitLabSessionCapabilityError('invalid_capability'); try {