30 lines
10 KiB
TypeScript
30 lines
10 KiB
TypeScript
import {test} from 'node:test';import assert from 'node:assert/strict';
|
|
import {newDb} from 'pg-mem';import express from 'express';import {once} from 'node:events';
|
|
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
|
|
import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js';
|
|
import {Storage,Conflict} from '../server/storage.mjs';import {CharacterOAuth} from '../server/oauth.mjs';import {characterMcp,installMcp} from '../server/mcp.mjs';import {sessionCodec} from '../server/auth.mjs';import {newCharacter} from '../src/model.ts';import {installCharacters} from '../server/characters.mjs';
|
|
const origin='https://dnd.example.test',codec=sessionCodec('test-secret-long-enough','test');
|
|
async function storage(){const pg=newDb().adapters.createPg();const s=new Storage(new pg.Pool());await s.init();return s}
|
|
const hash=async(s:string)=>(await import('node:crypto')).createHash('sha256').update(s).digest('hex');
|
|
test('persistent sheets isolate accounts and reject stale updates/deletions',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);const updated=await s.put('alice',c.id,{...c,name:'Updated'},1);assert.equal(updated.revision,2);await assert.rejects(s.put('alice',c.id,null,1,true),Conflict);assert.equal((await s.get('alice',c.id)).character.name,'Updated');const deleted=await s.put('alice',c.id,null,2,true);assert.equal(deleted.deleted,true);assert.equal((await s.list('alice')).length,1)});
|
|
test('OAuth codes bind client, redirect, resource, scope and one-time use; refresh rotates; revocation rejects access',async()=>{const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin});const client={client_id:'client',redirect_uris:['https://chatgpt.com/callback']};const payload={purpose:'mcp-code',clientId:'client',sub:'alice',challenge:'abc',redirectUri:client.redirect_uris[0],scopes:['characters:read'],sessionExpiry:Math.floor(Date.now()/1000)+3600,nonce:'unique'};const code=await codec.seal(payload,300);await s.code(await hash(code),{});assert.equal(await p.challengeForAuthorizationCode(client,code),'abc');await assert.rejects(p.challengeForAuthorizationCode({client_id:'other'},code));await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,'https://evil.test',p.resource));await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,new URL('https://other.test/mcp')));const tokens=await p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,p.resource);assert.equal((await p.verifyAccessToken(tokens.access_token)).extra.sub,'alice');await assert.rejects(p.exchangeAuthorizationCode(client,code,undefined,payload.redirectUri,p.resource));await assert.rejects(p.verifyAccessToken(await codec.seal({sub:'alice'},300)));await assert.rejects(p.exchangeRefreshToken(client,tokens.refresh_token,['characters:write'],p.resource));const renewed=await p.exchangeRefreshToken(client,tokens.refresh_token,undefined,p.resource);await assert.rejects(p.exchangeRefreshToken(client,tokens.refresh_token,undefined,p.resource));await p.revokeToken(client,{token:renewed.access_token});await assert.rejects(p.verifyAccessToken(tokens.access_token));});
|
|
test('MCP SDK lists tools, performs account-scoped HP changes, rejects stale revisions and denies read-only writes',async()=>{const s=await storage(),c=newCharacter();c.hp.temp=5;await s.put('alice',c.id,c,0);async function connect(sub:string,scopes:string[]){const server=characterMcp({storage:s,sub,scopes}),client=new Client({name:'test',version:'1'});const [a,b]=InMemoryTransport.createLinkedPair();await server.connect(a);await client.connect(b);return client}const client=await connect('alice',['characters:read','characters:write']);assert.equal((await client.listTools()).tools.length,17);const hp=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-7}});assert.equal(hp.isError,undefined);assert.deepEqual((await s.get('alice',c.id)).character.hp,{current:8,max:10,temp:0});const stale=await client.callTool({name:'change_hit_points',arguments:{id:c.id,expected_revision:1,amount:-1}});assert.equal(stale.isError,true);const bob=await connect('bob',['characters:read']);const missing=await bob.callTool({name:'get_character',arguments:{id:c.id}});assert.equal(missing.isError,true);const readonly=await connect('alice',['characters:read']);assert.equal((await readonly.callTool({name:'create_character',arguments:{name:'Denied'}})).isError,true);await Promise.all([client.close(),bob.close(),readonly.close()])});
|
|
test('HTTP discovery, bearer denial, PKCE enforcement and API origin protection',async()=>{const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin}),app=express();app.use(express.json());app.use(express.urlencoded({extended:false}));app.use((req,res,next)=>{req.identity={sub:'alice'};next()});const auth=p.install(app,{cookie:()=>{},readCookie:()=>''});installMcp(app,{storage:s,auth,origin});installCharacters(app,{storage:s,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 denied=await fetch(base+'/mcp',{method:'POST',headers:{'content-type':'application/json'},body:'{}'});assert.equal(denied.status,401);assert.match(denied.headers.get('www-authenticate')||'',/oauth-protected-resource\/mcp/);const metadata=await(await fetch(base+'/.well-known/oauth-protected-resource/mcp')).json();assert.equal(metadata.resource,origin+'/mcp');const registered=await fetch(base+'/register',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({client_name:'Tests',redirect_uris:['https://chatgpt.com/callback'],token_endpoint_auth_method:'none'})});assert.equal(registered.status,201);const client=await registered.json();const authorize=await fetch(base+'/authorize?'+new URLSearchParams({client_id:client.client_id,redirect_uri:client.redirect_uris[0],response_type:'code',resource:origin+'/mcp',code_challenge:'a'.repeat(43),code_challenge_method:'S256'}),{redirect:'manual'});assert.equal(authorize.status,302);const badPkce=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({client_id:client.client_id,grant_type:'authorization_code',code:'invalid',code_verifier:'wrong',redirect_uri:client.redirect_uris[0],resource:origin+'/mcp'})});assert.equal(badPkce.status,400);const c=newCharacter();const blocked=await fetch(base+'/api/characters/'+c.id,{method:'PUT',headers:{'content-type':'application/json','origin':'https://evil.test'},body:JSON.stringify({character:c,revision:0})});assert.equal(blocked.status,403)}finally{await new Promise<void>(resolve=>server.close(()=>resolve()))}});
|
|
test('full consent → PKCE exchange → HTTP MCP call works and rejects wrong verifier',async()=>{
|
|
const s=await storage(),p=new CharacterOAuth({storage:s,codec,origin}),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 c=await p.clientsStore.registerClient({redirect_uris:['https://chatgpt.com/callback'],token_endpoint_auth_method:'none'});
|
|
const {createHash}=await import('node:crypto');const verifier='v'.repeat(64),challenge=createHash('sha256').update(verifier).digest('base64url');
|
|
const authorized=await fetch(base+'/authorize?'+new URLSearchParams({client_id:c.client_id,redirect_uri:c.redirect_uris[0],response_type:'code',resource:origin+'/mcp',scope:'characters:read characters:write',state:'test-state',code_challenge:challenge,code_challenge_method:'S256'}),{redirect:'manual'});assert.equal(authorized.status,302);
|
|
const consent=await fetch(base+authorized.headers.get('location'),{redirect:'manual'}),html=await consent.text();const ticket=html.match(/name="ticket" value="([^"]+)"/)![1],nonce=html.match(/name="nonce" value="([^"]+)"/)![1],csrf=consent.headers.get('set-cookie')!.split(';')[0];
|
|
const consentBody=new URLSearchParams({ticket,nonce,decision:'allow'});const denied=await fetch(base+'/mcp/consent',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded','origin':'https://evil.test','cookie':csrf},body:consentBody,redirect:'manual'});assert.equal(denied.status,403);
|
|
const approved=await fetch(base+'/mcp/consent',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded','origin':origin,'cookie':csrf},body:consentBody,redirect:'manual'});assert.equal(approved.status,302);const callback=new URL(approved.headers.get('location')!);assert.equal(callback.searchParams.get('state'),'test-state');const code=callback.searchParams.get('code')!;
|
|
const tokenBody=(v:string)=>new URLSearchParams({client_id:c.client_id,grant_type:'authorization_code',code,code_verifier:v,redirect_uri:c.redirect_uris[0],resource:origin+'/mcp'});
|
|
const wrong=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:tokenBody('x'.repeat(64))});assert.equal(wrong.status,400);
|
|
const exchanged=await fetch(base+'/token',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:tokenBody(verifier)});assert.equal(exchanged.status,200);const tokens=await exchanged.json();
|
|
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()))}
|
|
});
|