200 lines
9.2 KiB
TypeScript
200 lines
9.2 KiB
TypeScript
// @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');
|
||
});
|
||
});
|