|
1 | 1 | import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; |
2 | 2 |
|
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]; |
4 | 16 |
|
5 | 17 | const FLOW_COOKIE = "mcp_oauth"; |
6 | 18 | const FLOW_TTL_SECONDS = 10 * 60; |
@@ -164,17 +176,25 @@ export function renderMcpConsentPage(input: { |
164 | 176 | const hostname = safeHostname(input.redirectUri); |
165 | 177 | const scopes = allowedScopes(input.requestedScopes); |
166 | 178 | const scopeList = scopes |
167 | | - .map((scope) => `<li><code>${escapeHtml(scope)}</code></li>`) |
| 179 | + .map((scope) => `<li><code>${escapeHtml(scope)}</code>${scopeDescription(scope)}</li>`) |
168 | 180 | .join(""); |
169 | 181 | return htmlPage( |
170 | 182 | "Authorize MCP client", |
171 | 183 | `<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>`, |
172 | 184 | ); |
173 | 185 | } |
174 | 186 |
|
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)); |
178 | 198 | } |
179 | 199 |
|
180 | 200 | export function isSameOriginPost(request: Request, expectedOrigin: string): boolean { |
@@ -216,5 +236,5 @@ function escapeHtml(value: string): string { |
216 | 236 | } |
217 | 237 |
|
218 | 238 | 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>`; |
220 | 240 | } |
0 commit comments