65 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 and campaigns you belong to, within your campaign roles. Private character notes are never shared with the party. DM secrets require a DM role, and changing another player’s saved HP requires their explicit delegation.</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});
}
}