Mirror Keycloak-authorized D&D MCP without in-app OAuth

This commit is contained in:
Admin 2026-10-08 04:41:20 +00:00
parent 733fa7dd4b
commit da08a36ba7
5 changed files with 78 additions and 138 deletions

View File

@ -64,8 +64,6 @@ Select a session fieldbook, prepare scenes and link campaign NPCs, accessible jo
## Coin purse ## Coin purse
Gear tracks gold (gp), silver (sp) and copper (cp) separately, with integer add/spend controls and direct amount editing. Total value uses copper arithmetic (100 CP = 10 SP = 1 GP) without exchanging denominations. Currency is an optional additive sheet field, retained when older clients omit it. Existing inventory notes are untouched; no free-text coin amounts are imported automatically. Currency remains private in party projections and is included in account sync, backups and character MCP reads/updates. Gear tracks gold (gp), silver (sp) and copper (cp) separately, with integer add/spend controls and direct amount editing. Total value uses copper arithmetic (100 CP = 10 SP = 1 GP) without exchanging denominations. Currency is an optional additive sheet field, retained when older clients omit it. Existing inventory notes are untouched; no free-text coin amounts are imported automatically. Currency remains private in party projections and is included in account sync, backups and character MCP reads/updates.
## ChatGPT predefined confidential OAuth client
ChatGPT installations that require a Client ID and Client Secret use the server's predefined confidential client rather than DCR. The MCP OAuth issuer remains `https://dnd.arrowstack.dev`, **not** Keycloak; Keycloak only authenticates the person at the consent step. Configure `MCP_CHATGPT_CLIENT_ID` and `MCP_CHATGPT_CLIENT_SECRET` in Coolify through the secure Arrowstack Keycloak-client secret handoff. Use an independent Keycloak client `dnd-character-chatgpt` with all Keycloak login/grant flows disabled; it serves as the secret's management and rotation home, not as the OAuth issuer. The backend reads its credential pair to authenticate the predefined ChatGPT client at `/token`. ## Keycloak MCP auth (current)
The MCP uses the Template Studio pattern: Keycloak issues OAuth tokens, and the D&D app verifies JWT signatures, issuer and exact authorized azp client ID via JWKS. The protected-resource metadata advertises the Keycloak realm. There are no in-app OAuth issuer, token, consent or registration routes. Client dnd-character-mcp is a confidential Keycloak service account, with its secret in Keycloak and handed to Coolify. Its signed sub maps to a separate mcp-machine:<client>:<sub> sheet and campaign namespace. It cannot access browser users' saved sheets. All holders of the same machine client share one isolated service account workspace; accessing a player's sheets requires a separate user-delegation design. Older OAuth database tables and inert legacy source remain for non-destructive compatibility.
Redirect URI is exactly `https://chatgpt.com/connector_platform_oauth_redirect`, auth method `client_secret_post`, required S256 PKCE, authorization-code flow. Client credentials must **never** appear in source control or application logs. In Keycloak Admin Console → Clients → dnd-character-chatgpt → Credentials, retrieve the secret privately for ChatGPT's plugin form. Do not reuse the website's `dnd-character` secret. Upon rotation, hand the changed secret back to Coolify and redeploy. DCR remains available to clients that support it.

View File

@ -2,7 +2,7 @@ import {Catalogue,installCatalogue} from './catalogue.mjs';
import express from 'express'; import express from 'express';
import {Campaigns,installCampaigns} from './campaigns.mjs'; import {Campaigns,installCampaigns} from './campaigns.mjs';
import {productionStorage} from './storage.mjs'; import {productionStorage} from './storage.mjs';
import {CharacterOAuth} from './oauth.mjs'; import {installKeycloakMcpAuth} from './mcp-auth.mjs';
import {installCharacters} from './characters.mjs'; import {installCharacters} from './characters.mjs';
import {installMcp} from './mcp.mjs'; import {installMcp} from './mcp.mjs';
import * as oidc from 'openid-client'; import * as oidc from 'openid-client';
@ -21,22 +21,12 @@ const readCookie=(req,name)=>{const raw=(req.headers.cookie||'').split(';').map(
app.use((req,res,next)=>{res.set({'X-Content-Type-Options':'nosniff','Referrer-Policy':'same-origin','Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",'Cache-Control':'no-store'});next()}); app.use((req,res,next)=>{res.set({'X-Content-Type-Options':'nosniff','Referrer-Policy':'same-origin','Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",'Cache-Control':'no-store'});next()});
app.get('/health',(_,res)=>res.json({status:'ok',version:'0.8.0',catalogue:catalogue.summary,revision:process.env.SOURCE_COMMIT||'unknown'})); app.get('/health',(_,res)=>res.json({status:'ok',version:'0.8.0',catalogue:catalogue.summary,revision:process.env.SOURCE_COMMIT||'unknown'}));
app.get('/auth/login',async(req,res,next)=>{try{const pkce=oidc.randomPKCECodeVerifier(),state=oidc.randomState(),nonce=oidc.randomNonce();cookie(res,'__Host-character-flow',await codec.seal({purpose:'character-flow',pkce,state,nonce},600),600);const url=oidc.buildAuthorizationUrl(await provider(),{redirect_uri:`${origin}/auth/callback`,scope:'openid profile',state,nonce,code_challenge:await oidc.calculatePKCECodeChallenge(pkce),code_challenge_method:'S256'});res.redirect(url.href)}catch(e){next(e)}}); app.get('/auth/login',async(req,res,next)=>{try{const pkce=oidc.randomPKCECodeVerifier(),state=oidc.randomState(),nonce=oidc.randomNonce();cookie(res,'__Host-character-flow',await codec.seal({purpose:'character-flow',pkce,state,nonce},600),600);const url=oidc.buildAuthorizationUrl(await provider(),{redirect_uri:`${origin}/auth/callback`,scope:'openid profile',state,nonce,code_challenge:await oidc.calculatePKCECodeChallenge(pkce),code_challenge_method:'S256'});res.redirect(url.href)}catch(e){next(e)}});
app.get('/auth/callback',async(req,res,next)=>{try{const flow=await codec.open(readCookie(req,'__Host-character-flow'));cookie(res,'__Host-character-flow','',0);if(!flow||typeof flow.pkce!=='string'||typeof flow.state!=='string'||typeof flow.nonce!=='string')return res.status(400).send('Sign-in expired. Start again at /auth/login.');const url=new URL(req.originalUrl,origin);const tokens=await oidc.authorizationCodeGrant(await provider(),url,{pkceCodeVerifier:flow.pkce,expectedState:flow.state,expectedNonce:flow.nonce});const claims=tokens.claims();if(!claims?.sub)throw new Error('Missing identity');cookie(res,'__Host-character-session',await codec.seal({purpose:'character-session',sub:claims.sub,name:claims.name||claims.preferred_username||'Adventurer'}),43200);const back=await codec.open(readCookie(req,'__Host-character-mcp-return'));cookie(res,'__Host-character-mcp-return','',0);res.redirect(back?.purpose==='mcp-return'?`/mcp/consent?ticket=${encodeURIComponent(back.ticket)}`:'/')}catch(e){next(e)}}); app.get('/auth/callback',async(req,res,next)=>{try{const flow=await codec.open(readCookie(req,'__Host-character-flow'));cookie(res,'__Host-character-flow','',0);if(!flow||typeof flow.pkce!=='string'||typeof flow.state!=='string'||typeof flow.nonce!=='string')return res.status(400).send('Sign-in expired. Start again at /auth/login.');const url=new URL(req.originalUrl,origin);const tokens=await oidc.authorizationCodeGrant(await provider(),url,{pkceCodeVerifier:flow.pkce,expectedState:flow.state,expectedNonce:flow.nonce});const claims=tokens.claims();if(!claims?.sub)throw new Error('Missing identity');cookie(res,'__Host-character-session',await codec.seal({purpose:'character-session',sub:claims.sub,name:claims.name||claims.preferred_username||'Adventurer'}),43200);res.redirect('/')}catch(e){next(e)}});
app.use(async(req,res,next)=>{const session=await codec.open(readCookie(req,'__Host-character-session'));req.identity=session?.sub&&typeof session.name==='string'&&(!session.purpose||session.purpose==='character-session')?session:null;next()}); app.use(async(req,res,next)=>{const session=await codec.open(readCookie(req,'__Host-character-session'));req.identity=session?.sub&&typeof session.name==='string'&&(!session.purpose||session.purpose==='character-session')?session:null;next()});
app.get('/api/session',(req,res)=>req.identity?res.json({sub:req.identity.sub,name:req.identity.name,expiresAt:req.identity.exp*1000}):res.status(401).json({error:'Sign in required'})); app.get('/api/session',(req,res)=>req.identity?res.json({sub:req.identity.sub,name:req.identity.name,expiresAt:req.identity.exp*1000}):res.status(401).json({error:'Sign in required'}));
app.post('/auth/logout',(req,res)=>{if(req.get('origin')!==origin)return res.sendStatus(403);cookie(res,'__Host-character-session','',0);cookie(res,'__Host-character-flow','',0);res.redirect('/auth/signed-out')}); app.post('/auth/logout',(req,res)=>{if(req.get('origin')!==origin)return res.sendStatus(403);cookie(res,'__Host-character-session','',0);cookie(res,'__Host-character-flow','',0);res.redirect('/auth/signed-out')});
app.get('/auth/signed-out',(_,res)=>res.type('html').send('<!doctype html><meta name="viewport" content="width=device-width"><title>Signed out</title><h1>Signed out of Character</h1><p>Your character saves remain in your account and on this device.</p><a href="/auth/login">Sign in with Arrowstack</a>')); app.get('/auth/signed-out',(_,res)=>res.type('html').send('<!doctype html><meta name="viewport" content="width=device-width"><title>Signed out</title><h1>Signed out of Character</h1><p>Your character saves remain in your account and on this device.</p><a href="/auth/login">Sign in with Arrowstack</a>'));
// The static OAuth client is optional. Configure BOTH values via the secure const mcpAuth=installKeycloakMcpAuth(app,{origin,issuer:process.env.MCP_KEYCLOAK_ISSUER||issuer,allowedClient:process.env.MCP_KEYCLOAK_CLIENT_ID});
// Keycloak-to-Coolify handoff; never commit or log the client secret.
const mcpClientId=process.env.MCP_CHATGPT_CLIENT_ID;
const mcpClientSecret=process.env.MCP_CHATGPT_CLIENT_SECRET;
if(Boolean(mcpClientId)!==Boolean(mcpClientSecret))throw new Error('Incomplete ChatGPT MCP OAuth client configuration');
const predefinedClient=mcpClientId&&mcpClientSecret?{
client_id:mcpClientId,client_secret:mcpClientSecret,
redirect_uris:['https://chatgpt.com/connector_platform_oauth_redirect']
}:undefined;
const oauth=new CharacterOAuth({storage,codec,origin,predefinedClient});
const mcpAuth=oauth.install(app,{cookie,readCookie});
installCharacters(app,{storage,origin}); installCharacters(app,{storage,origin});
installCampaigns(app,{campaigns,origin}); installCampaigns(app,{campaigns,origin});
installCatalogue(app,{catalogue}); installCatalogue(app,{catalogue});

29
server/mcp-auth.mjs Normal file
View File

@ -0,0 +1,29 @@
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');
// A service account is not a human user. Isolate it from all browser sheets.
return {sub:'mcp-machine:'+payload.azp+':'+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'})}
};
}

View File

@ -1,69 +1 @@
import {randomUUID,createHash} from 'node:crypto'; // Retired: all MCP OAuth issuance is delegated to Keycloak; this module exports nothing.
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,predefinedClient}){this.storage=storage;this.codec=codec;this.origin=origin;this.resource=new URL('/mcp',origin);
// A predefined ChatGPT OAuth client uses our own authorization server, not
// Keycloak's authorization/token endpoints. The secret is injected from Coolify.
if(predefinedClient&&(!predefinedClient.client_id||!predefinedClient.client_secret||!Array.isArray(predefinedClient.redirect_uris)||predefinedClient.redirect_uris.length===0))throw new Error('Invalid predefined MCP client configuration');
this.predefinedClient=predefinedClient?{client_name:'ChatGPT — Arrowstack Character',grant_types:['authorization_code','refresh_token'],response_types:['code'],token_endpoint_auth_method:'client_secret_post',...predefinedClient}:null;
this.clientsStore={getClient:id=>id===this.predefinedClient?.client_id?this.predefinedClient: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.clientsStore.getClient(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});
}
}

View File

@ -1,57 +1,48 @@
import {test} from 'node:test';import assert from 'node:assert/strict'; import {test} from 'node:test';import assert from 'node:assert/strict';
import {newDb} from 'pg-mem';import express from 'express';import {once} from 'node:events'; import express from 'express';import {once} from 'node:events';import {newDb} from 'pg-mem';
import {Client} from '@modelcontextprotocol/sdk/client/index.js'; import {generateKeyPair,exportJWK,SignJWT,createLocalJWKSet} from 'jose';
import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js'; import {Storage,Conflict} from '../server/storage.mjs';import {newCharacter} from '../src/model.ts';
import {Storage,Conflict} from '../server/storage.mjs';import {CharacterOAuth} from '../server/oauth.mjs';import {characterMcp,installMcp} from '../server/mcp.mjs';import {sessionCodec} from '../server/auth.mjs';import {newCharacter} from '../src/model.ts';import {installCharacters} from '../server/characters.mjs'; import {characterMcp,installMcp} from '../server/mcp.mjs';import {createKeycloakVerifier,installKeycloakMcpAuth} from '../server/mcp-auth.mjs';
const origin='https://dnd.example.test',codec=sessionCodec('test-secret-long-enough','test'); import {Client} from '@modelcontextprotocol/sdk/client/index.js';import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js';
const origin='https://dnd.example.test',issuer='https://auth.example.test/realms/dev',clientId='dnd-character-mcp';
async function storage(){const pg=newDb().adapters.createPg();const s=new Storage(new pg.Pool());await s.init();return s} async function storage(){const pg=newDb().adapters.createPg();const s=new Storage(new pg.Pool());await s.init();return s}
const hash=async(s:string)=>(await import('node:crypto')).createHash('sha256').update(s).digest('hex'); async function tokens(){
test('persistent sheets isolate accounts and reject stale updates/deletions',async()=>{const s=await storage(),c=newCharacter();await s.put('alice',c.id,c,0);assert.equal((await s.list('bob')).length,0);await assert.rejects(s.put('bob',c.id,c,1),Conflict);const updated=await s.put('alice',c.id,{...c,name:'Updated'},1);assert.equal(updated.revision,2);await assert.rejects(s.put('alice',c.id,null,1,true),Conflict);assert.equal((await s.get('alice',c.id)).character.name,'Updated');const deleted=await s.put('alice',c.id,null,2,true);assert.equal(deleted.deleted,true);assert.equal((await s.list('alice')).length,1)}); const {privateKey,publicKey}=await generateKeyPair('RS256'),pub=await exportJWK(publicKey);pub.kid='test-key';pub.alg='RS256';
test('OAuth codes bind client, redirect, resource, scope and one-time use; refresh rotates; revocation rejects access',async()=>{const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin});const client={client_id:'client',redirect_uris:['https://chatgpt.com/callback']};const payload={purpose:'mcp-code',clientId:'client',sub:'alice',challenge:'abc',redirectUri:client.redirect_uris[0],scopes:['characters:read'],sessionExpiry:Math.floor(Date.now()/1000)+3600,nonce:'unique'};const code=await codec.seal(payload,300);await s.code(await hash(code),{});assert.equal(await p.challengeForAuthorizationCode(client,code),'abc');await assert.rejects(p.challengeForAuthorizationCode({client_id:'other'},code));await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,'https://evil.test',p.resource));await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,new URL('https://other.test/mcp')));const tokens=await p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,p.resource);assert.equal((await p.verifyAccessToken(tokens.access_token)).extra.sub,'alice');await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,p.resource));await assert.rejects(p.verifyAccessToken(await codec.seal({sub:'alice'},300)));await assert.rejects(p.exchangeRefreshToken(client,tokens.refresh_token,['characters:write'],p.resource));const renewed=await p.exchangeRefreshToken(client,tokens.refresh_token,undefined,p.resource);await assert.rejects(p.exchangeRefreshToken(client,tokens.refresh_token,undefined,p.resource));await p.revokeToken(client,{token:renewed.access_token});await assert.rejects(p.verifyAccessToken(tokens.access_token));}); const sign=async({azp=clientId,sub='machine-1',iss=issuer,exp=3600}={})=>new SignJWT({azp,sub}).setProtectedHeader({alg:'RS256',kid:'test-key'}).setIssuer(iss).setIssuedAt().setExpirationTime(Math.floor(Date.now()/1000)+exp).sign(privateKey);
test('MCP SDK lists tools, performs account-scoped HP changes, rejects stale revisions and denies read-only writes',async()=>{const s=await storage(),c=newCharacter();c.hp.temp=5;await s.put('alice',c.id,c,0);async function connect(sub:string,scopes:string[]){const server=characterMcp({storage:s,sub,scopes}),client=new Client({name:'test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const client=await connect('alice',['characters:read','characters:write']);assert.equal((await client.listTools()).tools.length,17);const hp=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-7}});assert.equal(hp.isError,undefined);assert.deepEqual((await s.get('alice',c.id)).character.hp,{current:8,max:10,temp:0});const stale=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(stale.isError,true);const bob=await connect('bob',['characters:read']);const missing=await bob.callTool({name:'get_character',arguments:{id:c.id}});assert.equal(missing.isError,true);const readonly=await connect('alice',['characters:read']);assert.equal((await readonly.callTool({name:'create_character',arguments:{name:'Denied'}})).isError,true);await Promise.all([client.close(),bob.close(),readonly.close()])}); return {verify:createKeycloakVerifier({issuer,allowedClient:clientId,jwks:createLocalJWKSet({keys:[pub]})}),sign};
test('HTTP discovery, bearer denial, PKCE enforcement and API origin protection',async()=>{const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin}),app=express();app.use(express.json());app.use(express.urlencoded({extended:false}));app.use((req,res,next)=>{req.identity={sub:'alice'};next()});const auth=p.install(app,{cookie:()=>{},readCookie:()=>''});installMcp(app,{storage:s,auth,origin});installCharacters(app,{storage:s,origin});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${server.address().port}`;try{const denied=await fetch(base+'/mcp',{method:'POST',headers:{'content-type':'application/json'},body:'{}'});assert.equal(denied.status,401);assert.match(denied.headers.get('www-authenticate')||'',/oauth-protected-resource\/mcp/);const metadata=await(await fetch(base+'/.well-known/oauth-protected-resource/mcp')).json();assert.equal(metadata.resource,origin+'/mcp');const registered=await fetch(base+'/register',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({client_name:'Tests',redirect_uris:['https://chatgpt.com/callback'],token_endpoint_auth_method:'none'})});assert.equal(registered.status,201);const client=await registered.json();const authorize=await fetch(base+'/authorize?'+new URLSearchParams({client_id:client.client_id,redirect_uri:client.redirect_uris[0],response_type:'code',resource:origin+'/mcp',code_challenge:'a'.repeat(43),code_challenge_method:'S256'}),{redirect:'manual'});assert.equal(authorize.status,302);const badPkce=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({client_id:client.client_id,grant_type:'authorization_code',code:'invalid',code_verifier:'wrong',redirect_uri:client.redirect_uris[0],resource:origin+'/mcp'})});assert.equal(badPkce.status,400);const c=newCharacter();const blocked=await fetch(base+'/api/characters/'+c.id,{method:'PUT',headers:{'content-type':'application/json','origin':'https://evil.test'},body:JSON.stringify({character:c,revision:0})});assert.equal(blocked.status,403)}finally{await new Promise<void>(resolve=>server.close(()=>resolve()))}}); }
test('full consent → PKCE exchange → HTTP MCP call works and rejects wrong verifier',async()=>{ test('account sheets remain isolated and stale revisions fail',async()=>{const s=await storage(),c=newCharacter();await s.put('alice',c.id,c,0);assert.equal((await s.list('bob')).length,0);await assert.rejects(s.put('bob',c.id,c,1),Conflict);await s.put('alice',c.id,{...c,name:'Updated'},1);await assert.rejects(s.put('alice',c.id,null,1,true),Conflict)});
const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin}),app=express();app.use(express.json());app.use(express.urlencoded({extended:false}));app.use((req,res,next)=>{req.identity={sub:'alice',name:'Alice',exp:Math.floor(Date.now()/1000)+3600};next()}); test('Keycloak JWKS verifier rejects incorrect client, issuer, expired and unsigned tokens',async()=>{
const cookie=(res:any,name:string,value:string)=>res.append('Set-Cookie',`${name}=${value}; Path=/; HttpOnly`),readCookie=(req:any,name:string)=>(req.headers.cookie||'').split(';').map((x:string)=>x.trim()).find((x:string)=>x.startsWith(name+'='))?.slice(name.length+1)||''; const {verify,sign}=await tokens(),good=await sign();const identity=await verify(good);
const auth=p.install(app,{cookie,readCookie});installMcp(app,{storage:s,auth,origin});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${server.address().port}`; assert.match(identity.sub,/^mcp-machine:dnd-character-mcp:/);assert.deepEqual(identity.scopes,['characters:read','characters:write']);
try{ await assert.rejects(verify(await sign({azp:'untrusted-client'})));
const c=await p.clientsStore.registerClient({redirect_uris:['https://chatgpt.com/callback'],token_endpoint_auth_method:'none'}); await assert.rejects(verify(await sign({iss:'https://evil.example'})));
const {createHash}=await import('node:crypto');const verifier='v'.repeat(64),challenge=createHash('sha256').update(verifier).digest('base64url'); await assert.rejects(verify(await sign({exp:-30})));
const authorized=await fetch(base+'/authorize?'+new URLSearchParams({client_id:c.client_id,redirect_uri:c.redirect_uris[0],response_type:'code',resource:origin+'/mcp',scope:'characters:read characters:write',state:'test-state',code_challenge:challenge,code_challenge_method:'S256'}),{redirect:'manual'});assert.equal(authorized.status,302); await assert.rejects(verify('not-a-jwt'));
const consent=await fetch(base+authorized.headers.get('location'),{redirect:'manual'}),html=await consent.text();const ticket=html.match(/name="ticket" value="([^"]+)"/)![1],nonce=html.match(/name="nonce" value="([^"]+)"/)![1],csrf=consent.headers.get('set-cookie')!.split(';')[0];
const consentBody=new URLSearchParams({ticket,nonce,decision:'allow'});const denied=await fetch(base+'/mcp/consent',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded','origin':'https://evil.test','cookie':csrf},body:consentBody,redirect:'manual'});assert.equal(denied.status,403);
const approved=await fetch(base+'/mcp/consent',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded','origin':origin,'cookie':csrf},body:consentBody,redirect:'manual'});assert.equal(approved.status,302);const callback=new URL(approved.headers.get('location')!);assert.equal(callback.searchParams.get('state'),'test-state');const code=callback.searchParams.get('code')!;
const tokenBody=(v:string)=>new URLSearchParams({client_id:c.client_id,grant_type:'authorization_code',code,code_verifier:v,redirect_uri:c.redirect_uris[0],resource:origin+'/mcp'});
const wrong=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:tokenBody('x'.repeat(64))});assert.equal(wrong.status,400);
const exchanged=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:tokenBody(verifier)});assert.equal(exchanged.status,200);const tokens=await exchanged.json();
const {StreamableHTTPClientTransport}=await import('@modelcontextprotocol/sdk/client/streamableHttp.js');const client=new Client({name:'http-test',version:'1'});await client.connect(new StreamableHTTPClientTransport(new URL(base+'/mcp'),{requestInit:{headers:{Authorization:'Bearer '+tokens.access_token}}}));assert.equal((await client.listTools()).tools.length,17);const created=await client.callTool({name:'create_character',arguments:{name:'HTTP adventurer'}});assert.equal(created.isError,undefined);assert.equal((await s.list('alice'))[0].character.name,'HTTP adventurer');await client.close();
}finally{await new Promise<void>(resolve=>server.close(()=>resolve()))}
}); });
test('MCP middleware advertises Keycloak, denies anonymous tokens and isolates machine storage',async()=>{
const s=await storage(),{verify,sign}=await tokens(),app=express();app.use(express.json());const auth=installKeycloakMcpAuth(app,{origin,issuer,allowedClient:clientId,verify});
test('predefined confidential ChatGPT OAuth client accepts secret with PKCE and rejects wrong secrets and redirects',async()=>{ installMcp(app,{storage:s,auth,origin});const server=app.listen(0,'127.0.0.1');await once(server,'listening');
const s=await storage(),predefined={client_id:'dnd-chatgpt-predefined',client_secret:'testing-static-client-secret-32',redirect_uris:['https://chatgpt.com/connector_platform_oauth_redirect']},p=new CharacterOAuth({storage:s,codec,origin,predefinedClient:predefined}); const base='http://127.0.0.1:'+server.address().port;
const app=express();app.use(express.json());app.use(express.urlencoded({extended:false}));app.use((req,res,next)=>{req.identity={sub:'alice',name:'Alice',exp:Math.floor(Date.now()/1000)+3600};next()});
const cookie=(res:any,name:string,value:string)=>res.append('Set-Cookie',`${name}=${value}; Path=/; HttpOnly`),readCookie=(req:any,name:string)=>(req.headers.cookie||'').split(';').map((x:string)=>x.trim()).find((x:string)=>x.startsWith(name+'='))?.slice(name.length+1)||'';
const auth=p.install(app,{cookie,readCookie});installMcp(app,{storage:s,auth,origin});const server=app.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${server.address().port}`;
try{ try{
const found=await p.clientsStore.getClient(predefined.client_id);assert.equal(found?.client_secret,predefined.client_secret); const metadata=await(await fetch(base+'/.well-known/oauth-protected-resource/mcp')).json();assert.deepEqual(metadata.authorization_servers,[issuer]);
assert.equal(await p.clientsStore.getClient('bogus'),undefined); for(const path of ['/authorize','/token','/register','/mcp/consent'])assert.equal((await fetch(base+path)).status,404,path);
const {createHash}=await import('node:crypto'),verifier='v'.repeat(64),challenge=createHash('sha256').update(verifier).digest('base64url'); const payload={jsonrpc:'2.0',id:1,method:'tools/list',params:{}};
const params={client_id:predefined.client_id,redirect_uri:predefined.redirect_uris[0],response_type:'code',resource:origin+'/mcp',scope:'characters:read characters:write',state:'confidential-test',code_challenge:challenge,code_challenge_method:'S256'}; const call=authorization=>fetch(base+'/mcp',{method:'POST',headers:{'content-type':'application/json',accept:'application/json, text/event-stream',...(authorization?{authorization}:{})},body:JSON.stringify(payload)});
const rejected=await fetch(base+'/authorize?'+new URLSearchParams({...params,redirect_uri:'https://evil.example/callback'}),{redirect:'manual'}); assert.equal((await call()).status,401);
assert.notEqual(rejected.status,302); assert.equal((await call('Bearer '+await sign({azp:'other'}))).status,401);
const started=await fetch(base+'/authorize?'+new URLSearchParams(params),{redirect:'manual'});assert.equal(started.status,302); assert.equal((await call('Bearer '+await sign())).status,200);
const location=started.headers.get('location')!;assert.match(location,/^\/mcp\/consent\?ticket=/,'Unexpected authorize redirect: '+location.slice(0,130)); const pc=newCharacter();await s.put('browser-human',pc.id,pc,0);
const consent=await fetch(base+location,{redirect:'manual'}),html=await consent.text();assert.equal(consent.status,200,'Consent endpoint response: '+html.slice(0,350));assert.match(html,/name="ticket" value="([^"]+)"/,'Consent HTML missing ticket: '+html.slice(0,350)); assert.equal((await s.list('mcp-machine:dnd-character-mcp:machine-1')).length,0);
const ticket=html.match(/name="ticket" value="([^"]+)"/)![1],nonce=html.match(/name="nonce" value="([^"]+)"/)![1],csrf=consent.headers.get('set-cookie')!.split(';')[0]; assert.equal((await s.list('browser-human')).length,1);
const approved=await fetch(base+'/mcp/consent',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded',origin,cookie:csrf},body:new URLSearchParams({ticket,nonce,decision:'allow'}),redirect:'manual'}); }finally{await new Promise(resolve=>server.close(resolve))}
assert.equal(approved.status,302);const callback=new URL(approved.headers.get('location')!);assert.equal(callback.origin,'https://chatgpt.com');const code=callback.searchParams.get('code')!; });
const body=(secret:string)=>new URLSearchParams({client_id:predefined.client_id,client_secret:secret,grant_type:'authorization_code',code,code_verifier:verifier,redirect_uri:predefined.redirect_uris[0],resource:origin+'/mcp'}); test('SDK tools preserve machine ownership and revision enforcement',async()=>{
const invalid=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:body('wrong-secret')});assert.equal(invalid.status,400); const s=await storage(),subject='mcp-machine:dnd-character-mcp:machine-1',c=newCharacter();await s.put(subject,c.id,c,0);
const missing=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({client_id:predefined.client_id,grant_type:'authorization_code',code,code_verifier:verifier,redirect_uri:predefined.redirect_uris[0],resource:origin+'/mcp'})});assert.equal(missing.status,400); const server=characterMcp({storage:s,sub:subject,scopes:['characters:read','characters:write']});
const exchanged=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:body(predefined.client_secret)});assert.equal(exchanged.status,200); const client=new Client({name:'test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);
const tokens=await exchanged.json();assert.equal((await p.verifyAccessToken(tokens.access_token)).extra.sub,'alice'); try{assert.equal((await client.listTools()).tools.length,17);
const replay=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:body(predefined.client_secret)});assert.equal(replay.status,400); const hp=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(hp.isError,undefined);
}finally{await new Promise<void>(resolve=>server.close(()=>resolve()))} const stale=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(stale.isError,true);
}finally{await client.close()}
}); });