From c47049753dcd788ec796d771eae3453bbf87e548 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 8 Oct 2026 04:00:43 +0000 Subject: [PATCH] Mirror canonical static confidential ChatGPT OAuth client support from 04a82ff --- README.md | 6 ++++++ server/index.mjs | 11 ++++++++++- server/oauth.mjs | 7 ++++++- tests/mcp.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9702247..fef5b88 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,9 @@ 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. diff --git a/server/index.mjs b/server/index.mjs index 51157cb..88d4889 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -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.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')); -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}); installCharacters(app,{storage,origin}); installCampaigns(app,{campaigns,origin}); diff --git a/server/oauth.mjs b/server/oauth.mjs index 0bd4b88..20a5218 100644 --- a/server/oauth.mjs +++ b/server/oauth.mjs @@ -5,7 +5,12 @@ import {requireBearerAuth} from '@modelcontextprotocol/sdk/server/auth/middlewar 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}){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');} 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)}); }};} diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 5f2d0b9..5af4789 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -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(); }finally{await new Promise(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(resolve=>server.close(()=>resolve()))} +});