mirror of
https://github.com/coleam00/context-engineering-intro.git
synced 2025-12-29 16:14:56 +00:00
MCP Server Example with PRPs
This commit is contained in:
119
use-cases/mcp-server/src/auth/github-handler.ts
Normal file
119
use-cases/mcp-server/src/auth/github-handler.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
// import { env } from "cloudflare:workers";
|
||||
import type { AuthRequest } from "@cloudflare/workers-oauth-provider";
|
||||
import { Hono } from "hono";
|
||||
import { Octokit } from "octokit";
|
||||
import type { Props, ExtendedEnv } from "../types";
|
||||
import {
|
||||
clientIdAlreadyApproved,
|
||||
parseRedirectApproval,
|
||||
renderApprovalDialog,
|
||||
fetchUpstreamAuthToken,
|
||||
getUpstreamAuthorizeUrl,
|
||||
} from "./oauth-utils";
|
||||
const app = new Hono<{ Bindings: ExtendedEnv }>();
|
||||
|
||||
app.get("/authorize", async (c) => {
|
||||
const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw);
|
||||
const { clientId } = oauthReqInfo;
|
||||
if (!clientId) {
|
||||
return c.text("Invalid request", 400);
|
||||
}
|
||||
|
||||
if (
|
||||
await clientIdAlreadyApproved(c.req.raw, oauthReqInfo.clientId, (c.env as any).COOKIE_ENCRYPTION_KEY)
|
||||
) {
|
||||
return redirectToGithub(c.req.raw, oauthReqInfo, c.env, {});
|
||||
}
|
||||
|
||||
return renderApprovalDialog(c.req.raw, {
|
||||
client: await c.env.OAUTH_PROVIDER.lookupClient(clientId),
|
||||
server: {
|
||||
description: "This is a demo MCP Remote Server using GitHub for authentication.",
|
||||
logo: "https://avatars.githubusercontent.com/u/314135?s=200&v=4",
|
||||
name: "Cloudflare GitHub MCP Server", // optional
|
||||
},
|
||||
state: { oauthReqInfo }, // arbitrary data that flows through the form submission below
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/authorize", async (c) => {
|
||||
// Validates form submission, extracts state, and generates Set-Cookie headers to skip approval dialog next time
|
||||
const { state, headers } = await parseRedirectApproval(c.req.raw, (c.env as any).COOKIE_ENCRYPTION_KEY);
|
||||
if (!state.oauthReqInfo) {
|
||||
return c.text("Invalid request", 400);
|
||||
}
|
||||
|
||||
return redirectToGithub(c.req.raw, state.oauthReqInfo, c.env, headers);
|
||||
});
|
||||
|
||||
async function redirectToGithub(
|
||||
request: Request,
|
||||
oauthReqInfo: AuthRequest,
|
||||
env: Env,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
...headers,
|
||||
location: getUpstreamAuthorizeUrl({
|
||||
client_id: (env as any).GITHUB_CLIENT_ID,
|
||||
redirect_uri: new URL("/callback", request.url).href,
|
||||
scope: "read:user",
|
||||
state: btoa(JSON.stringify(oauthReqInfo)),
|
||||
upstream_url: "https://github.com/login/oauth/authorize",
|
||||
}),
|
||||
},
|
||||
status: 302,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth Callback Endpoint
|
||||
*
|
||||
* This route handles the callback from GitHub after user authentication.
|
||||
* It exchanges the temporary code for an access token, then stores some
|
||||
* user metadata & the auth token as part of the 'props' on the token passed
|
||||
* down to the client. It ends by redirecting the client back to _its_ callback URL
|
||||
*/
|
||||
app.get("/callback", async (c) => {
|
||||
// Get the oathReqInfo out of KV
|
||||
const oauthReqInfo = JSON.parse(atob(c.req.query("state") as string)) as AuthRequest;
|
||||
if (!oauthReqInfo.clientId) {
|
||||
return c.text("Invalid state", 400);
|
||||
}
|
||||
|
||||
// Exchange the code for an access token
|
||||
const [accessToken, errResponse] = await fetchUpstreamAuthToken({
|
||||
client_id: (c.env as any).GITHUB_CLIENT_ID,
|
||||
client_secret: (c.env as any).GITHUB_CLIENT_SECRET,
|
||||
code: c.req.query("code"),
|
||||
redirect_uri: new URL("/callback", c.req.url).href,
|
||||
upstream_url: "https://github.com/login/oauth/access_token",
|
||||
});
|
||||
if (errResponse) return errResponse;
|
||||
|
||||
// Fetch the user info from GitHub
|
||||
const user = await new Octokit({ auth: accessToken }).rest.users.getAuthenticated();
|
||||
const { login, name, email } = user.data;
|
||||
|
||||
// Return back to the MCP client a new token
|
||||
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
|
||||
metadata: {
|
||||
label: name,
|
||||
},
|
||||
// This will be available on this.props inside MyMCP
|
||||
props: {
|
||||
accessToken,
|
||||
email,
|
||||
login,
|
||||
name,
|
||||
} as Props,
|
||||
request: oauthReqInfo,
|
||||
scope: oauthReqInfo.scope,
|
||||
userId: login,
|
||||
});
|
||||
|
||||
return Response.redirect(redirectTo);
|
||||
});
|
||||
|
||||
export { app as GitHubHandler };
|
||||
662
use-cases/mcp-server/src/auth/oauth-utils.ts
Normal file
662
use-cases/mcp-server/src/auth/oauth-utils.ts
Normal file
@@ -0,0 +1,662 @@
|
||||
// OAuth utilities for cookie-based approval and upstream OAuth flows
|
||||
|
||||
import type {
|
||||
AuthRequest,
|
||||
ClientInfo,
|
||||
ApprovalDialogOptions,
|
||||
ParsedApprovalResult,
|
||||
UpstreamAuthorizeParams,
|
||||
UpstreamTokenParams
|
||||
} from "../types";
|
||||
|
||||
const COOKIE_NAME = "mcp-approved-clients";
|
||||
const ONE_YEAR_IN_SECONDS = 31536000;
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
/**
|
||||
* 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 (e) {
|
||||
console.error("Error encoding state:", e);
|
||||
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 (e) {
|
||||
console.error("Error decoding state:", e);
|
||||
throw new Error("Could not decode state");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
{ hash: "SHA-256", name: "HMAC" },
|
||||
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 (e) {
|
||||
// Handle errors during hex parsing or verification
|
||||
console.error("Error verifying signature:", e);
|
||||
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) {
|
||||
console.warn("Invalid cookie format received.");
|
||||
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) {
|
||||
console.warn("Cookie signature verification failed.");
|
||||
return null; // Signature invalid
|
||||
}
|
||||
|
||||
try {
|
||||
const approvedClients = JSON.parse(payload);
|
||||
if (!Array.isArray(approvedClients)) {
|
||||
console.warn("Cookie payload is not an array.");
|
||||
return null; // Payload isn't an array
|
||||
}
|
||||
// Ensure all elements are strings
|
||||
if (!approvedClients.every((item) => typeof item === "string")) {
|
||||
console.warn("Cookie payload contains non-string elements.");
|
||||
return null;
|
||||
}
|
||||
return approvedClients as string[];
|
||||
} catch (e) {
|
||||
console.error("Error parsing cookie payload:", e);
|
||||
return null; // JSON parsing failed
|
||||
}
|
||||
}
|
||||
|
||||
// --- Exported Functions ---
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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 = btoa(JSON.stringify(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: #0070f3;
|
||||
--error-color: #f44336;
|
||||
--border-color: #e5e7eb;
|
||||
--text-color: #333;
|
||||
--background-color: #fff;
|
||||
--card-shadow: 0 8px 36px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
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-color: #f9fafb;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.precard {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--background-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-right: 1rem;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.client-info {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
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 a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* 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">
|
||||
${logoUrl ? `<img src="${logoUrl}" alt="${serverName} Logo" class="logo">` : ""}
|
||||
<h1 class="title"><strong>${serverName}</strong></h1>
|
||||
</div>
|
||||
|
||||
${serverDescription ? `<p class="description">${serverDescription}</p>` : ""}
|
||||
</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 to be authorized on ${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="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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error processing form submission:", e);
|
||||
// Rethrow or handle as appropriate, maybe return a specific error response
|
||||
throw new Error(
|
||||
`Failed to parse approval form: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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 { headers, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes HTML content to prevent XSS attacks
|
||||
* @param unsafe - The unsafe string that might contain HTML
|
||||
* @returns A safe string with HTML special characters escaped
|
||||
*/
|
||||
function sanitizeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// --- OAuth Helper Functions ---
|
||||
|
||||
/**
|
||||
* Constructs an authorization URL for an upstream service.
|
||||
*
|
||||
* @param {UpstreamAuthorizeParams} options - The parameters for constructing the URL
|
||||
* @returns {string} The authorization URL.
|
||||
*/
|
||||
export function getUpstreamAuthorizeUrl({
|
||||
upstream_url,
|
||||
client_id,
|
||||
scope,
|
||||
redirect_uri,
|
||||
state,
|
||||
}: UpstreamAuthorizeParams): string {
|
||||
const upstream = new URL(upstream_url);
|
||||
upstream.searchParams.set("client_id", client_id);
|
||||
upstream.searchParams.set("redirect_uri", redirect_uri);
|
||||
upstream.searchParams.set("scope", scope);
|
||||
if (state) upstream.searchParams.set("state", state);
|
||||
upstream.searchParams.set("response_type", "code");
|
||||
return upstream.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an authorization token from an upstream service.
|
||||
*
|
||||
* @param {UpstreamTokenParams} options - The parameters for the token exchange
|
||||
* @returns {Promise<[string, null] | [null, Response]>} A promise that resolves to an array containing the access token or an error response.
|
||||
*/
|
||||
export async function fetchUpstreamAuthToken({
|
||||
client_id,
|
||||
client_secret,
|
||||
code,
|
||||
redirect_uri,
|
||||
upstream_url,
|
||||
}: UpstreamTokenParams): Promise<[string, null] | [null, Response]> {
|
||||
if (!code) {
|
||||
return [null, new Response("Missing code", { status: 400 })];
|
||||
}
|
||||
|
||||
const resp = await fetch(upstream_url, {
|
||||
body: new URLSearchParams({ client_id, client_secret, code, redirect_uri }).toString(),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.log(await resp.text());
|
||||
return [null, new Response("Failed to fetch access token", { status: 500 })];
|
||||
}
|
||||
const body = await resp.formData();
|
||||
const accessToken = body.get("access_token") as string;
|
||||
if (!accessToken) {
|
||||
return [null, new Response("Missing access token", { status: 400 })];
|
||||
}
|
||||
return [accessToken, null];
|
||||
}
|
||||
Reference in New Issue
Block a user