maestro/src/bridge/a2a/consent-api.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

168 lines
8.1 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.

import express, { Router, type Request, type Response } from 'express';
import type Provider from 'oidc-provider';
import type { Repository } from '../../db/repository.js';
import { A2A_READ_SCOPE } from './oidc-config.js';
import { logger } from '../../logger.js';
import { randomUUID } from 'crypto';
/**
* 同意 interaction。ユーザーは既存 Passport セッションでログイン済みである必要があるreq.user
* GET /:uid → 同意画面(最小 HTML
* POST /:uid/confirm → Grant を保存して認可コードフローを再開
* POST /:uid/abort → access_denied で中断
*/
export function createConsentRouter(provider: Provider, repo: Repository, resourceAudience: string): Router {
const router = Router();
// マウント先の body-parser 構成によらず確実に動作するよう自前で登録する。
// HTML フォーム (application/x-www-form-urlencoded) にも対応。
router.use(express.urlencoded({ extended: false }));
router.use(express.json());
router.get('/:uid', async (req: Request, res: Response) => {
try {
const details = await provider.interactionDetails(req, res);
const user = req.user as Express.User | undefined;
if (!user) {
// 未ログイン: 既存ログインへ。戻り先に interaction を指定。
res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`);
return;
}
const client = repo.getA2aClient(details.params.client_id as string);
const clientName = client?.name ?? (details.params.client_id as string);
const scopes = String(details.params.scope ?? '').split(' ').filter(Boolean);
// ユーザーが閲覧可能でかつ A2A スキルが 1 件以上あるスペースを列挙する。
const allSpaces = await repo.listSpaces({ viewer: user });
const spacesWithSkills: Array<{ id: string; title: string; skills: string[] }> = [];
for (const space of allSpaces) {
const skills = repo.getSpaceA2aSkills(space.id);
if (skills.length > 0) spacesWithSkills.push({ id: space.id, title: space.title, skills });
}
res.type('html').send(renderConsent(details.uid, clientName, scopes, spacesWithSkills));
} catch (err) {
logger.error(`[a2a-oidc] interaction render error: ${err}`);
res.status(500).send('interaction error');
}
});
router.post('/:uid/confirm', async (req: Request, res: Response) => {
try {
const details = await provider.interactionDetails(req, res);
const user = req.user as Express.User | undefined;
if (!user) { res.status(401).json({ error: 'login required' }); return; }
const accountId = user.id;
const clientId = details.params.client_id as string;
const resource = (details.params.resource as string | undefined) ?? resourceAudience;
// ボディから要求されたスペース/スキルを取り出すJSON or URL-encoded 両対応)。
// 単一チェックボックスは string で届くため concat で配列に正規化する。
const toStringArray = (v: unknown): string[] =>
([] as string[]).concat(v as any ?? []).filter((x): x is string => typeof x === 'string');
const requestedSpaces = toStringArray(req.body?.selectedSpaces);
const requestedSkillSet = new Set<string>(toStringArray(req.body?.selectedSkills));
// サーバー側で再検証: ユーザーが実際に閲覧できるかつスキルを持つスペースのみ許可。
const visibleSpaces = await repo.listSpaces({ viewer: user });
const visibleSpaceIds = new Set(visibleSpaces.map(s => s.id));
const grantedSpaceIds: string[] = [];
const grantedSkillsSet = new Set<string>();
for (const spaceId of requestedSpaces) {
if (!visibleSpaceIds.has(spaceId)) continue;
const allowlisted = repo.getSpaceA2aSkills(spaceId);
if (allowlisted.length === 0) continue;
grantedSpaceIds.push(spaceId);
for (const skill of allowlisted) {
if (requestedSkillSet.has(skill)) grantedSkillsSet.add(skill);
}
}
// Grant を作って scope/resource を付与。
// a2a.read は oidc-config の top-level `scopes` に載る OP スコープでもあるため、
// resource スコープだけでなく OP スコープとしても付与しないと、consent prompt の
// `op_scopes_missing` が解消されず認可コードフローが consent ループに陥る。
const GrantCtor = (provider as any).Grant;
const grant = new GrantCtor({ accountId, clientId });
grant.addOIDCScope('openid');
grant.addOIDCScope(A2A_READ_SCOPE);
grant.addResourceScope(resource, A2A_READ_SCOPE);
const grantId: string = await grant.save();
// 委任行を書き込むgrant.save() 直後、interactionResult より前)。
// スコープ外のみ選択された場合は grantedSpaceIds が空になる → 孤立行を作らない。
if (grantedSpaceIds.length > 0) {
repo.createA2aDelegation({
id: randomUUID(),
userId: accountId,
clientId,
grantId,
grantedSpaceIds,
grantedSkills: [...grantedSkillsSet],
audience: resource,
expiresAt: null,
revokedAt: null,
});
}
const result = { login: { accountId }, consent: { grantId } };
const redirectTo = await provider.interactionResult(req, res, result, { mergeWithLastSubmission: false });
try {
await repo.addAuditLog(null, 'a2a.consent.granted', accountId, { clientId, resource, grantId });
} catch (auditErr) {
logger.warn(`[a2a-oidc] audit log failed (non-fatal): ${auditErr}`);
}
res.json({ redirectTo });
} catch (err) {
logger.error(`[a2a-oidc] interaction confirm error: ${err}`);
res.status(500).json({ error: 'confirm error' });
}
});
router.post('/:uid/abort', async (req: Request, res: Response) => {
try {
const result = { error: 'access_denied', error_description: 'user aborted' };
const redirectTo = await provider.interactionResult(req, res, result, { mergeWithLastSubmission: false });
res.json({ redirectTo });
} catch (err) {
logger.error(`[a2a-oidc] interaction abort error: ${err}`);
res.status(500).json({ error: 'abort error' });
}
});
return router;
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
}
type SpaceOption = { id: string; title: string; skills: string[] };
function renderConsent(uid: string, clientName: string, scopes: string[], spaces: SpaceOption[]): string {
const scopeItems = scopes.map(s => `<li>${escapeHtml(s)}</li>`).join('');
const spaceSection = spaces.length === 0
? '<p><em>委任可能なスペースがありません。</em></p>'
: spaces.map(sp => {
const skillBoxes = sp.skills.map(sk =>
`<label style="display:block;margin-left:16px"><input type="checkbox" name="selectedSkills" value="${escapeHtml(sk)}" checked> ${escapeHtml(sk)}</label>`
).join('');
return `<div style="margin:8px 0;padding:8px;border:1px solid #ccc;border-radius:4px"><label><input type="checkbox" name="selectedSpaces" value="${escapeHtml(sp.id)}" checked> <strong>${escapeHtml(sp.title)}</strong></label>${skillBoxes}</div>`;
}).join('');
return `<!doctype html><meta charset="utf-8"><title>A2A 委任の同意</title>
<body style="font-family:sans-serif;max-width:480px;margin:40px auto">
<h1>外部エージェントへの委任</h1>
<p><strong>${escapeHtml(clientName)}</strong> があなたの代理として次の権限を要求しています。</p>
<ul>${scopeItems}</ul>
<form method="post" action="/oidc/interaction/${encodeURIComponent(uid)}/confirm">
<h2>委任するスペースとスキル</h2>
${spaceSection}
<button type="submit">許可する</button>
</form>
<form method="post" action="/oidc/interaction/${encodeURIComponent(uid)}/abort" style="margin-top:8px">
<button type="submit">拒否する</button>
</form>
</body>`;
}