31 lines
2.1 KiB
JavaScript

import {createRemoteJWKSet,jwtVerify} from 'jose';
export function createKeycloakVerifier({issuer,allowedClient,jwks}){
if(!issuer?.startsWith('https://')||!allowedClient)throw new Error('Keycloak MCP issuer and allowed client are required');
const normalized=issuer.replace(/\/$/,'');
const jwkset=jwks||createRemoteJWKSet(new URL(normalized+'/protocol/openid-connect/certs'));
return async token=>{
const {payload}=await jwtVerify(token,jwkset,{issuer:normalized});
if(payload.azp!==allowedClient)throw new Error('Client not allowed');
if(typeof payload.sub!=='string'||!payload.sub)throw new Error('Missing Keycloak subject');
if(payload.preferred_username?.startsWith('service-account-')||payload.typ==='Service')throw new Error('Machine tokens are not valid for personal sheets');
// Keycloak delegated user subject is the same identity used by the PWA.
return {sub:payload.sub,scopes:['characters:read','characters:write'],clientId:payload.azp,expiresAt:payload.exp};
};
}
export function installKeycloakMcpAuth(app,{origin,issuer,allowedClient,verify}){
if(!issuer?.startsWith('https://')||!allowedClient)throw new Error('Keycloak MCP configuration required');
const verifyToken=verify||createKeycloakVerifier({issuer,allowedClient});
const metadata={resource:origin+'/mcp',authorization_servers:[issuer.replace(/\/$/,'')],bearer_methods_supported:['header']};
app.get('/.well-known/oauth-protected-resource',(_,res)=>res.json(metadata));
app.get('/.well-known/oauth-protected-resource/mcp',(_,res)=>res.json(metadata));
return async(req,res,next)=>{
const header=String(req.get('authorization')||'');
if(!header.startsWith('Bearer ')){res.set('WWW-Authenticate','Bearer resource_metadata="'+origin+'/.well-known/oauth-protected-resource/mcp"');return res.status(401).json({error:'mcp_bearer_token_required'})}
try{
const identity=await verifyToken(header.slice(7).trim());
req.auth={token:header.slice(7).trim(),clientId:identity.clientId,scopes:identity.scopes,expiresAt:identity.expiresAt,extra:{sub:identity.sub}};
next();
}catch{res.set('WWW-Authenticate','Bearer error="invalid_token"');res.status(401).json({error:'invalid_mcp_token'})}
};
}