65 lines
7.1 KiB
JavaScript

import {randomUUID,createHash} from 'node:crypto';
import {InvalidGrantError,InvalidScopeError,InvalidTargetError,InvalidTokenError,InvalidClientMetadataError} from '@modelcontextprotocol/sdk/server/auth/errors.js';
import {mcpAuthRouter} from '@modelcontextprotocol/sdk/server/auth/router.js';
import {requireBearerAuth} from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
const hash=s=>createHash('sha256').update(s).digest('hex');
const escape=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
export class CharacterOAuth{
constructor({storage,codec,origin}){this.storage=storage;this.codec=codec;this.origin=origin;this.resource=new URL('/mcp',origin);this.clientsStore={getClient:id=>storage.client(id),registerClient:async c=>{
for(const uri of c.redirect_uris){const u=new URL(uri);if(u.username||u.password||u.hash||(u.protocol!=='https:'&&!(u.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(u.hostname))))throw new InvalidClientMetadataError('Use HTTPS redirect URLs or loopback HTTP');}
const id=randomUUID();return storage.register(id,{...c,client_id:id,client_id_issued_at:Math.floor(Date.now()/1000),client_name:(c.client_name||'MCP client').slice(0,120)});
}};}
async authorize(client,params,res){
if(params.resource?.href!==this.resource.href)throw new InvalidTargetError('resource must be the MCP endpoint');
const scopes=[...new Set(['characters:read',...(params.scopes||[])])];
if(scopes.some(s=>!['characters:read','characters:write'].includes(s)))throw new InvalidScopeError('Unknown scope');
const ticket=await this.codec.seal({purpose:'mcp-consent',clientId:client.client_id,params:{...params,resource:this.resource.href,scopes}},600);
res.redirect(`/mcp/consent?ticket=${encodeURIComponent(ticket)}`);
}
async readCode(client,code){const p=await this.codec.open(code);if(!p||p.purpose!=='mcp-code'||p.clientId!==client.client_id)throw new InvalidGrantError('Invalid authorization code');return p}
async challengeForAuthorizationCode(client,code){return (await this.readCode(client,code)).challenge}
async exchangeAuthorizationCode(client,code,_verifier,redirectUri,resource){
const p=await this.readCode(client,code);
if(p.redirectUri!==redirectUri||resource?.href!==this.resource.href)throw new InvalidGrantError('Redirect or resource mismatch');
if(!await this.storage.consume(hash(code)))throw new InvalidGrantError('Code expired or already used');
const grant=randomUUID(),payload={sub:p.sub,clientId:p.clientId,scopes:p.scopes};
await this.storage.grant(grant,payload,p.sessionExpiry*1000);return this.tokens(grant,payload,p.sessionExpiry);
}
async tokens(grant,payload,expiry){
const remaining=expiry-Math.floor(Date.now()/1000);if(remaining<=0)throw new InvalidGrantError('Sign in again');
const seconds=Math.min(3600,remaining),access=await this.codec.seal({...payload,purpose:'mcp-access',grant,aud:this.resource.href},seconds);
const refresh=await this.codec.seal({purpose:'mcp-refresh',grant,clientId:payload.clientId,sessionExpiry:expiry,nonce:randomUUID()},remaining);
await this.storage.code(hash(refresh),{},remaining);
return {access_token:access,token_type:'Bearer',expires_in:seconds,refresh_token:refresh,scope:payload.scopes.join(' ')};
}
async exchangeRefreshToken(client,token,scopes,resource){
const p=await this.codec.open(token);if(!p||p.purpose!=='mcp-refresh'||p.clientId!==client.client_id||resource?.href!==this.resource.href)throw new InvalidGrantError('Invalid refresh token');
const grant=await this.storage.readGrant(p.grant);if(!grant)throw new InvalidGrantError('Grant expired or revoked');
if(scopes?.some(s=>!grant.scopes.includes(s)))throw new InvalidScopeError('Cannot expand scope');
if(!await this.storage.consume(hash(token)))throw new InvalidGrantError('Refresh token already used');
return this.tokens(p.grant,{...grant,scopes:scopes||grant.scopes},p.sessionExpiry);
}
async verifyAccessToken(token){const p=await this.codec.open(token);if(!p||p.purpose!=='mcp-access'||p.aud!==this.resource.href||!await this.storage.readGrant(p.grant))throw new InvalidTokenError('Invalid or revoked access token');return {token,clientId:p.clientId,scopes:p.scopes,expiresAt:p.exp,resource:this.resource,extra:{sub:p.sub}}}
async revokeToken(client,{token}){const p=await this.codec.open(token);if(p?.clientId===client.client_id&&['mcp-access','mcp-refresh'].includes(p.purpose))await this.storage.revoke(p.grant)}
install(app,{cookie,readCookie}){
app.use(mcpAuthRouter({provider:this,issuerUrl:new URL(this.origin),resourceServerUrl:this.resource,scopesSupported:['characters:read','characters:write'],resourceName:'Arrowstack Character'}));
app.get('/mcp/consent',async(req,res)=>{
const p=await this.codec.open(req.query.ticket);if(p?.purpose!=='mcp-consent')return res.status(400).send('Authorization expired. Reconnect your MCP client.');
if(!req.identity){cookie(res,'__Host-character-mcp-return',await this.codec.seal({purpose:'mcp-return',ticket:req.query.ticket},600),600);return res.redirect('/auth/login')}
const client=await this.storage.client(p.clientId);if(!client)return res.sendStatus(400);
const nonce=randomUUID();cookie(res,'__Host-character-consent',await this.codec.seal({nonce,sub:req.identity.sub,ticketHash:hash(req.query.ticket)},600),600);
res.type('html').send(`<!doctype html><meta name="viewport" content="width=device-width"><title>Connect Character</title><h1>Connect ${escape(client.client_name)}?</h1><p>This client can ${p.params.scopes.includes('characters:write')?'read and edit':'read'} the characters saved to your Arrowstack account.</p><p>Redirect destination: ${escape(new URL(p.params.redirectUri).origin)}</p><form method="post" action="/mcp/consent"><input type="hidden" name="ticket" value="${escape(req.query.ticket)}"><input type="hidden" name="nonce" value="${nonce}"><button name="decision" value="allow">Allow access</button><button name="decision" value="deny">Cancel</button></form>`);
});
app.post('/mcp/consent',async(req,res)=>{
if(req.get('origin')!==this.origin||!req.identity)return res.sendStatus(403);
const p=await this.codec.open(req.body.ticket),csrf=await this.codec.open(readCookie(req,'__Host-character-consent'));
if(p?.purpose!=='mcp-consent'||!csrf||csrf.nonce!==req.body.nonce||csrf.sub!==req.identity.sub||csrf.ticketHash!==hash(req.body.ticket))return res.sendStatus(400);
cookie(res,'__Host-character-consent','',0);const uri=new URL(p.params.redirectUri);if(p.params.state)uri.searchParams.set('state',p.params.state);
if(req.body.decision!=='allow'){uri.searchParams.set('error','access_denied');return res.redirect(uri.href)}
const code=await this.codec.seal({purpose:'mcp-code',clientId:p.clientId,sub:req.identity.sub,challenge:p.params.codeChallenge,redirectUri:p.params.redirectUri,scopes:p.params.scopes,sessionExpiry:req.identity.exp,nonce:randomUUID()},300);
await this.storage.code(hash(code),{});uri.searchParams.set('code',code);res.redirect(uri.href);
});
return requireBearerAuth({verifier:this,requiredScopes:['characters:read'],resourceMetadataUrl:new URL('/.well-known/oauth-protected-resource/mcp',this.origin).href,expectedResource:this.resource});
}
}