Skip to content

Commit b5985b3

Browse files
tlgimenesclaude
andauthored
fix(github): surface GitHub invalid_grant as OAuthInvalidGrantError (#405)
When GitHub responds to a refresh-token exchange with `invalid_grant` or `bad_refresh_token` (the user revoked the app, or the refresh_token was rotated out), throw the typed `OAuthInvalidGrantError` from `@decocms/runtime` instead of a generic `Error`. The runtime's `/token` handler maps this to a spec-compliant `400 invalid_grant`, which lets the mesh evict the dead refresh_token and prompt the user to reconnect. Transient failures (5xx, network) keep throwing a plain `Error` and surface as `500`, so mesh leaves the cached row intact for retry. Bumps `@decocms/runtime` to ^1.6.0 (the version that exports `OAuthInvalidGrantError`). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6e26d88 commit b5985b3

5 files changed

Lines changed: 214 additions & 10 deletions

File tree

bun.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

github/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,20 @@
77
"scripts": {
88
"dev": "bunx wrangler dev",
99
"check": "tsc --noEmit",
10+
"test": "bun test",
1011
"build": "bunx wrangler deploy --dry-run --outdir=dist",
1112
"deploy": "bunx wrangler deploy"
1213
},
1314
"dependencies": {
1415
"@decocms/bindings": "^1.4.0",
15-
"@decocms/runtime": "1.5.0",
16+
"@decocms/runtime": "^1.6.0",
1617
"@modelcontextprotocol/sdk": "^1.27.1",
1718
"zod": "^4.0.0"
1819
},
1920
"devDependencies": {
2021
"@cloudflare/workers-types": "^4.20251014.0",
2122
"@decocms/mcps-shared": "1.0.0",
23+
"@types/bun": "^1.2.14",
2224
"@types/node": "^22.0.0",
2325
"typescript": "^5.7.2",
2426
"wrangler": "^4.28.0"
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+
import { OAuthInvalidGrantError } from "@decocms/runtime";
3+
import { refreshAccessToken } from "./github-client.ts";
4+
5+
const realFetch = globalThis.fetch;
6+
7+
type FetchMock = (
8+
input: Request | URL | string,
9+
init?: RequestInit,
10+
) => Promise<Response>;
11+
12+
function mockFetch(impl: FetchMock) {
13+
globalThis.fetch = impl as typeof globalThis.fetch;
14+
}
15+
16+
describe("refreshAccessToken", () => {
17+
beforeEach(() => {
18+
globalThis.fetch = realFetch;
19+
});
20+
21+
afterEach(() => {
22+
globalThis.fetch = realFetch;
23+
});
24+
25+
test("throws OAuthInvalidGrantError when GitHub returns 200 with error=invalid_grant", async () => {
26+
mockFetch(
27+
async () =>
28+
new Response(
29+
JSON.stringify({
30+
error: "invalid_grant",
31+
error_description:
32+
"The refresh token has expired or has been revoked",
33+
}),
34+
{ status: 200, headers: { "Content-Type": "application/json" } },
35+
),
36+
);
37+
38+
let caught: unknown;
39+
try {
40+
await refreshAccessToken("rt", "client_id", "client_secret");
41+
} catch (err) {
42+
caught = err;
43+
}
44+
45+
expect(caught).toBeInstanceOf(OAuthInvalidGrantError);
46+
const typed = caught as OAuthInvalidGrantError;
47+
expect(typed.error).toBe("invalid_grant");
48+
expect(typed.errorDescription).toBe(
49+
"The refresh token has expired or has been revoked",
50+
);
51+
});
52+
53+
test("throws OAuthInvalidGrantError when GitHub returns error=bad_refresh_token", async () => {
54+
mockFetch(
55+
async () =>
56+
new Response(JSON.stringify({ error: "bad_refresh_token" }), {
57+
status: 200,
58+
headers: { "Content-Type": "application/json" },
59+
}),
60+
);
61+
62+
let caught: unknown;
63+
try {
64+
await refreshAccessToken("rt", "client_id", "client_secret");
65+
} catch (err) {
66+
caught = err;
67+
}
68+
69+
expect(caught).toBeInstanceOf(OAuthInvalidGrantError);
70+
expect((caught as OAuthInvalidGrantError).error).toBe("bad_refresh_token");
71+
});
72+
73+
test("throws OAuthInvalidGrantError on 400 invalid_grant", async () => {
74+
mockFetch(
75+
async () =>
76+
new Response(JSON.stringify({ error: "invalid_grant" }), {
77+
status: 400,
78+
headers: { "Content-Type": "application/json" },
79+
}),
80+
);
81+
82+
let caught: unknown;
83+
try {
84+
await refreshAccessToken("rt", "client_id", "client_secret");
85+
} catch (err) {
86+
caught = err;
87+
}
88+
89+
expect(caught).toBeInstanceOf(OAuthInvalidGrantError);
90+
});
91+
92+
test("throws plain Error (not OAuthInvalidGrantError) on 5xx", async () => {
93+
mockFetch(
94+
async () =>
95+
new Response("Bad Gateway", {
96+
status: 502,
97+
headers: { "Content-Type": "text/plain" },
98+
}),
99+
);
100+
101+
let caught: unknown;
102+
try {
103+
await refreshAccessToken("rt", "client_id", "client_secret");
104+
} catch (err) {
105+
caught = err;
106+
}
107+
108+
expect(caught).toBeInstanceOf(Error);
109+
expect(caught).not.toBeInstanceOf(OAuthInvalidGrantError);
110+
});
111+
112+
test("propagates plain Error on network failure", async () => {
113+
mockFetch(async () => {
114+
throw new Error("network down");
115+
});
116+
117+
let caught: unknown;
118+
try {
119+
await refreshAccessToken("rt", "client_id", "client_secret");
120+
} catch (err) {
121+
caught = err;
122+
}
123+
124+
expect(caught).toBeInstanceOf(Error);
125+
expect(caught).not.toBeInstanceOf(OAuthInvalidGrantError);
126+
expect((caught as Error).message).toBe("network down");
127+
});
128+
129+
test("returns token response on success", async () => {
130+
mockFetch(
131+
async () =>
132+
new Response(
133+
JSON.stringify({
134+
access_token: "new-access",
135+
token_type: "Bearer",
136+
expires_in: 28800,
137+
refresh_token: "new-refresh",
138+
refresh_token_expires_in: 15897600,
139+
scope: "repo",
140+
}),
141+
{ status: 200, headers: { "Content-Type": "application/json" } },
142+
),
143+
);
144+
145+
const result = await refreshAccessToken("rt", "client_id", "client_secret");
146+
147+
expect(result.access_token).toBe("new-access");
148+
expect(result.refresh_token).toBe("new-refresh");
149+
expect(result.scope).toBe("repo");
150+
});
151+
});

github/server/lib/github-client.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* GitHub OAuth helpers
33
*/
44

5+
import { OAuthInvalidGrantError } from "@decocms/runtime";
6+
57
export interface GitHubTokenResponse {
68
access_token: string;
79
token_type: string;
@@ -83,16 +85,63 @@ export function exchangeCodeForToken(
8385
/**
8486
* Exchange a refresh token for a new access token.
8587
* Only works for GitHub Apps that issue expiring user tokens.
88+
*
89+
* Throws `OAuthInvalidGrantError` for the spec-compliant permanent-failure
90+
* cases (`invalid_grant` / `bad_refresh_token`) so the runtime's `/token`
91+
* handler can map it to `400 invalid_grant` and let mesh evict the cached
92+
* refresh_token. Anything else (5xx, network, malformed body) propagates
93+
* as a plain `Error` and surfaces as `500` — treated as transient by mesh.
8694
*/
87-
export function refreshAccessToken(
95+
export async function refreshAccessToken(
8896
refreshToken: string,
8997
clientId: string,
9098
clientSecret: string,
9199
): Promise<GitHubTokenResponse> {
92-
return postToGitHub({
93-
client_id: clientId,
94-
client_secret: clientSecret,
95-
grant_type: "refresh_token",
96-
refresh_token: refreshToken,
100+
const response = await fetch(GITHUB_TOKEN_ENDPOINT, {
101+
method: "POST",
102+
headers: {
103+
Accept: "application/json",
104+
"Content-Type": "application/json",
105+
"User-Agent": "deco-cms-github-mcp",
106+
},
107+
body: JSON.stringify({
108+
client_id: clientId,
109+
client_secret: clientSecret,
110+
grant_type: "refresh_token",
111+
refresh_token: refreshToken,
112+
}),
97113
});
114+
115+
// Per RFC 6749 §5.2 the canonical signal is the body's `error` field, not
116+
// the HTTP status — and GitHub historically returns `200 { error: "..." }`
117+
// when `Accept: application/json` is set. Read the body before branching
118+
// on status so we can recognise the typed error in either shape.
119+
const data = (await response.json().catch(() => ({}))) as
120+
| RawGitHubTokenResponse
121+
| Record<string, never>;
122+
123+
if (data.error === "invalid_grant" || data.error === "bad_refresh_token") {
124+
throw new OAuthInvalidGrantError(data.error, data.error_description);
125+
}
126+
127+
if (!response.ok) {
128+
throw new Error(
129+
`GitHub OAuth failed: ${response.status} - ${data.error ?? "unknown"}`,
130+
);
131+
}
132+
133+
if (data.error) {
134+
throw new Error(
135+
`GitHub OAuth error: ${data.error_description || data.error}`,
136+
);
137+
}
138+
139+
return {
140+
access_token: data.access_token,
141+
token_type: data.token_type || "Bearer",
142+
expires_in: data.expires_in,
143+
refresh_token: data.refresh_token,
144+
refresh_token_expires_in: data.refresh_token_expires_in,
145+
scope: data.scope,
146+
};
98147
}

github/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
/* Types */
2525
"types": [
2626
"@types/node",
27-
"@cloudflare/workers-types"
27+
"@cloudflare/workers-types",
28+
"bun"
2829
]
2930
},
3031
"include": [

0 commit comments

Comments
 (0)