diff --git a/README.md b/README.md index fef5b88..4e8ea70 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,6 @@ Select a session fieldbook, prepare scenes and link campaign NPCs, accessible jo ## 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. -## 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`. - -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. +## 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:: 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. diff --git a/server/index.mjs b/server/index.mjs index 88d4889..ffb9a65 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -2,7 +2,7 @@ import {Catalogue,installCatalogue} from './catalogue.mjs'; import express from 'express'; import {Campaigns,installCampaigns} from './campaigns.mjs'; import {productionStorage} from './storage.mjs'; -import {CharacterOAuth} from './oauth.mjs'; +import {installKeycloakMcpAuth} from './mcp-auth.mjs'; import {installCharacters} from './characters.mjs'; import {installMcp} from './mcp.mjs'; 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.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/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.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.get('/auth/signed-out',(_,res)=>res.type('html').send('Signed out

Signed out of Character

Your character saves remain in your account and on this device.

Sign in with Arrowstack')); -// The static OAuth client is optional. Configure BOTH values via the secure -// 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}); +const mcpAuth=installKeycloakMcpAuth(app,{origin,issuer:process.env.MCP_KEYCLOAK_ISSUER||issuer,allowedClient:process.env.MCP_KEYCLOAK_CLIENT_ID}); installCharacters(app,{storage,origin}); installCampaigns(app,{campaigns,origin}); installCatalogue(app,{catalogue}); diff --git a/server/mcp-auth.mjs b/server/mcp-auth.mjs new file mode 100644 index 0000000..266a28c --- /dev/null +++ b/server/mcp-auth.mjs @@ -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'})} + }; +} diff --git a/server/oauth.mjs b/server/oauth.mjs index 3476b32..a85c3c1 100644 --- a/server/oauth.mjs +++ b/server/oauth.mjs @@ -1,69 +1 @@ -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,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(`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}); - } -} +// Retired: all MCP OAuth issuance is delegated to Keycloak; this module exports nothing. diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 7eebade..f4a3742 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,57 +1,48 @@ 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 {Client} from '@modelcontextprotocol/sdk/client/index.js'; -import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js'; -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'; -const origin='https://dnd.example.test',codec=sessionCodec('test-secret-long-enough','test'); +import express from 'express';import {once} from 'node:events';import {newDb} from 'pg-mem'; +import {generateKeyPair,exportJWK,SignJWT,createLocalJWKSet} from 'jose'; +import {Storage,Conflict} from '../server/storage.mjs';import {newCharacter} from '../src/model.ts'; +import {characterMcp,installMcp} from '../server/mcp.mjs';import {createKeycloakVerifier,installKeycloakMcpAuth} from '../server/mcp-auth.mjs'; +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} -const hash=async(s:string)=>(await import('node:crypto')).createHash('sha256').update(s).digest('hex'); -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)}); -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));}); -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()])}); -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(resolve=>server.close(()=>resolve()))}}); -test('full consent → PKCE exchange → HTTP MCP call works and rejects wrong verifier',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',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{ - const c=await p.clientsStore.registerClient({redirect_uris:['https://chatgpt.com/callback'],token_endpoint_auth_method:'none'}); - const {createHash}=await import('node:crypto');const verifier='v'.repeat(64),challenge=createHash('sha256').update(verifier).digest('base64url'); - 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); - 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(resolve=>server.close(()=>resolve()))} +async function tokens(){ + const {privateKey,publicKey}=await generateKeyPair('RS256'),pub=await exportJWK(publicKey);pub.kid='test-key';pub.alg='RS256'; + 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); + return {verify:createKeycloakVerifier({issuer,allowedClient:clientId,jwks:createLocalJWKSet({keys:[pub]})}),sign}; +} +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)}); +test('Keycloak JWKS verifier rejects incorrect client, issuer, expired and unsigned tokens',async()=>{ + const {verify,sign}=await tokens(),good=await sign();const identity=await verify(good); + assert.match(identity.sub,/^mcp-machine:dnd-character-mcp:/);assert.deepEqual(identity.scopes,['characters:read','characters:write']); + await assert.rejects(verify(await sign({azp:'untrusted-client'}))); + await assert.rejects(verify(await sign({iss:'https://evil.example'}))); + await assert.rejects(verify(await sign({exp:-30}))); + await assert.rejects(verify('not-a-jwt')); }); - - -test('predefined confidential ChatGPT OAuth client accepts secret with PKCE and rejects wrong secrets and redirects',async()=>{ - 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 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}`; +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}); + 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{ - const found=await p.clientsStore.getClient(predefined.client_id);assert.equal(found?.client_secret,predefined.client_secret); - assert.equal(await p.clientsStore.getClient('bogus'),undefined); - const {createHash}=await import('node:crypto'),verifier='v'.repeat(64),challenge=createHash('sha256').update(verifier).digest('base64url'); - 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 rejected=await fetch(base+'/authorize?'+new URLSearchParams({...params,redirect_uri:'https://evil.example/callback'}),{redirect:'manual'}); - assert.notEqual(rejected.status,302); - const started=await fetch(base+'/authorize?'+new URLSearchParams(params),{redirect:'manual'});assert.equal(started.status,302); - const location=started.headers.get('location')!;assert.match(location,/^\/mcp\/consent\?ticket=/,'Unexpected authorize redirect: '+location.slice(0,130)); - 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)); - const ticket=html.match(/name="ticket" value="([^"]+)"/)![1],nonce=html.match(/name="nonce" value="([^"]+)"/)![1],csrf=consent.headers.get('set-cookie')!.split(';')[0]; - 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'}); - 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'}); - 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 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 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 tokens=await exchanged.json();assert.equal((await p.verifyAccessToken(tokens.access_token)).extra.sub,'alice'); - 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); - }finally{await new Promise(resolve=>server.close(()=>resolve()))} + const metadata=await(await fetch(base+'/.well-known/oauth-protected-resource/mcp')).json();assert.deepEqual(metadata.authorization_servers,[issuer]); + for(const path of ['/authorize','/token','/register','/mcp/consent'])assert.equal((await fetch(base+path)).status,404,path); + const payload={jsonrpc:'2.0',id:1,method:'tools/list',params:{}}; + 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)}); + assert.equal((await call()).status,401); + assert.equal((await call('Bearer '+await sign({azp:'other'}))).status,401); + assert.equal((await call('Bearer '+await sign())).status,200); + const pc=newCharacter();await s.put('browser-human',pc.id,pc,0); + assert.equal((await s.list('mcp-machine:dnd-character-mcp:machine-1')).length,0); + assert.equal((await s.list('browser-human')).length,1); + }finally{await new Promise(resolve=>server.close(resolve))} +}); +test('SDK tools preserve machine ownership and revision enforcement',async()=>{ + const s=await storage(),subject='mcp-machine:dnd-character-mcp:machine-1',c=newCharacter();await s.put(subject,c.id,c,0); + const server=characterMcp({storage:s,sub:subject,scopes:['characters:read','characters:write']}); + const client=new Client({name:'test',version:'1'}),[a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b); + try{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:-1}});assert.equal(hp.isError,undefined); + 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()} });