maestro/src/bridge/a2a/oidc-e2e.test.ts
oss-sync b1292e34b2
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (edc775f2)
2026-07-06 01:04:12 +00:00

200 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment node
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import express from 'express';
import http from 'http';
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 { b64url, Client } from './__e2e-helpers.js';
describe('A2A oidc e2e (authorization_code + PKCE)', () => {
let server: http.Server;
let base: string;
let audience: string;
let repo: Repository;
const clientId = 'a2a_test';
const redirectUri = 'https://client.test/cb';
beforeAll(async () => {
repo = new Repository(':memory:');
repo.createA2aClient({
clientId, name: 'E2E', redirectUris: [redirectUri],
secretHash: null, tokenEndpointAuthMethod: 'none',
agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin',
});
// disabled なクライアント(否定経路用)。最初から disabled なので Client キャッシュにも乗らない。
repo.createA2aClient({
clientId: 'a2a_disabled', name: 'Disabled', redirectUris: [redirectUri],
secretHash: null, tokenEndpointAuthMethod: 'none',
agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin',
});
repo.setA2aClientStatus('a2a_disabled', 'disabled');
const secretsDir = mkdtempSync(join(tmpdir(), 'a2a-e2e-'));
const app = express();
// 擬似ログイン: 全リクエストに user を注入consent の req.user 用。mount より前に置く。
app.use((req, _res, next) => { (req as any).user = { id: 'user-1', 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`;
const provider = createA2aOidcProvider({
repo, secretsDir,
issuer: `${base}/oidc`,
resourceAudience: audience,
cookieKeys: ['test-cookie-key'],
});
// テスト専用: http/127.0.0.1 で cookie を non-secure にして cookie jar が機能するようにする。
// 本番は逆プロキシ + https で proxy=true のまま。
(provider as any).proxy = false;
mountA2aOidc(app, provider, { repo, resourceAudience: audience });
});
afterAll(() => { server?.close(); });
/** authorize → consent → code 取得まで通し、認可コードを返す。 */
async function obtainCode(client: Client, verifier: string): Promise<string> {
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')!;
// oidc-provider は login と consent を別々の interaction として要求しうるので、
// interaction → confirm → resume を redirect_uri に着くまでループするcookie jar 経由)。
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`, {});
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();
return code!;
}
it('issues a scoped token, introspection reflects active + aud, then revocation flips to inactive', async () => {
const client = new Client();
const verifier = b64url(randomBytes(32));
const code = await obtainCode(client, verifier);
// token 交換public client → client_id をボディに)
const tokenRes = await client.postForm(`${base}/oidc/token`, {
grant_type: 'authorization_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; token_type: string };
expect(tokenJson.access_token).toBeTruthy();
expect(tokenJson.scope.split(' ')).toContain('a2a.read');
// introspection: active + aud
const introRes = await client.postForm(`${base}/oidc/token/introspection`, {
token: tokenJson.access_token, client_id: clientId,
});
expect(introRes.status).toBe(200);
const introBefore = await introRes.json() as { active: boolean; aud?: string | string[]; scope?: string };
expect(introBefore.active).toBe(true);
const audValue = Array.isArray(introBefore.aud) ? introBefore.aud : [introBefore.aud];
expect(audValue).toContain(audience);
expect(String(introBefore.scope ?? '').split(' ')).toContain('a2a.read');
// revocation
const revRes = await client.postForm(`${base}/oidc/token/revocation`, {
token: tokenJson.access_token, client_id: clientId,
});
expect(revRes.status).toBe(200);
// revoke 後の introspection は inactive
const introAfterRes = await client.postForm(`${base}/oidc/token/introspection`, {
token: tokenJson.access_token, client_id: clientId,
});
expect(introAfterRes.status).toBe(200);
const introAfter = await introAfterRes.json() as { active: boolean };
expect(introAfter.active).toBe(false);
});
it('rejects an unregistered client_id at the authorize endpoint', async () => {
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: 'no_such_client', response_type: 'code', redirect_uri: redirectUri,
scope: 'openid a2a.read', resource: audience,
code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
});
const res = await client.get(authUrl);
// 未登録 client は redirect_uri を信頼できないため、リダイレクトせず 400 エラーを返す
expect(res.status).toBe(400);
const body = await res.text();
expect(body).toMatch(/invalid_client|unrecognized|client/i);
});
it('rejects authorize when code_challenge is missing (PKCE required)', async () => {
const client = new Client();
const authUrl = `${base}/oidc/auth?` + new URLSearchParams({
client_id: clientId, response_type: 'code', redirect_uri: redirectUri,
scope: 'openid a2a.read', resource: audience, state: 'xyz',
// code_challenge を意図的に省略
});
const res = await client.get(authUrl);
// 既知 client + 既知 redirect_uri なので、エラーは redirect_uri に返される303 + error=invalid_request
expect([302, 303]).toContain(res.status);
const loc = res.headers.get('location')!;
const err = new URL(loc).searchParams.get('error');
expect(err).toBe('invalid_request');
expect(new URL(loc).searchParams.get('error_description') ?? '').toMatch(/code_challenge|pkce/i);
});
it('cannot start an authorize flow with a disabled client', async () => {
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: 'a2a_disabled', response_type: 'code', redirect_uri: redirectUri,
scope: 'openid a2a.read', resource: audience,
code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
});
const res = await client.get(authUrl);
// disabled client は adapter.find が undefined を返す → 未知 client 扱い → 400
expect(res.status).toBe(400);
const body = await res.text();
expect(body).toMatch(/invalid_client|unrecognized|client/i);
});
it('rejects token exchange with a tampered code_verifier (invalid_grant)', async () => {
const client = new Client();
const verifier = b64url(randomBytes(32));
const code = await obtainCode(client, verifier);
const tampered = b64url(randomBytes(32)); // 正しい verifier と一致しない
const tokenRes = await client.postForm(`${base}/oidc/token`, {
grant_type: 'authorization_code',
code, code_verifier: tampered, client_id: clientId, redirect_uri: redirectUri,
});
expect(tokenRes.status).toBe(400);
const body = await tokenRes.json() as { error: string };
expect(body.error).toBe('invalid_grant');
});
});