Mirror canonical static confidential ChatGPT OAuth client support from 04a82ff

This commit is contained in:
Admin 2026-10-08 04:00:43 +00:00
parent c2932a1f09
commit c47049753d
4 changed files with 48 additions and 2 deletions

View File

@ -63,3 +63,9 @@ 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`.
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

@ -26,7 +26,16 @@ app.use(async(req,res,next)=>{const session=await codec.open(readCookie(req,'__H
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>'));
const oauth=new CharacterOAuth({storage,codec,origin}); // 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=oauth.install(app,{cookie,readCookie});
installCharacters(app,{storage,origin}); installCharacters(app,{storage,origin});
installCampaigns(app,{campaigns,origin}); installCampaigns(app,{campaigns,origin});

View File

@ -5,7 +5,12 @@ import {requireBearerAuth} from '@modelcontextprotocol/sdk/server/auth/middlewar
const hash=s=>createHash('sha256').update(s).digest('hex'); const hash=s=>createHash('sha256').update(s).digest('hex');
const escape=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const escape=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
export class CharacterOAuth{ 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=>{ 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');} 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)}); 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)});
}};} }};}

View File

@ -27,3 +27,29 @@ test('full consent → PKCE exchange → HTTP MCP call works and rejects wrong v
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(); 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()))} }finally{await new Promise<void>(resolve=>server.close(()=>resolve()))}
}); });
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}`;
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 consent=await fetch(base+started.headers.get('location'),{redirect:'manual'}),html=await consent.text(),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<void>(resolve=>server.close(()=>resolve()))}
});