Skip to content

Commit 9b5b4f3

Browse files
authored
Merge pull request #285 from Blazity/feat/mcp-offline-access
feat(worker): issue refresh tokens via offline_access for MCP OAuth
2 parents a81c8c8 + 4d3166d commit 9b5b4f3

9 files changed

Lines changed: 149 additions & 13 deletions

File tree

apps/worker/src/auth.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ describe("MCP OAuth provider", () => {
311311

312312
await expect(auth.api.getOAuthServerConfig()).resolves.toMatchObject({
313313
issuer: "http://localhost:3000/api/auth",
314-
scopes_supported: [...MCP_SCOPES],
314+
scopes_supported: [...MCP_SCOPES, "offline_access"],
315315
registration_endpoint: "http://localhost:3000/api/auth/oauth2/register",
316316
code_challenge_methods_supported: expect.arrayContaining(["S256"]),
317317
grant_types_supported: expect.arrayContaining([

apps/worker/src/mcp/auth-pages.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,35 @@ describe("MCP auth pages", () => {
102102
expect(html).not.toContain("oauth_query");
103103
});
104104

105+
it("shows offline_access with an honest refresh-token description and keeps it in the posted scope", () => {
106+
const html = renderMcpConsentPage({
107+
clientName: "Agent",
108+
redirectUri: "https://callback.example.com/oauth/callback",
109+
requestedScopes: ["mcp:read", "offline_access"],
110+
flowId: "flow-safe",
111+
});
112+
113+
expect(html).toContain("offline_access");
114+
expect(html).toContain("Stay signed in");
115+
expect(html).toContain("not access to any of your data");
116+
// The hidden field the browser posts back must carry offline_access, or the
117+
// provider never issues a refresh token.
118+
expect(html).toContain('name="scope" value="mcp:read offline_access"');
119+
});
120+
121+
it("omits the refresh-token description when offline_access is not requested", () => {
122+
const html = renderMcpConsentPage({
123+
clientName: "Agent",
124+
redirectUri: "https://callback.example.com/oauth/callback",
125+
requestedScopes: ["mcp:read", "runs:dispatch"],
126+
flowId: "flow-safe",
127+
});
128+
129+
expect(html).not.toContain("offline_access");
130+
expect(html).not.toContain("Stay signed in");
131+
expect(html).toContain('name="scope" value="mcp:read runs:dispatch"');
132+
});
133+
105134
it("keeps signed oauth_query state HttpOnly and rejects missing, expired, or tampered state", () => {
106135
const now = new Date("2026-08-11T12:00:00.000Z");
107136
const cookie = createOAuthFlowCookie("client_id=abc&sig=opaque", SECRET, now);

apps/worker/src/mcp/auth-pages.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
22

3-
import { MCP_SCOPES, type McpScope } from "./contracts.js";
3+
import { MCP_SCOPES } from "./contracts.js";
4+
5+
// offline_access is the standard OAuth2 / OIDC refresh-token marker, the same scope
6+
// Atlassian and Supabase surface on their consent screens. It is deliberately not an
7+
// MCP permission: request-context.ts materializes an actor's scope set by intersecting
8+
// against MCP_SCOPES, so offline_access can never become one. The consent path is the
9+
// one place it has to pass through, so the provider issues the 30-day refresh token,
10+
// which is why the consent allowlist is MCP_SCOPES plus this single marker. Every
11+
// consent spot (the get-gate, the rendered screen, and the post-grant) reads it through
12+
// allowedScopes, so this one list keeps all three in agreement by construction.
13+
export const OFFLINE_ACCESS_SCOPE = "offline_access";
14+
export const CONSENT_SCOPES = [...MCP_SCOPES, OFFLINE_ACCESS_SCOPE] as const;
15+
export type ConsentScope = (typeof CONSENT_SCOPES)[number];
416

517
const FLOW_COOKIE = "mcp_oauth";
618
const FLOW_TTL_SECONDS = 10 * 60;
@@ -164,17 +176,25 @@ export function renderMcpConsentPage(input: {
164176
const hostname = safeHostname(input.redirectUri);
165177
const scopes = allowedScopes(input.requestedScopes);
166178
const scopeList = scopes
167-
.map((scope) => `<li><code>${escapeHtml(scope)}</code></li>`)
179+
.map((scope) => `<li><code>${escapeHtml(scope)}</code>${scopeDescription(scope)}</li>`)
168180
.join("");
169181
return htmlPage(
170182
"Authorize MCP client",
171183
`<main class="card" aria-labelledby="consent-title"><p class="eyebrow">MCP authorization</p><h1 id="consent-title">Authorize ${escapeHtml(input.clientName)}</h1><p class="lede">This application is requesting access to your AI Workflow account.</p><dl class="details"><div><dt>Application</dt><dd>${escapeHtml(input.clientName)}</dd></div><div><dt>Redirect host</dt><dd><code>${escapeHtml(hostname)}</code></dd></div></dl><h2>Requested access</h2><ul class="scopes">${scopeList}</ul><form method="post" action="/mcp-auth/consent"><input type="hidden" name="flow_id" value="${escapeHtml(input.flowId)}"><input type="hidden" name="scope" value="${escapeHtml(scopes.join(" "))}"><div class="actions"><button class="secondary" name="accept" value="false" type="submit">Deny</button><button class="primary" name="accept" value="true" type="submit">Allow</button></div></form></main>`,
172184
);
173185
}
174186

175-
export function allowedScopes(scopes: readonly string[]): McpScope[] {
176-
const allowed = new Set<string>(MCP_SCOPES);
177-
return [...new Set(scopes)].filter((scope): scope is McpScope => allowed.has(scope));
187+
// offline_access is not access to any data, so the screen says what it actually does
188+
// instead of showing a bare code the person cannot weigh. Every other scope renders as
189+
// its code alone, exactly as before.
190+
function scopeDescription(scope: ConsentScope): string {
191+
if (scope !== OFFLINE_ACCESS_SCOPE) return "";
192+
return "<small>Stay signed in. Lets this application refresh its own access without sending you back here. It is not access to any of your data.</small>";
193+
}
194+
195+
export function allowedScopes(scopes: readonly string[]): ConsentScope[] {
196+
const allowed = new Set<string>(CONSENT_SCOPES);
197+
return [...new Set(scopes)].filter((scope): scope is ConsentScope => allowed.has(scope));
178198
}
179199

180200
export function isSameOriginPost(request: Request, expectedOrigin: string): boolean {
@@ -216,5 +236,5 @@ function escapeHtml(value: string): string {
216236
}
217237

218238
function htmlPage(title: string, body: string): string {
219-
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>:root{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f5f7fb}*{box-sizing:border-box}body{min-height:100vh;margin:0;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at top,#fff 0,#f5f7fb 55%)}.card{width:min(100%,460px);padding:36px;border:1px solid #dfe5ef;border-radius:18px;background:#fff;box-shadow:0 18px 48px #17203314}.eyebrow{margin:0 0 10px;color:#53627a;font-size:12px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}.lede{margin:0 0 26px;color:#53627a;line-height:1.55}h1{margin:0 0 10px;font-size:30px;letter-spacing:-.03em}h2{margin:28px 0 12px;font-size:15px}form{display:grid;gap:9px}label,dt{color:#344158;font-size:13px;font-weight:650}input{width:100%;margin:0 0 8px;padding:11px 12px;border:1px solid #cbd4e2;border-radius:9px;background:#fff;color:inherit;font:inherit}input:focus{outline:3px solid #b9d7ff;border-color:#377dcc}button,.secondary{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:10px 16px;border-radius:9px;font:inherit;font-weight:700;text-decoration:none;cursor:pointer}.primary{border:1px solid #1f5fa8;background:#1f5fa8;color:#fff}.secondary{border:1px solid #b9c5d6;background:#fff;color:#24334d}.divider{display:flex;align-items:center;gap:12px;margin:22px 0;color:#8490a3;font-size:12px}.divider:before,.divider:after{content:"";height:1px;flex:1;background:#e3e8f0}.fine-print{margin:18px 0 0;color:#718097;font-size:12px;line-height:1.5;text-align:center}.error{margin:0 0 18px;padding:10px 12px;border:1px solid #efb9b9;border-radius:9px;background:#fff4f4;color:#a52f2f;font-size:13px}.details{display:grid;gap:12px;margin:24px 0;padding:16px;border-radius:12px;background:#f6f8fb}.details div{display:grid;gap:4px}.details dd{margin:0;color:#53627a;font-size:14px}.scopes{display:grid;gap:9px;margin:0;padding:0;list-style:none}.scopes li{padding:11px 12px;border:1px solid #e1e7f0;border-radius:9px;background:#fbfcfe}.scopes code,dd code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}.actions{display:flex;justify-content:flex-end;gap:10px;margin-top:28px}</style></head><body>${body}</body></html>`;
239+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>:root{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f5f7fb}*{box-sizing:border-box}body{min-height:100vh;margin:0;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at top,#fff 0,#f5f7fb 55%)}.card{width:min(100%,460px);padding:36px;border:1px solid #dfe5ef;border-radius:18px;background:#fff;box-shadow:0 18px 48px #17203314}.eyebrow{margin:0 0 10px;color:#53627a;font-size:12px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}.lede{margin:0 0 26px;color:#53627a;line-height:1.55}h1{margin:0 0 10px;font-size:30px;letter-spacing:-.03em}h2{margin:28px 0 12px;font-size:15px}form{display:grid;gap:9px}label,dt{color:#344158;font-size:13px;font-weight:650}input{width:100%;margin:0 0 8px;padding:11px 12px;border:1px solid #cbd4e2;border-radius:9px;background:#fff;color:inherit;font:inherit}input:focus{outline:3px solid #b9d7ff;border-color:#377dcc}button,.secondary{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:10px 16px;border-radius:9px;font:inherit;font-weight:700;text-decoration:none;cursor:pointer}.primary{border:1px solid #1f5fa8;background:#1f5fa8;color:#fff}.secondary{border:1px solid #b9c5d6;background:#fff;color:#24334d}.divider{display:flex;align-items:center;gap:12px;margin:22px 0;color:#8490a3;font-size:12px}.divider:before,.divider:after{content:"";height:1px;flex:1;background:#e3e8f0}.fine-print{margin:18px 0 0;color:#718097;font-size:12px;line-height:1.5;text-align:center}.error{margin:0 0 18px;padding:10px 12px;border:1px solid #efb9b9;border-radius:9px;background:#fff4f4;color:#a52f2f;font-size:13px}.details{display:grid;gap:12px;margin:24px 0;padding:16px;border-radius:12px;background:#f6f8fb}.details div{display:grid;gap:4px}.details dd{margin:0;color:#53627a;font-size:14px}.scopes{display:grid;gap:9px;margin:0;padding:0;list-style:none}.scopes li{padding:11px 12px;border:1px solid #e1e7f0;border-radius:9px;background:#fbfcfe}.scopes code,dd code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}.scopes small{display:block;margin-top:6px;color:#53627a;font-size:12px;line-height:1.45}.actions{display:flex;justify-content:flex-end;gap:10px;margin-top:28px}</style></head><body>${body}</body></html>`;
220240
}

apps/worker/src/mcp/oauth-metadata.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ describe("MCP OAuth discovery", () => {
8080
"prompts:write",
8181
"workflows:write",
8282
"tickets:write",
83+
"offline_access",
8384
],
8485
code_challenge_methods_supported: ["S256"],
8586
grant_types_supported: expect.arrayContaining([

apps/worker/src/mcp/oauth.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe("MCP OAuth provider options", () => {
5454
it("advertises the exact scopes, S256, and supported grants", () => {
5555
const options = createMcpOAuthOptions(DEPLOYMENT);
5656

57-
expect(options.scopes).toEqual(MCP_SCOPES);
57+
expect(options.scopes).toEqual([...MCP_SCOPES, "offline_access"]);
5858
expect(options.validAudiences).toEqual(["https://worker.example.com/mcp"]);
5959
expect(options.grantTypes).toEqual(
6060
expect.arrayContaining(["authorization_code", "client_credentials", "refresh_token"]),
@@ -63,6 +63,19 @@ describe("MCP OAuth provider options", () => {
6363
expect(options.silenceWarnings).toEqual({ oauthAuthServerConfig: true });
6464
});
6565

66+
it("advertises offline_access as a registrable but opt-in refresh-token scope", () => {
67+
const options = createMcpOAuthOptions(DEPLOYMENT);
68+
69+
// Advertised in AS metadata and allowed at /authorize and DCR, so an interactive
70+
// client can request a refresh token by asking for it.
71+
expect(options.scopes).toContain("offline_access");
72+
expect(options.clientRegistrationAllowedScopes).toContain("offline_access");
73+
// Never a default, so it is opt-in and is never written into an unattended
74+
// client's grant. request-context.ts drops it from the actor's permission set.
75+
expect(options.clientRegistrationDefaultScopes).not.toContain("offline_access");
76+
expect(options.clientCredentialGrantDefaultScopes).not.toContain("offline_access");
77+
});
78+
6679
it("keeps unauthenticated DCR disabled by default", () => {
6780
const options = createMcpOAuthOptions(DEPLOYMENT);
6881

apps/worker/src/mcp/oauth.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,19 @@ export function createMcpOAuthOptions(deployment: McpOAuthDeployment) {
6060
const automationScopes = scopes.filter(
6161
(scope) => scope !== "prompts:write" && scope !== "workflows:write",
6262
);
63+
// offline_access is the standard OAuth2/OIDC marker a client sends to ask for a
64+
// refresh token, the same way Atlassian and Supabase do it. It is advertised and
65+
// registrable so an interactive client can opt in, but it is permission-inert:
66+
// request-context.ts materializes an actor's scope set by intersecting the token's
67+
// issued scopes against MCP_SCOPES, so offline_access never becomes a permission.
68+
// It stays out of both defaults below, so it is opt-in and never written into an
69+
// unattended client's grant.
70+
const OFFLINE_ACCESS = "offline_access";
71+
const advertisedScopes = [...scopes, OFFLINE_ACCESS];
6372
const resolveOrganizationId = () => deploymentOrganizationId(deployment);
6473

6574
const options = {
66-
scopes,
75+
scopes: advertisedScopes,
6776
validAudiences: [canonicalMcpResource(baseURL)],
6877
grantTypes: [
6978
"authorization_code",
@@ -76,7 +85,7 @@ export function createMcpOAuthOptions(deployment: McpOAuthDeployment) {
7685
allowDynamicClientRegistration: true,
7786
allowUnauthenticatedClientRegistration: deployment.allowPublicDcr ?? false,
7887
clientRegistrationDefaultScopes: scopes,
79-
clientRegistrationAllowedScopes: scopes,
88+
clientRegistrationAllowedScopes: advertisedScopes,
8089
clientCredentialGrantDefaultScopes: automationScopes,
8190
codeChallengeMethodsSupported: ["S256"] as const,
8291
silenceWarnings: { oauthAuthServerConfig: true },

apps/worker/src/mcp/request-context.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,23 @@ describe("requireMcpActor", () => {
8484
});
8585
});
8686

87+
// offline_access is the refresh-token marker, never a permission. Both the token
88+
// claim and the client row carry it here, so the only thing that can remove it is
89+
// the MCP_SCOPES intersection in request-context.ts, which is the mechanism the
90+
// whole "permission-inert" argument rests on.
91+
it("never lets offline_access leak into the actor's permission scopes", async () => {
92+
await db
93+
.update(oauthClient)
94+
.set({ scopes: ["mcp:read", "runs:dispatch", "offline_access"] });
95+
state.verifyAccessToken.mockResolvedValue(
96+
userClaims({ scope: "mcp:read runs:dispatch offline_access" }),
97+
);
98+
99+
const actor = await requireMcpActor(request());
100+
101+
expect(actor.scopes).toEqual(new Set(["mcp:read", "runs:dispatch"]));
102+
});
103+
87104
it("normalizes an admin membership instead of trusting the token role", async () => {
88105
await db.update(member).set({ role: "admin" });
89106
state.verifyAccessToken.mockResolvedValue(userClaims({ organization_role: "member" }));

apps/worker/src/routes/mcp-auth/consent.get.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,35 @@ describe("MCP consent screen", () => {
8888
expect(response.status).toBe(400);
8989
});
9090

91+
it("renders offline_access as a refresh-token scope when the client requests it", async () => {
92+
const response = await handlerFor(consentRoute)(
93+
new Request(consentUrl({ scope: "mcp:read offline_access" })),
94+
);
95+
96+
expect(response.status).toBe(200);
97+
const body = await response.text();
98+
expect(body).toContain("offline_access");
99+
expect(body).toContain("Stay signed in");
100+
// The hidden field the browser posts back has to carry it, or the provider never
101+
// issues a refresh token.
102+
expect(body).toContain('name="scope" value="mcp:read offline_access"');
103+
});
104+
105+
it("leaves a request without offline_access exactly as it was", async () => {
106+
const response = await handlerFor(consentRoute)(
107+
new Request(
108+
consentUrl({
109+
scope: "mcp:read runs:dispatch prompts:write workflows:write tickets:write",
110+
}),
111+
),
112+
);
113+
114+
expect(response.status).toBe(200);
115+
const body = await response.text();
116+
expect(body).not.toContain("offline_access");
117+
expect(body).not.toContain("Stay signed in");
118+
});
119+
91120
it("still refuses an unregistered or edited redirect, whose signature prelogin rejects", async () => {
92121
// This is the negative case for the check the route no longer performs.
93122
// Two independent guards keep it covered, both verified against the

apps/worker/src/routes/mcp-auth/consent.post.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ function handlerFor(route: Parameters<typeof eventHandler>[0]) {
5757
return toWebHandler(app);
5858
}
5959

60-
function consentUrl(): string {
60+
function consentUrl(overrides: Record<string, string> = {}): string {
6161
const query = new URLSearchParams({
6262
response_type: "code",
6363
client_id: CLIENT_ID,
@@ -69,13 +69,14 @@ function consentUrl(): string {
6969
exp: "1786597132",
7070
ba_iat: "1786596532923",
7171
sig: "0e6g9sZI2vvfQEnndJjoGGfdPi2kfBu9W21XvBmGbe4=",
72+
...overrides,
7273
});
7374
return `https://worker.example.com/mcp-auth/consent?${query.toString()}`;
7475
}
7576

7677
/** Walks the browser's half: render the page, then post the form it rendered. */
77-
async function renderThenApprove(accept = "true") {
78-
const rendered = await handlerFor(consentGet)(new Request(consentUrl()));
78+
async function renderThenApprove(accept = "true", url = consentUrl()) {
79+
const rendered = await handlerFor(consentGet)(new Request(url));
7980
expect(rendered.status).toBe(200);
8081
const html = await rendered.text();
8182
const setCookie = rendered.headers.get("set-cookie") ?? "";
@@ -126,6 +127,23 @@ describe("MCP consent POST", () => {
126127
expect(response.headers.get("set-cookie")).toContain("Max-Age=0");
127128
});
128129

130+
it("grants offline_access to the provider so it issues a refresh token", async () => {
131+
const { response } = await renderThenApprove(
132+
"true",
133+
consentUrl({ scope: "mcp:read runs:dispatch offline_access" }),
134+
);
135+
136+
expect(response.status).toBe(302);
137+
const request = state.authHandler.mock.calls[0]?.[0] as Request;
138+
const posted = (await request.json()) as { scope: string };
139+
// dist/index.mjs:509 only mints a refresh token when the granted scopes include
140+
// offline_access, so it has to survive all the way to this POST.
141+
expect(posted.scope.split(" ")).toContain("offline_access");
142+
expect(posted.scope.split(" ")).toEqual(
143+
expect.arrayContaining(["mcp:read", "runs:dispatch", "offline_access"]),
144+
);
145+
});
146+
129147
it("carries a denial through instead of pretending it approved", async () => {
130148
const { response } = await renderThenApprove("false");
131149

0 commit comments

Comments
 (0)