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
5 changes: 5 additions & 0 deletions packages/gatekeeper-github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ your own GitHub OAuth app. This guide walks you through the process.
(replace the host with your `PUBLIC_BASE_URL` when not running locally)
4. Click **Register application**

GitHub enables expiring user tokens by default for OAuth apps registered since August 2026
(**Optional features** in the app's settings). The gatekeeper supports both kinds: it refreshes an
expiring token shortly before its eight hours run out, and asks the user to reconnect only once
GitHub rejects the refresh token itself (unused for six months, or revoked).

### Step 2: Generate a Client Secret

On the app's settings page after registration:
Expand Down
425 changes: 425 additions & 0 deletions packages/gatekeeper-github/__tests__/workerd/credentials.test.ts

Large diffs are not rendered by default.

71 changes: 67 additions & 4 deletions packages/gatekeeper-github/__tests__/workerd/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
// stubs the test passes (the fake approval queue and git cache) ride through to the facet, and
// results ride back as plain data.

import { DurableObject } from "cloudflare:workers";
import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
import type { RpcStub } from "cloudflare:workers";
import type { ActionDescription, GitCache } from "@gadgets/workshop-shared/gatekeeper";
import type { GitHubGatekeeperImpl } from "../../src/github.js";
import type {
AccountDescription, ActionDescription, ConnectHandoff, GatekeeperUser, GitCache,
} from "@gadgets/workshop-shared/gatekeeper";
import type { GitHubGatekeeperImpl, GitHubVerifierApi } from "../../src/github.js";
import type {
GitHubBranchSummary,
GitHubCommitDetails,
Expand All @@ -26,6 +28,39 @@ import type {

export { default } from "../../src/github.js";
export * from "../../src/github.js";
// Named as well, since the pool builds `ctx.exports` entrypoints only from exports it can see
// statically, and the account and TestHooks mint these.
export { GatekeeperUserImpl, GitHubVerifier } from "../../src/github.js";

/** What each `TestConnectCallback` was told, by its `props.name`. */
export const connectCallbackEvents = new Map<string, string[]>();

/** Stands in for the Workshop's connect callback, recording each call it receives. */
export class TestConnectCallback extends WorkerEntrypoint<Cloudflare.Env, { name: string }> {
#record(event: string): void {
const events = connectCallbackEvents.get(this.ctx.props.name) ?? [];
events.push(event);
connectCallbackEvents.set(this.ctx.props.name, events);
}

async complete(): Promise<ConnectHandoff> {
this.#record("complete");
return { targetOrigin: "https://workshop.example", ticket: "ticket" };
}

async reconnectComplete(stageId: string): Promise<ConnectHandoff> {
this.#record(`reconnectComplete:${stageId}`);
return { targetOrigin: "https://workshop.example", ticket: "ticket" };
}

async credentialsExpired(): Promise<void> {
this.#record("credentialsExpired");
}

async credentialsRestored(): Promise<void> {
this.#record("credentialsRestored");
}
}

/** Mirrors github.ts's (unexported) `GitHubGatekeeperImplProps`. */
export type GatekeeperProps = {
Expand Down Expand Up @@ -60,9 +95,15 @@ export type CreatePullRequestActionData = {
options: GitHubCreatePullRequestOptions;
};

/** github.ts's (unexported) `PostReviewAction` record, read off the real submit signature. */
export type PostReviewActionData = Extract<
Parameters<GitHubGatekeeperImpl["submitActionForApproval"]>[1], { type: "postReview" }>;

type TestExports = {
GitHubGatekeeperImpl(options: { props: GatekeeperProps }):
DurableObjectClass<GitHubGatekeeperImpl>;
GatekeeperUserImpl(options: { props: { userObjectId: string } }): Fetcher<GatekeeperUser>;
GitHubVerifier(options: { props: { userObjectId: string } }): Fetcher<GitHubVerifierApi>;
};

// The facet methods TestHooks forwards to, spelled structurally: workers-types' `Fetcher<T>`
Expand All @@ -74,7 +115,7 @@ type GatekeeperFacet = {
prepareCreatePullRequest(options: GitHubCreatePullRequestOptions)
: Promise<CreatePullRequestActionData>;
submitActionForApproval(
queue: unknown, action: PushActionData | CreatePullRequestActionData,
queue: unknown, action: PushActionData | CreatePullRequestActionData | PostReviewActionData,
description: ActionDescription): Promise<void>;
applyAction(actionId: number, cache: RpcStub<GitCache>): Promise<void>;
rejectAction(actionId: number): Promise<undefined | { restart?: boolean }>;
Expand Down Expand Up @@ -134,6 +175,20 @@ export class TestHooks extends DurableObject<Cloudflare.Env> {
})) as unknown as GatekeeperFacet;
}

/** `GatekeeperUser.describe()` for the account with id `userObjectId`. */
async describeAccount(userObjectId: string): Promise<Outcome<AccountDescription>> {
const user = (this.ctx.exports as unknown as TestExports)
.GatekeeperUserImpl({ props: { userObjectId } });
return await outcome(() => user.describe());
}

/** `GitHubVerifier.hasRepoAccess()` as the account with id `userObjectId`. */
async hasRepoAccess(userObjectId: string, owner: string, repo: string): Promise<Outcome<boolean>> {
const verifier = (this.ctx.exports as unknown as TestExports)
.GitHubVerifier({ props: { userObjectId } });
return await outcome(() => verifier.hasRepoAccess(owner, repo));
}

async preparePush(
facetName: string, props: GatekeeperProps,
branch: string, commitId: string, force: boolean, cache: RpcStub<GitCache>,
Expand Down Expand Up @@ -165,6 +220,14 @@ export class TestHooks extends DurableObject<Cloudflare.Env> {
this.#gatekeeper(facetName, props).submitActionForApproval(queue, action, description));
}

async submitReview(
facetName: string, props: GatekeeperProps,
queue: unknown, action: PostReviewActionData, description: ActionDescription,
): Promise<Outcome<void>> {
return await outcome(() =>
this.#gatekeeper(facetName, props).submitActionForApproval(queue, action, description));
}

async rejectAction(
facetName: string, props: GatekeeperProps, actionId: number,
): Promise<Outcome<undefined | { restart?: boolean }>> {
Expand Down
96 changes: 49 additions & 47 deletions packages/gatekeeper-github/src/github-api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import type { RefreshCredentials } from "@gadgets/gatekeeper-kit/credentials";
import {
isInvalidGrant, mergeOAuthTokens, OAuthClient, oauthRefresh,
} from "@gadgets/gatekeeper-kit/oauth-client";

/**
* A GitHub OAuth grant. An expiring grant -- the default for OAuth apps registered since August
* 2026, opt-in before -- also carries `refreshToken` and `expiresAt`: its access token lasts eight
* hours, and each refresh rotates both tokens. A grant without them does not expire.
*/
export type GitHubOAuthGrant = {
accessToken: string;
scopes: string[];
tokenType: string;
refreshToken?: string;
Comment thread
ndisidore marked this conversation as resolved.
/** Absolute access-token expiry, epoch milliseconds. */
expiresAt?: number;
};

export type GitHubSimpleUser = {
Expand Down Expand Up @@ -357,60 +369,50 @@ async function request<T>(
};
}

function oauthClient(clientId: string, clientSecret: string): OAuthClient {
return new OAuthClient({
label: "GitHub",
client: { method: "post", id: clientId, secret: clientSecret },
tokenEndpoint: `${LOGIN_BASE_URL}/login/oauth/access_token`,
headers: { "User-Agent": USER_AGENT },
scopeSeparator: ",",
timeoutMs: REQUEST_TIMEOUT_MS,
});
}

export async function exchangeAuthCode(
code: string,
clientId: string,
clientSecret: string,
redirectUri: string,
): Promise<GitHubOAuthGrant> {
const body = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: redirectUri,
});

const response = await fetch(`${LOGIN_BASE_URL}/login/oauth/access_token`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": USER_AGENT,
},
body: body.toString(),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});

const parsed = await parseBody(response);
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
if (typeof parsed === "string" && parsed.length > 0) {
message = parsed;
} else if (parsed && typeof parsed === "object") {
const details = parsed as { error?: string; error_description?: string };
message = [details.error, details.error_description].filter(Boolean).join(": ") || message;
}
throw new GitHubApiError(response.status, message, parsed);
}

const result = parsed as {
access_token?: string;
scope?: string;
token_type?: string;
error?: string;
error_description?: string;
const tokens = await oauthClient(clientId, clientSecret).exchangeCode({ code, redirectUri });
Comment thread
ndisidore marked this conversation as resolved.
const grant: GitHubOAuthGrant = {
accessToken: tokens.accessToken,
scopes: tokens.scopes?.map(scope => scope.trim()).filter(Boolean) ?? [],
};
if (!result.access_token || !result.token_type || result.error) {
const message = [result.error, result.error_description].filter(Boolean).join(": ")
|| "GitHub OAuth token exchange failed";
throw new GitHubApiError(400, message, parsed);
}
if (tokens.refreshToken !== undefined) grant.refreshToken = tokens.refreshToken;
if (tokens.expiresAt !== undefined) grant.expiresAt = tokens.expiresAt;
return grant;
}

return {
accessToken: result.access_token,
scopes: result.scope?.split(",").map((scope: string) => scope.trim()).filter(Boolean) ?? [],
tokenType: result.token_type,
};
/**
* Refreshes an expiring grant. The scopes are kept as stored, since GitHub never changes them on
* refresh. GitHub answers a refresh token that is expired, revoked, or already used with
* `bad_refresh_token` (in an HTTP 200), which proves the grant dead.
*/
export function refreshGitHubGrant(
clientId: string,
clientSecret: string,
expiredMessage: string,
): RefreshCredentials<GitHubOAuthGrant> {
return oauthRefresh<GitHubOAuthGrant>(oauthClient(clientId, clientSecret), {
refreshToken: grant => grant.refreshToken,
merge: (grant, tokens) => ({ ...mergeOAuthTokens(grant, tokens), scopes: grant.scopes }),
isGrantDeath: error => isInvalidGrant(error)
|| (error.oauthError === "bad_refresh_token" && error.httpStatus < 500),
expiredMessage,
});
}

/**
Expand Down
Loading
Loading