168 lines
8.1 KiB
TypeScript
168 lines
8.1 KiB
TypeScript
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>`;
|
||
}
|