Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 43 additions & 18 deletions packages/code/src/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,23 +104,30 @@ test('mints and caches a short-lived GitHub App installation token', async (t) =
{ mode: 0o600 },
);
await chmod(privateKeyPath, 0o600);
let calls = 0;
const calls: Array<{ url: string; authorization: string | null }> = [];
const request = async (
_input: string | URL | Request,
input: string | URL | Request,
init?: RequestInit,
) => {
calls += 1;
assert.match(
String(new Headers(init?.headers).get('authorization')),
/^Bearer eyJ/,
);
return new Response(
JSON.stringify({
const url = String(input);
const authorization = new Headers(init?.headers).get('authorization');
calls.push({ url, authorization });
if (url.endsWith('/app')) {
assert.match(String(authorization), /^Bearer eyJ/);
return Response.json({ slug: 'lia' });
}
if (url.endsWith('/app/installations/456/access_tokens')) {
assert.match(String(authorization), /^Bearer eyJ/);
return Response.json({
token: 'ghs_abcdefghijklmnopqrstuvwxyz',
expires_at: '2030-01-01T01:00:00Z',
}),
{ status: 201 },
);
}, { status: 201 });
}
if (url.endsWith('/users/lia%5Bbot%5D')) {
assert.equal(authorization, 'Bearer ghs_abcdefghijklmnopqrstuvwxyz');
return Response.json({ id: 1234, login: 'lia[bot]', type: 'Bot' });
}
return Response.json({}, { status: 404 });
};
const provider = new GitHubAppCredentialProvider({
appId: '123',
Expand All @@ -138,7 +145,15 @@ test('mints and caches a short-lived GitHub App installation token', async (t) =
(await provider.getCredential()).value,
'ghs_abcdefghijklmnopqrstuvwxyz',
);
assert.equal(calls, 1);
assert.equal(calls.filter(call => call.url.endsWith('/app')).length, 1);
assert.equal(
calls.filter(call => call.url.endsWith('/access_tokens')).length,
1,
);
assert.equal(
calls.filter(call => call.url.endsWith('/users/lia%5Bbot%5D')).length,
1,
);
});

test('routes and scopes GitHub App tokens per repository installation', async (t) => {
Expand Down Expand Up @@ -767,16 +782,26 @@ test('App JWT requests use the resolved public or enterprise endpoint', async (t
now: () => new Date('2030-01-01T00:00:00Z'),
fetch: async (input, init) => {
calls++;
assert.equal(String(input), `${expected}/app/installations/456/access_tokens`);
assert.equal(init?.method, 'POST');
const url = String(input);
assert.equal(init?.redirect, 'error');
assert.match(new Headers(init?.headers).get('authorization')!, /^Bearer eyJ/);
return new Response(JSON.stringify({ token: 'ghs_abcdefghijklmnopqrstuvwxyz', expires_at: '2030-01-01T01:00:00Z' }), { status: 201 });
const authorization = new Headers(init?.headers).get('authorization');
if (url === `${expected}/app`) {
assert.match(authorization!, /^Bearer eyJ/);
return Response.json({ slug: 'lia' });
}
if (url === `${expected}/app/installations/456/access_tokens`) {
assert.equal(init?.method, 'POST');
assert.match(authorization!, /^Bearer eyJ/);
return new Response(JSON.stringify({ token: 'ghs_abcdefghijklmnopqrstuvwxyz', expires_at: '2030-01-01T01:00:00Z' }), { status: 201 });
}
assert.equal(url, `${expected}/users/lia%5Bbot%5D`);
assert.equal(authorization, 'Bearer ghs_abcdefghijklmnopqrstuvwxyz');
return Response.json({ id: 1234, login: 'lia[bot]', type: 'Bot' });
},
});
await provider.getCredential();
await provider.getCredential();
assert.equal(calls, 1);
assert.equal(calls, 3);
}
});

Expand Down
54 changes: 46 additions & 8 deletions packages/code/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider {
private readonly cached = new Map<string, GitHubCredential>();
private readonly inFlight = new Map<string, Promise<GitHubCredential>>();
private readonly installationIds = new Map<string, string>();
private appLogin?: string;
private appLoginInFlight?: Promise<string>;
private actor?: GitHubCredential['actor'];
private actorInFlight?: Promise<NonNullable<GitHubCredential['actor']>>;
private readonly apiUrl: string;
Expand Down Expand Up @@ -280,12 +282,12 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider {
});
}

private async resolveActor(
private async resolveAppLogin(
jwt: string,
signal?: AbortSignal,
): Promise<NonNullable<GitHubCredential['actor']>> {
if (this.actor) return this.actor;
if (!this.actorInFlight) {
): Promise<string> {
if (this.appLogin) return this.appLogin;
if (!this.appLoginInFlight) {
const pending = (async () => {
const sharedSignal = AbortSignal.timeout(
GITHUB_SHARED_REQUEST_TIMEOUT_MS,
Expand All @@ -303,10 +305,41 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider {
) {
throw new Error('GitHub App identity response is invalid');
}
const login = `${app.slug}[bot]`;
return `${app.slug}[bot]`;
})();
this.appLoginInFlight = pending;
void pending.then(
login => {
this.appLogin = login;
if (this.appLoginInFlight === pending) {
this.appLoginInFlight = undefined;
}
},
() => {
if (this.appLoginInFlight === pending) {
this.appLoginInFlight = undefined;
}
},
);
}
return waitForShared(this.appLoginInFlight, signal);
}

private async resolveActor(
jwt: string,
installationToken: string,
signal?: AbortSignal,
): Promise<NonNullable<GitHubCredential['actor']>> {
if (this.actor) return this.actor;
if (!this.actorInFlight) {
const pending = (async () => {
const sharedSignal = AbortSignal.timeout(
GITHUB_SHARED_REQUEST_TIMEOUT_MS,
);
const login = await this.resolveAppLogin(jwt, sharedSignal);
const userResponse = await this.request(
`/users/${encodeURIComponent(login)}`,
jwt,
installationToken,
sharedSignal,
);
if (!userResponse.ok) {
Expand Down Expand Up @@ -349,7 +382,7 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider {
async validate(signal?: AbortSignal): Promise<void> {
const now = (this.options.now ?? (() => new Date()))();
const jwt = await this.appJwt(now);
await this.resolveActor(jwt, signal);
await this.resolveAppLogin(jwt, signal);
if (this.options.installationId) {
await this.getCredential(signal);
}
Expand Down Expand Up @@ -477,10 +510,15 @@ export class GitHubAppCredentialProvider implements GitHubCredentialProvider {
) {
throw new Error('GitHub App token expiry is invalid');
}
const actor = await this.resolveActor(
jwt,
body.token,
sharedSignal,
);
const credential = {
value: body.token,
expiresAt,
...(this.actor ? { actor: this.actor } : {}),
actor,
};
this.cached.set(key, credential);
return credential;
Expand Down
Loading