298 lines
14 KiB
TypeScript
298 lines
14 KiB
TypeScript
// @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<void>(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');
|
||
});
|
||
});
|