// @vitest-environment node import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import express from 'express'; import http from 'http'; import type Provider from 'oidc-provider'; import { createHash, randomBytes } from 'crypto'; import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { Repository } from '../../db/repository.js'; import { createA2aOidcProvider, mountA2aOidc } from './oidc-provider.js'; import { createA2aRouter } from './a2a-api.js'; import { mountA2aRequestHandler } from './request-handler.js'; import { A2aLimiter, resolveA2aLimits } from './limiter.js'; import { b64url, Client } from './__e2e-helpers.js'; /** * Plan 1(実 oidc-provider)+ Plan 2A(inbound trust core + agent card)の統合 e2e。 * * 同一 http server に「実 oidc provider + consent」と「A2A agent card ルータ」を相乗りさせ、 * 本物の authorization_code + PKCE フローで発行したトークンが extended card ルートで * 受理され、consent で選んだスペース/スキルだけにスコープされることを実証する。 * 否定経路(Bearer 無し / 失効後 / 委任外スキル)も同じ実トークンで検証する。 */ describe('A2A card e2e (real token → scoped extended card + negative paths)', () => { let server: http.Server; let base: string; let audience: string; let repo: Repository; let provider: Provider; const clientId = 'a2a_card_e2e'; const redirectUri = 'https://client.test/cb'; const userId = 'user-1'; const spaceId = 'space-research'; beforeAll(async () => { repo = new Repository(':memory:'); // 公開 A2A クライアント(PKCE / token_endpoint_auth_method=none)。 repo.createA2aClient({ clientId, name: 'Card E2E', redirectUris: [redirectUri], secretHash: null, tokenEndpointAuthMethod: 'none', agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin', }); // acting user(extended card ルートが getUserById で実ロール/組織を引くため実在が必要)。 (repo as any).db.prepare( "INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) " + "VALUES (?, ?, ?, NULL, 'user', 'active', '2026-01-01', '2026-01-01')", ).run(userId, 'u1@test', 'User One'); // user-1 が所有する案件スペース + A2A 公開許可リスト(research / summarize)。 (repo as any).db.prepare( "INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Research', 'case', ?, 'private')", ).run(spaceId, userId); repo.setSpaceA2aSkills(spaceId, ['research', 'summarize']); // クロススペース AND 交差テスト用スペース(space-a に research / space-b に analyze)。 // 同意は space-a のみに絞ることで、space-b の analyze がカードに漏れないことを検証する。 (repo as any).db.prepare( "INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Space A', 'case', ?, 'private')", ).run('space-a', userId); repo.setSpaceA2aSkills('space-a', ['research']); (repo as any).db.prepare( "INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Space B', 'case', ?, 'private')", ).run('space-b', userId); repo.setSpaceA2aSkills('space-b', ['analyze']); const secretsDir = mkdtempSync(join(tmpdir(), 'a2a-card-e2e-')); const app = express(); // 擬似ログイン: 全リクエストに user を注入(consent の req.user 用)。mount より前に置く。 app.use((req, _res, next) => { (req as any).user = { id: userId, role: 'user' }; next(); }); // listen → port 確定 → issuer/audience 確定 → provider 生成 → mount の順(テストのみ並び替え) server = http.createServer(app); await new Promise(r => server.listen(0, '127.0.0.1', r)); const port = (server.address() as any).port; base = `http://127.0.0.1:${port}`; audience = `${base}/a2a`; provider = createA2aOidcProvider({ repo, secretsDir, issuer: `${base}/oidc`, resourceAudience: audience, cookieKeys: ['test-cookie-key'], }); // テスト専用: http/127.0.0.1 で cookie を non-secure にして cookie jar が機能するようにする。 (provider as any).proxy = false; // 実 provider + consent を /oidc に、A2A card ルータ + JSON-RPC ハンドラを同じ app に // マウント。両者に「同一」limiter を渡すことで a2a-subsystem の共有配線を再現する。 // ratePerMinute:2 は各テストが 1 grant あたり最大 2 回しか叩かないため既存ケースに // 影響しない(obtainToken ごとに新しい grantId=新しいバケット)。 const sharedLimiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 2 })); mountA2aOidc(app, provider, { repo, resourceAudience: audience }); app.use(createA2aRouter({ provider, repo, baseUrl: base, issuer: `${base}/oidc`, version: '1.0.0-test', limiter: sharedLimiter, })); mountA2aRequestHandler(app, { provider, repo, baseUrl: base, issuer: `${base}/oidc`, version: '1.0.0-test', limiter: sharedLimiter, }); }); afterAll(() => { server?.close(); }); /** * authorize → consent(space/skill を選択)→ code 取得 → token 交換まで通し、 * access_token と、そのトークンに紐づく grantId を返す。 * consent confirm のボディは consent-api.ts が読む `selectedSpaces` / `selectedSkills`。 */ async function obtainToken( selectedSpaces: string, selectedSkills: string, ): Promise<{ accessToken: string; grantId: string }> { const client = new Client(); const verifier = b64url(randomBytes(32)); const challenge = b64url(createHash('sha256').update(verifier).digest()); const authUrl = `${base}/oidc/auth?` + new URLSearchParams({ client_id: clientId, response_type: 'code', redirect_uri: redirectUri, scope: 'openid a2a.read', resource: audience, code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz', }); const authRes = await client.get(authUrl); expect([302, 303]).toContain(authRes.status); let loc = authRes.headers.get('location')!; // login / consent interaction を redirect_uri に着くまでループ(cookie jar 経由)。 // confirm のたびに space/skill 選択を送る(最終 grant に委任が紐づく)。 for (let i = 0; i < 5; i++) { if (loc.startsWith(redirectUri)) break; expect(loc).toContain('/oidc/interaction/'); const uid = new URL(loc, base).pathname.split('/').pop()!; const confirmRes = await client.postForm(`${base}/oidc/interaction/${uid}/confirm`, { selectedSpaces, selectedSkills, }); expect(confirmRes.status).toBe(200); const { redirectTo } = await confirmRes.json() as { redirectTo: string }; expect(redirectTo).toContain('/oidc/auth/'); const resumeRes = await client.get(redirectTo); expect([302, 303]).toContain(resumeRes.status); loc = resumeRes.headers.get('location')!; } expect(loc.startsWith(redirectUri)).toBe(true); const code = new URL(loc).searchParams.get('code'); expect(code).toBeTruthy(); const tokenRes = await client.postForm(`${base}/oidc/token`, { grant_type: 'authorization_code', code: code!, code_verifier: verifier, client_id: clientId, redirect_uri: redirectUri, }); expect(tokenRes.status).toBe(200); const tokenJson = await tokenRes.json() as { access_token: string; scope: string }; expect(tokenJson.access_token).toBeTruthy(); expect(tokenJson.scope.split(' ')).toContain('a2a.read'); // opaque access token から grantId を引く(失効テストで委任を特定するため)。 const at = await (provider as any).AccessToken.find(tokenJson.access_token); expect(at?.grantId).toBeTruthy(); return { accessToken: tokenJson.access_token, grantId: at.grantId as string }; } it('happy path: real token → extended card scoped to the single consented skill', async () => { const { accessToken } = await obtainToken(spaceId, 'research'); const res = await fetch(`${base}/a2a/agent-card/extended`, { headers: { authorization: `Bearer ${accessToken}` }, }); expect(res.status).toBe(200); const card = await res.json() as { skills: Array<{ id: string }> }; // research のみ consent → summarize は許可リストにあっても出ない expect(card.skills.map(s => s.id)).toEqual(['research']); }); it('base card is public (no auth): skills [] + supportsAuthenticatedExtendedCard', async () => { const res = await fetch(`${base}/.well-known/agent-card.json`); expect(res.status).toBe(200); const card = await res.json() as { skills: unknown[]; supportsAuthenticatedExtendedCard: boolean }; expect(card.skills).toEqual([]); expect(card.supportsAuthenticatedExtendedCard).toBe(true); }); it('no bearer → 401 on the extended card', async () => { const res = await fetch(`${base}/a2a/agent-card/extended`); expect(res.status).toBe(401); }); it('revoked delegation → 401 with the same previously-working token', async () => { const { accessToken, grantId } = await obtainToken(spaceId, 'research'); // まず動くことを確認 const okRes = await fetch(`${base}/a2a/agent-card/extended`, { headers: { authorization: `Bearer ${accessToken}` }, }); expect(okRes.status).toBe(200); // grantId から委任を特定して失効 const delegation = repo.getA2aDelegationByGrantId(grantId); expect(delegation).toBeTruthy(); repo.revokeA2aDelegation(delegation!.id, new Date().toISOString()); // 同じトークンは委任が live でないため 401 const res = await fetch(`${base}/a2a/agent-card/extended`, { headers: { authorization: `Bearer ${accessToken}` }, }); expect(res.status).toBe(401); }); it('out-of-scope skill dropped: summarize allowlisted but not consented is absent', async () => { // research だけ同意。summarize はスペース許可リストにあるが委任に含めない。 const { accessToken } = await obtainToken(spaceId, 'research'); const res = await fetch(`${base}/a2a/agent-card/extended`, { headers: { authorization: `Bearer ${accessToken}` }, }); expect(res.status).toBe(200); const card = await res.json() as { skills: Array<{ id: string }> }; const ids = card.skills.map(s => s.id); expect(ids).toContain('research'); expect(ids).not.toContain('summarize'); }); it('rate limit: extended card throttles past ratePerMinute and writes no audit row on 429', async () => { // Fresh grant → fresh token-bucket seeded to ratePerMinute:2. Third call on the same // token exhausts it. The 429 must be enforced BEFORE the scope computation + audit write, // so the audit_log count for a2a.card.extended must not grow across the throttled call. const { accessToken } = await obtainToken(spaceId, 'research'); const hdr = { authorization: `Bearer ${accessToken}` }; const r1 = await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr }); const r2 = await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr }); expect(r1.status).toBe(200); expect(r2.status).toBe(200); const auditBefore = (repo as any).db .prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'a2a.card.extended'") .get().n as number; const r3 = await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr }); expect(r3.status).toBe(429); const auditAfter = (repo as any).db .prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'a2a.card.extended'") .get().n as number; expect(auditAfter).toBe(auditBefore); // throttled request did not touch the DB }); it('shared limiter: REST card drain throttles the JSON-RPC extended-card RPC on the same grant', async () => { // Core invariant of this change: the REST route and the JSON-RPC handler share ONE limiter // (mirrors a2a-subsystem wiring). Draining the grant's bucket via 2 REST calls must throttle // the RPC surface too. If each surface held its own bucket, the RPC would still have 2 tokens // and return a result — so a `rate limit` error here proves the buckets are shared. const { accessToken } = await obtainToken(spaceId, 'research'); const hdr = { authorization: `Bearer ${accessToken}` }; expect((await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr })).status).toBe(200); expect((await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr })).status).toBe(200); // bucket for this grant is now empty — the RPC surface must observe it const rpcRes = await fetch(`${base}/a2a`, { method: 'POST', headers: { ...hdr, 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'agent/getAuthenticatedExtendedCard' }), }); const body = await rpcRes.json() as { result?: unknown; error?: { code: number; message: string } }; expect(body.error).toBeDefined(); // NOT a successful card result expect(body.error!.message).toMatch(/rate limit/); // shared bucket already drained by REST }); it('cross-space AND-intersection: non-granted space skills are absent from extended card', async () => { // space-a(research)と space-b(analyze)が両方ユーザーから見えるが、 // space-a のみに同意 → resolveEffectiveSpaces が space-b を AND 交差で排除し、 // analyze がカードに漏れないことを end-to-end で実証する(最高 IDOR リスク経路)。 const { accessToken } = await obtainToken('space-a', 'research'); const res = await fetch(`${base}/a2a/agent-card/extended`, { headers: { authorization: `Bearer ${accessToken}` }, }); expect(res.status).toBe(200); const card = await res.json() as { skills: Array<{ id: string }> }; const ids = card.skills.map(s => s.id); // space-a の research は同意済み → 出現する expect(ids).toEqual(['research']); // space-b の analyze は未同意スペースにのみ存在 → 漏れてはならない expect(ids).not.toContain('analyze'); }); });