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('
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