diff --git a/README.md b/README.md index 4e8ea70..c94286c 100644 --- a/README.md +++ b/README.md @@ -65,5 +65,6 @@ Select a session fieldbook, prepare scenes and link campaign NPCs, accessible jo 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. -## 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. + +## Keycloak MCP user authorization +The MCP uses Keycloak for OAuth issuance via confidential authorization-code flow with S256 PKCE. ChatGPT client: `dnd-character-mcp` in dev realm, exact redirect `https://chatgpt.com/connector_platform_oauth_redirect`. The app verifies signed Keycloak JWTs via JWKS and accepts only tokens with `azp=dnd-character-mcp`, validates issuer and expiration. The authenticated user's Keycloak `sub` is used directly as sheet/campaign owner, matching the PWA. Machine/service account tokens are not supported. OAuth registration, authorization, consent and token endpoints are on Keycloak, not D&D; legacy in-app OAuth source remains inert. The website retains its separate confidential client and browser session. The MCP application does not mint tokens or store OAuth secrets. diff --git a/server/mcp-auth.mjs b/server/mcp-auth.mjs index 266a28c..cb62b4b 100644 --- a/server/mcp-auth.mjs +++ b/server/mcp-auth.mjs @@ -7,8 +7,9 @@ export function createKeycloakVerifier({issuer,allowedClient,jwks}){ 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}; + if(payload.preferred_username?.startsWith('service-account-')||payload.typ==='Service')throw new Error('Machine tokens are not valid for personal sheets'); + // Keycloak delegated user subject is the same identity used by the PWA. + return {sub:payload.sub,scopes:['characters:read','characters:write'],clientId:payload.azp,expiresAt:payload.exp}; }; } export function installKeycloakMcpAuth(app,{origin,issuer,allowedClient,verify}){ diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index b8f7692..0a1f6fa 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -14,13 +14,13 @@ async function tokens(){ 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']); + assert.equal(identity.sub,'machine-1');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('MCP middleware advertises Keycloak, denies anonymous tokens and isolates machine storage',async()=>{ +test('MCP middleware advertises Keycloak, denies anonymous tokens and retains user storage isolation',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; @@ -34,11 +34,11 @@ test('MCP middleware advertises Keycloak, denies anonymous tokens and isolates m 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('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()=>{ +test('SDK tools use delegated Keycloak user 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);