@kya-os/mcp-i
Version:
The TypeScript MCP framework with identity features built-in
290 lines (289 loc) • 11.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createOAuthRouter = createOAuthRouter;
exports.createOAuthMiddleware = createOAuthMiddleware;
const express_1 = require("express");
const mcp_i_core_1 = require("@kya-os/mcp-i-core");
function createOAuthRouter(config) {
const router = (0, express_1.Router)();
const { provider, issuerUrl, baseUrl, serviceDocumentationUrl, pathPrefix = "/oauth2", } = config;
router.use((req, res, next) => {
// Apply shared CORS headers for OAuth endpoints
(0, mcp_i_core_1.applyCORSHeaders)(res, mcp_i_core_1.OAUTH_CORS_HEADERS);
if (req.method === "OPTIONS") {
res.sendStatus(200);
return;
}
next();
});
// OAuth 2.0 Protected Resource Metadata (RFC 9728)
router.get("/.well-known/oauth-protected-resource", async (req, res) => {
try {
const baseUrlStr = baseUrl.toString().replace(/\/$/, ""); // Remove trailing slash - formatting issues lol
// thinking of maybe removing the path prefix?
const metadata = {
resource: baseUrlStr,
authorization_servers: [issuerUrl.toString()],
bearer_methods_supported: ["header", "body"],
resource_documentation: serviceDocumentationUrl?.toString(),
introspection_endpoint: `${baseUrlStr}${pathPrefix}/introspect`,
revocation_endpoint: `${baseUrlStr}${pathPrefix}/revoke`,
};
res.json(metadata);
}
catch (error) {
console.error("Error in protected resource metadata endpoint:", error);
res.status(500).json({
error: "server_error",
error_description: "Internal server error",
});
}
});
// OAuth 2.0 Discovery endpoint - authorization server metadata RFC 8414
// maybe serve all the config as customizable? for example for scopes etc
router.get("/.well-known/oauth-authorization-server", async (req, res) => {
try {
const discovery = {
issuer: issuerUrl.toString(),
authorization_endpoint: provider.endpoints.authorizationUrl,
token_endpoint: provider.endpoints.tokenUrl,
revocation_endpoint: provider.endpoints.revocationUrl,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: [
"client_secret_post",
"client_secret_basic",
],
scopes_supported: ["openid", "profile", "email"],
// PKCE support (RFC 7636) - S256 mandatory for security
code_challenge_methods_supported: ["S256"],
// DCR is mandatory - all clients must register
// this is what MCP recommends doing to handle the entire OAuth flow
// cause we're not supporting manually setting up the client
registration_endpoint: `${baseUrl.toString().replace(/\/$/, "")}${pathPrefix}/register`,
...(serviceDocumentationUrl && {
service_documentation: serviceDocumentationUrl.toString(),
}),
};
res.json(discovery);
}
catch (error) {
console.error("Error in discovery endpoint:", error);
res.status(500).json({
error: "server_error",
error_description: "Internal server error",
});
}
});
// following endpoints we're redirecting to the external authorization server
router.get(`${pathPrefix}/authorize`, async (req, res) => {
try {
const params = {
response_type: req.query.response_type,
client_id: req.query.client_id,
redirect_uri: req.query.redirect_uri,
scope: req.query.scope,
state: req.query.state,
// PKCE parameters (RFC 7636)
code_challenge: req.query.code_challenge,
code_challenge_method: req.query.code_challenge_method,
};
if (!params.response_type || !params.client_id || !params.redirect_uri) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing required parameters",
});
return;
}
// PKCE params validation
if (!params.code_challenge || !params.code_challenge_method) {
res.status(400).json({
error: "invalid_request",
error_description: "PKCE parameters (code_challenge, code_challenge_method) are required",
});
return;
}
// only allow S256 method
if (params.code_challenge_method !== "S256") {
res.status(400).json({
error: "invalid_request",
error_description: "Only S256 code challenge method is supported",
});
return;
}
// let the provider handle the authorization
const authUrl = await provider.authorize(params);
res.redirect(authUrl);
}
catch (error) {
console.error("Error in authorize endpoint:", error);
const oauthError = extractOAuthError(error);
res.status(400).json(oauthError);
}
});
router.post(`${pathPrefix}/token`, async (req, res) => {
try {
const params = {
grant_type: req.body.grant_type,
client_id: req.body.client_id,
client_secret: req.body.client_secret,
code: req.body.code,
redirect_uri: req.body.redirect_uri,
refresh_token: req.body.refresh_token,
// PKCE parameter (RFC 7636)
code_verifier: req.body.code_verifier,
};
if (!params.grant_type || !params.client_id) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing required parameters",
});
return;
}
// PKCE is mandatory for authorization_code grant
if (params.grant_type === "authorization_code" && !params.code_verifier) {
res.status(400).json({
error: "invalid_request",
error_description: "code_verifier is required for authorization_code grant (PKCE)",
});
return;
}
const tokenResponse = await provider.token(params);
res.json(tokenResponse);
}
catch (error) {
console.error("Error in token endpoint:", error);
const oauthError = extractOAuthError(error);
res.status(400).json(oauthError);
}
});
router.post(`${pathPrefix}/revoke`, async (req, res) => {
try {
const params = {
token: req.body.token,
token_type_hint: req.body.token_type_hint,
client_id: req.body.client_id,
client_secret: req.body.client_secret,
};
if (!params.token) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing token parameter",
});
return;
}
await provider.revoke(params);
res.status(200).end();
}
catch (error) {
console.error("Error in revoke endpoint:", error);
const oauthError = extractOAuthError(error);
res.status(400).json(oauthError);
}
});
router.post(`${pathPrefix}/introspect`, async (req, res) => {
try {
const token = req.body.token;
if (!token) {
res.status(400).json({
error: "invalid_request",
error_description: "Missing token parameter",
});
return;
}
const accessToken = await provider.verifyAccessToken(token);
res.json({
active: true,
client_id: accessToken.clientId,
scope: accessToken.scopes.join(" "),
exp: accessToken.expiresAt
? Math.floor(accessToken.expiresAt.getTime() / 1000)
: undefined,
});
}
catch (error) {
console.error("Error in introspect endpoint:", error);
// return active: false for invalid tokens
res.json({ active: false });
}
});
router.all(`${pathPrefix}/register`, async (req, res) => {
try {
if (req.method === "GET") {
// redirect to the external provider's registration page
res.redirect(provider.endpoints.registerUrl);
return;
}
// proxy to the external provider's registration page
const response = await fetch(provider.endpoints.registerUrl, {
method: req.method,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(req.headers["user-agent"] && {
"User-Agent": req.headers["user-agent"],
}),
},
body: JSON.stringify(req.body),
});
const registrationData = await response.json();
res.status(response.status).json(registrationData);
}
catch (error) {
console.error("Error in registration endpoint:", error);
res.status(500).json({
error: "server_error",
error_description: "Failed to register client",
});
}
});
return router;
}
// create middleware for protecting routes
function createOAuthMiddleware(provider) {
return async (req, res, next) => {
try {
const authHeader = req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({
error: "invalid_token",
error_description: "Missing or malformed Authorization header",
});
return;
}
const token = authHeader.slice("Bearer ".length).trim();
if (!token) {
res.status(401).json({
error: "invalid_token",
error_description: "Missing access token",
});
return;
}
const accessToken = await provider.verifyAccessToken(token);
req.oauth = {
token: accessToken.token,
clientId: accessToken.clientId,
scopes: accessToken.scopes,
expiresAt: accessToken.expiresAt,
};
next();
}
catch (error) {
console.error("Error in OAuth middleware:", error);
res.status(401).json({
error: "invalid_token",
error_description: "Invalid or expired token",
});
}
};
}
// helper function to extract OAuth errors pretty self explanatory
function extractOAuthError(error) {
if (error && error.oauth) {
return error.oauth;
}
return {
error: "server_error",
error_description: error?.message || "Internal server error",
};
}