Connect Keycloak-authenticated ChatGPT users to existing character and campaign ownership

This commit is contained in:
Admin 2026-10-08 04:51:56 +00:00
parent d1f09c4855
commit 0fd9ed920f
3 changed files with 10 additions and 8 deletions

View File

@ -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:<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.
## 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.

View File

@ -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}){

View File

@ -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);