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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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(`Connect Character

Connect ${escape(client.client_name)}?

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.

Redirect destination: ${escape(new URL(p.params.redirectUri).origin)}

`); }); 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}); } }