approval-dialog.ts•27.7 kB
import type {
AuthRequest,
ClientInfo,
} from "@cloudflare/workers-oauth-provider";
import { logError, logIssue, logWarn } from "@sentry/mcp-server/telem/logging";
import { sanitizeHtml } from "./html-utils";
const COOKIE_NAME = "mcp-approved-clients";
const ONE_YEAR_IN_SECONDS = 31536000;
/**
* Imports a secret key string for HMAC-SHA256 signing.
* @param secret - The raw secret key string.
* @returns A promise resolving to the CryptoKey object.
*/
async function importKey(secret: string): Promise<CryptoKey> {
if (!secret) {
throw new Error(
"COOKIE_SECRET is not defined. A secret key is required for signing cookies.",
);
}
const enc = new TextEncoder();
return crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false, // not extractable
["sign", "verify"], // key usages
);
}
/**
* Signs data using HMAC-SHA256.
* @param key - The CryptoKey for signing.
* @param data - The string data to sign.
* @returns A promise resolving to the signature as a hex string.
*/
async function signData(key: CryptoKey, data: string): Promise<string> {
const enc = new TextEncoder();
const signatureBuffer = await crypto.subtle.sign(
"HMAC",
key,
enc.encode(data),
);
// Convert ArrayBuffer to hex string
return Array.from(new Uint8Array(signatureBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Verifies an HMAC-SHA256 signature.
* @param key - The CryptoKey for verification.
* @param signatureHex - The signature to verify (hex string).
* @param data - The original data that was signed.
* @returns A promise resolving to true if the signature is valid, false otherwise.
*/
async function verifySignature(
key: CryptoKey,
signatureHex: string,
data: string,
): Promise<boolean> {
const enc = new TextEncoder();
try {
// Convert hex signature back to ArrayBuffer
const signatureBytes = new Uint8Array(
signatureHex.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16)),
);
return await crypto.subtle.verify(
"HMAC",
key,
signatureBytes.buffer,
enc.encode(data),
);
} catch (error) {
logError(error, {
loggerScope: ["cloudflare", "approval-dialog"],
extra: {
message: "Error verifying signature",
},
});
return false;
}
}
/**
* Parses the signed cookie and verifies its integrity.
* @param cookieHeader - The value of the Cookie header from the request.
* @param secret - The secret key used for signing.
* @returns A promise resolving to the list of approved client IDs if the cookie is valid, otherwise null.
*/
async function getApprovedClientsFromCookie(
cookieHeader: string | null,
secret: string,
): Promise<string[] | null> {
if (!cookieHeader) return null;
const cookies = cookieHeader.split(";").map((c) => c.trim());
const targetCookie = cookies.find((c) => c.startsWith(`${COOKIE_NAME}=`));
if (!targetCookie) return null;
const cookieValue = targetCookie.substring(COOKIE_NAME.length + 1);
const parts = cookieValue.split(".");
if (parts.length !== 2) {
logWarn("Invalid approval cookie format", {
loggerScope: ["cloudflare", "approval-dialog"],
});
return null; // Invalid format
}
const [signatureHex, base64Payload] = parts;
const payload = atob(base64Payload); // Assuming payload is base64 encoded JSON string
const key = await importKey(secret);
const isValid = await verifySignature(key, signatureHex, payload);
if (!isValid) {
logWarn("Approval cookie signature verification failed", {
loggerScope: ["cloudflare", "approval-dialog"],
});
return null; // Signature invalid
}
try {
const approvedClients = JSON.parse(payload);
if (!Array.isArray(approvedClients)) {
logWarn("Approval cookie payload is not an array", {
loggerScope: ["cloudflare", "approval-dialog"],
});
return null; // Payload isn't an array
}
// Ensure all elements are strings
if (!approvedClients.every((item) => typeof item === "string")) {
logWarn("Approval cookie payload contains non-string elements", {
loggerScope: ["cloudflare", "approval-dialog"],
});
return null;
}
return approvedClients as string[];
} catch (e) {
logIssue(new Error(`Error parsing cookie payload: ${e}`, { cause: e }));
return null; // JSON parsing failed
}
}
/**
* Checks if a given client ID has already been approved by the user,
* based on a signed cookie.
*
* @param request - The incoming Request object to read cookies from.
* @param clientId - The OAuth client ID to check approval for.
* @param cookieSecret - The secret key used to sign/verify the approval cookie.
* @returns A promise resolving to true if the client ID is in the list of approved clients in a valid cookie, false otherwise.
*/
export async function clientIdAlreadyApproved(
request: Request,
clientId: string,
cookieSecret: string,
): Promise<boolean> {
if (!clientId) return false;
const cookieHeader = request.headers.get("Cookie");
const approvedClients = await getApprovedClientsFromCookie(
cookieHeader,
cookieSecret,
);
return approvedClients?.includes(clientId) ?? false;
}
/**
* Configuration for the approval dialog
*/
export interface ApprovalDialogOptions {
/**
* Client information to display in the approval dialog
*/
client: ClientInfo | null;
/**
* Server information to display in the approval dialog
*/
server: {
name: string;
logo?: string;
description?: string;
};
/**
* Arbitrary state data to pass through the approval flow
* Will be encoded in the form and returned when approval is complete
*/
state: Record<string, any>;
}
/**
* Encodes arbitrary data to a URL-safe base64 string.
* @param data - The data to encode (will be stringified).
* @returns A URL-safe base64 encoded string.
*/
function encodeState(data: any): string {
try {
const jsonString = JSON.stringify(data);
// Use btoa for simplicity, assuming Worker environment supports it well enough
// For complex binary data, a Buffer/Uint8Array approach might be better
return btoa(jsonString);
} catch (error) {
logError(error, {
loggerScope: ["cloudflare", "approval-dialog"],
extra: {
message: "Error encoding approval dialog state",
},
});
throw new Error("Could not encode state");
}
}
/**
* Decodes a URL-safe base64 string back to its original data.
* @param encoded - The URL-safe base64 encoded string.
* @returns The original data.
*/
function decodeState<T = any>(encoded: string): T {
try {
const jsonString = atob(encoded);
return JSON.parse(jsonString);
} catch (error) {
logError(error, {
loggerScope: ["cloudflare", "approval-dialog"],
extra: {
message: "Error decoding approval dialog state",
},
});
throw new Error("Could not decode state");
}
}
/**
* Renders an approval dialog for OAuth authorization
* The dialog displays information about the client and server
* and includes a form to submit approval
*
* @param request - The HTTP request
* @param options - Configuration for the approval dialog
* @returns A Response containing the HTML approval dialog
*/
export function renderApprovalDialog(
request: Request,
options: ApprovalDialogOptions,
): Response {
const { client, server, state } = options;
// Encode state for form submission
const encodedState = encodeState(state);
// Sanitize any untrusted content
const serverName = sanitizeHtml(server.name);
const clientName = client?.clientName
? sanitizeHtml(client.clientName)
: "Unknown MCP Client";
const serverDescription = server.description
? sanitizeHtml(server.description)
: "";
// Safe URLs
const logoUrl = server.logo ? sanitizeHtml(server.logo) : "";
const clientUri = client?.clientUri ? sanitizeHtml(client.clientUri) : "";
const policyUri = client?.policyUri ? sanitizeHtml(client.policyUri) : "";
const tosUri = client?.tosUri ? sanitizeHtml(client.tosUri) : "";
// Client contacts
const contacts =
client?.contacts && client.contacts.length > 0
? sanitizeHtml(client.contacts.join(", "))
: "";
// Get redirect URIs
const redirectUris =
client?.redirectUris && client.redirectUris.length > 0
? client.redirectUris.map((uri) => sanitizeHtml(uri))
: [];
// Generate HTML for the approval dialog
const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${clientName} | Authorization Request</title>
<style>
/* Modern, responsive styling with system fonts */
:root {
--primary-color: oklch(0.205 0 0);
--highlight-color: oklch(0.811 0.111 293.571);
--border-color: oklch(0.278 0.033 256.848);
--error-color: #f44336;
--border-color: oklch(0.269 0 0);
--text-color: oklch(0.872 0.01 258.338);
--background-color: oklab(0 0 0 / 0.3);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol";
line-height: 1.6;
color: var(--text-color);
background: linear-gradient(oklch(0.13 0.028 261.692) 0%, oklch(0.21 0.034 264.665) 50%, oklch(0.13 0.028 261.692) 100%);
min-height: 100vh;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 1rem auto;
padding: 1rem;
}
.precard {
text-align: center;
}
.card {
background-color: var(--background-color);
padding: 2rem;
}
.header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
}
.logo {
width: 36px;
height: 36px;
margin-right: 1rem;
color: var(--highlight-color);
}
.title {
margin: 0;
font-size: 26px;
font-weight: 400;
color: white;
}
.alert {
margin: 0;
font-size: 1.5rem;
font-weight: 400;
margin: 1rem 0;
text-align: center;
color: white;
}
.client-info {
border: 1px solid var(--border-color);
padding: 1rem 1rem 0.5rem;
margin-bottom: 1.5rem;
}
.client-name {
font-weight: 600;
font-size: 1.2rem;
margin: 0 0 0.5rem 0;
}
.client-detail {
display: flex;
margin-bottom: 0.5rem;
align-items: baseline;
}
.detail-label {
font-weight: 500;
min-width: 120px;
}
.detail-value {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
word-break: break-all;
}
.detail-value.small {
font-size: 0.8em;
}
.external-link-icon {
font-size: 0.75em;
margin-left: 0.25rem;
vertical-align: super;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
}
a {
color: var(--highlight-color);
text-decoration: underline;
}
.button {
padding: 0.75rem 1.5rem;
font-weight: 600;
cursor: pointer;
border: none;
font-size: 1rem;
}
.button-primary {
background-color: var(--highlight-color);
color: black;
}
.button-secondary {
background-color: transparent;
border: 1px solid var(--border-color);
color: var(--text-color);
}
/* Permission selection styles */
.permission-section {
margin: 2rem 0;
border: 1px solid var(--border-color);
padding: 1.5rem;
}
.permission-title {
margin: 0 0 0.5rem 0;
font-size: 1.3rem;
font-weight: 600;
color: white;
}
/* Default permissions section */
.default-permissions {
margin-bottom: 2rem;
background-color: oklab(0 0 0 / 0.15);
border: 1px solid var(--highlight-color);
padding: 1.5rem;
border-radius: 4px;
}
.default-permissions-title {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: white;
}
.default-permission-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.permission-check {
font-size: 1.2rem;
color: var(--highlight-color);
flex-shrink: 0;
margin-top: 0.1rem;
}
.default-permission-content {
flex: 1;
}
.default-permission-name {
font-weight: 600;
color: white;
font-size: 1rem;
}
.default-permission-description {
color: var(--text-color);
font-size: 0.9rem;
margin-top: 0.25rem;
}
/* Optional permissions section */
.optional-permissions {
margin-bottom: 1.5rem;
}
.optional-permissions-title {
margin: 0 0 1rem 0;
font-size: 1rem;
font-weight: 500;
color: white;
}
.optional-permission-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--border-color);
margin-bottom: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.optional-permission-item:hover {
border-color: var(--highlight-color);
background-color: oklab(0 0 0 / 0.1);
}
.optional-permission-item input[type="checkbox"] {
position: absolute;
opacity: 0;
pointer-events: none;
}
.permission-checkbox {
font-size: 1.2rem;
color: var(--text-color);
transition: color 0.2s ease;
flex-shrink: 0;
cursor: pointer;
margin-top: 0.1rem;
}
/* CSS-only checkbox interactions using :checked pseudo-class */
.optional-permission-item input[type="checkbox"]:checked + .permission-checkbox {
color: var(--highlight-color);
}
.optional-permission-item input[type="checkbox"]:checked + .permission-checkbox::before {
content: "☑";
}
.optional-permission-item input[type="checkbox"]:not(:checked) + .permission-checkbox::before {
content: "☐";
}
.optional-permission-item:has(input[type="checkbox"]:checked) {
border-color: var(--highlight-color);
background-color: oklab(0 0 0 / 0.1);
}
.optional-permission-content {
flex: 1;
}
.optional-permission-name {
font-weight: 600;
color: white;
font-size: 1rem;
}
.optional-permission-description {
color: var(--text-color);
font-size: 0.9rem;
margin-top: 0.25rem;
}
/* Responsive adjustments */
@media (max-width: 640px) {
.container {
margin: 1rem auto;
padding: 0.5rem;
}
.card {
padding: 1.5rem;
}
.client-detail {
flex-direction: column;
}
.detail-label {
min-width: unset;
margin-bottom: 0.25rem;
}
.actions {
flex-direction: column;
}
.button {
width: 100%;
}
}
</style>
</head>
<body>
<div class="container">
<div class="precard">
<div class="header">
<svg class="logo" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-labelledby="icon-title"><title id="icon-title">Sentry Logo</title><path d="M17.48 1.996c.45.26.823.633 1.082 1.083l13.043 22.622a2.962 2.962 0 0 1-2.562 4.44h-3.062c.043-.823.039-1.647 0-2.472h3.052a.488.488 0 0 0 .43-.734L16.418 4.315a.489.489 0 0 0-.845 0L12.582 9.51a23.16 23.16 0 0 1 7.703 8.362 23.19 23.19 0 0 1 2.8 11.024v1.234h-7.882v-1.236a15.284 15.284 0 0 0-6.571-12.543l-1.48 2.567a12.301 12.301 0 0 1 5.105 9.987v1.233h-9.3a2.954 2.954 0 0 1-2.56-1.48A2.963 2.963 0 0 1 .395 25.7l1.864-3.26a6.854 6.854 0 0 1 2.15 1.23l-1.883 3.266a.49.49 0 0 0 .43.734h6.758a9.985 9.985 0 0 0-4.83-7.272l-1.075-.618 3.927-6.835 1.075.615a17.728 17.728 0 0 1 6.164 5.956 17.752 17.752 0 0 1 2.653 8.154h2.959a20.714 20.714 0 0 0-3.05-9.627 20.686 20.686 0 0 0-7.236-7.036l-1.075-.618 4.215-7.309a2.958 2.958 0 0 1 4.038-1.083Z" fill="currentColor"></path></svg>
<h1 class="title"><strong>${serverName}</strong></h1>
</div>
</div>
<div class="card">
<h2 class="alert"><strong>${clientName || "A new MCP Client"}</strong> is requesting access</h1>
<div class="client-info">
<div class="client-detail">
<div class="detail-label">Name:</div>
<div class="detail-value">
${clientName}
</div>
</div>
${
clientUri
? `
<div class="client-detail">
<div class="detail-label">Website:</div>
<div class="detail-value small">
<a href="${clientUri}" target="_blank" rel="noopener noreferrer">
${clientUri}
</a>
</div>
</div>
`
: ""
}
${
policyUri
? `
<div class="client-detail">
<div class="detail-label">Privacy Policy:</div>
<div class="detail-value">
<a href="${policyUri}" target="_blank" rel="noopener noreferrer">
${policyUri}
</a>
</div>
</div>
`
: ""
}
${
tosUri
? `
<div class="client-detail">
<div class="detail-label">Terms of Service:</div>
<div class="detail-value">
<a href="${tosUri}" target="_blank" rel="noopener noreferrer">
${tosUri}
</a>
</div>
</div>
`
: ""
}
${
redirectUris.length > 0
? `
<div class="client-detail">
<div class="detail-label">Redirect URIs:</div>
<div class="detail-value small">
${redirectUris.map((uri) => `<div>${uri}</div>`).join("")}
</div>
</div>
`
: ""
}
${
contacts
? `
<div class="client-detail">
<div class="detail-label">Contact:</div>
<div class="detail-value">${contacts}</div>
</div>
`
: ""
}
</div>
<p>This MCP Client is requesting authorization to ${serverName}. If you approve, you will be redirected to complete authentication.</p>
<form method="post" action="${new URL(request.url).pathname}">
<input type="hidden" name="state" value="${encodedState}">
<div class="permission-section">
<h3 class="permission-title">Permissions</h3>
<!-- Default permissions section -->
<div class="default-permissions">
<div class="default-permission-item">
<span class="permission-check">✓</span>
<div class="default-permission-content">
<span class="default-permission-name">Read-only access to your Sentry data</span>
<div class="default-permission-description">View organizations, projects, teams, issues, and releases</div>
</div>
</div>
</div>
<!-- Optional permissions section -->
<div class="optional-permissions">
<h4 class="optional-permissions-title">Optional additional access:</h4>
<label class="optional-permission-item">
<input type="checkbox" name="permission" value="seer" checked>
<span class="permission-checkbox"></span>
<div class="optional-permission-content">
<span class="optional-permission-name">Seer</span>
<div class="optional-permission-description">Use Seer to analyze issues and generate fix recommendations (may incur costs)</div>
</div>
</label>
<label class="optional-permission-item">
<input type="checkbox" name="permission" value="issue_triage">
<span class="permission-checkbox"></span>
<div class="optional-permission-content">
<span class="optional-permission-name">Issue Triage (event:write)</span>
<div class="optional-permission-description">Update and manage issues - resolve, assign, and triage problems</div>
</div>
</label>
<label class="optional-permission-item">
<input type="checkbox" name="permission" value="project_management">
<span class="permission-checkbox"></span>
<div class="optional-permission-content">
<span class="optional-permission-name">Project Management (project:write, team:write)</span>
<div class="optional-permission-description">Create and modify projects, teams, and DSNs</div>
</div>
</label>
</div>
</div>
<div class="actions">
<button type="button" class="button button-secondary" onclick="window.history.back()">Cancel</button>
<button type="submit" class="button button-primary">Approve</button>
</div>
</form>
</div>
</div>
</body>
</html>
`;
return new Response(htmlContent, {
headers: {
"Content-Type": "text/html; charset=utf-8",
},
});
}
/**
* Result of parsing the approval form submission.
*/
export interface ParsedApprovalResult {
/** The original state object passed through the form. */
state: any;
/** Headers to set on the redirect response, including the Set-Cookie header. */
headers: Record<string, string>;
/** Selected permission levels */
permissions: string[];
}
/**
* Parses the form submission from the approval dialog, extracts the state,
* and generates Set-Cookie headers to mark the client as approved.
*
* @param request - The incoming POST Request object containing the form data.
* @param cookieSecret - The secret key used to sign the approval cookie.
* @returns A promise resolving to an object containing the parsed state and necessary headers.
* @throws If the request method is not POST, form data is invalid, or state is missing.
*/
export async function parseRedirectApproval(
request: Request,
cookieSecret: string,
): Promise<ParsedApprovalResult> {
if (request.method !== "POST") {
throw new Error("Invalid request method. Expected POST.");
}
let state: any;
let clientId: string | undefined;
let permissions: string[];
try {
const formData = await request.formData();
const encodedState = formData.get("state");
if (typeof encodedState !== "string" || !encodedState) {
throw new Error("Missing or invalid 'state' in form data.");
}
state = decodeState<{ oauthReqInfo?: AuthRequest }>(encodedState); // Decode the state
clientId = state?.oauthReqInfo?.clientId; // Extract clientId from within the state
if (!clientId) {
throw new Error("Could not extract clientId from state object.");
}
// Extract permission selections from checkboxes - collect all 'permission' field values
permissions = formData
.getAll("permission")
.filter((p): p is string => typeof p === "string");
} catch (error) {
logError(error, {
loggerScope: ["cloudflare", "approval-dialog"],
extra: {
message: "Error processing approval form submission",
},
});
throw new Error(
`Failed to parse approval form: ${error instanceof Error ? error.message : String(error)}`,
);
}
// Get existing approved clients
const cookieHeader = request.headers.get("Cookie");
const existingApprovedClients =
(await getApprovedClientsFromCookie(cookieHeader, cookieSecret)) || [];
// Add the newly approved client ID (avoid duplicates)
const updatedApprovedClients = Array.from(
new Set([...existingApprovedClients, clientId]),
);
// Sign the updated list
const payload = JSON.stringify(updatedApprovedClients);
const key = await importKey(cookieSecret);
const signature = await signData(key, payload);
const newCookieValue = `${signature}.${btoa(payload)}`; // signature.base64(payload)
// Generate Set-Cookie header
const headers: Record<string, string> = {
"Set-Cookie": `${COOKIE_NAME}=${newCookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=${ONE_YEAR_IN_SECONDS}`,
};
return { state, headers, permissions };
}
// sanitizeHtml function is now imported from "./html-utils"