Allow three OAuth consent levels and cache agent permission checks

This commit is contained in:
陈煜 committed 2026-10-05 21:35:37 +08:00
1 parent 794274d31b
commit daf9b458af
10 files changed
+245 -22

No files matched your search

+1
View File
@@ -82,6 +82,7 @@ async function main() {
'test/admin-integration.test.ts',
'test/mcp.test.ts',
'test/oauth-duration.test.ts',
'test/oauth-permissions.test.ts',
'test/database-migrations.test.ts',
'test/connection-repayment-integration.test.ts',
];
+4 -2
View File
@@ -155,8 +155,10 @@ export class AgentManagementController {
});
return { ok: true };
}
@Get('authorizations/:id') pending(@Param('id') id: string) {
return this.oauth.pending(z.string().uuid().parse(id));
@Get('authorizations/:id') async pending(@Req() r: UserRequest, @Param('id') id: string) {
const result = await this.oauth.pending(z.string().uuid().parse(id));
const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
return { ...result, canWrite: user.role !== 'readonly' };
}
@Post('authorizations/:id') async consent(
@Req() r: UserRequest,
+14 -2
View File
@@ -162,6 +162,12 @@ export class AgentOAuth implements OAuthServerProvider {
name: client?.client_name,
redirectUri: p.redirectUri,
scopes: p.scopes,
availableScopes: [
'read',
'draft',
'write',
...p.scopes.filter((s: string) => s.startsWith('hidden_')),
],
resource: p.resource,
};
}
@@ -188,8 +194,14 @@ export class AgentOAuth implements OAuthServerProvider {
allowed.some((s: string) => ['draft', 'write', 'hidden_write'].includes(s))
)
throw new ForbiddenException('只读账号只能授予查询权限');
if (allowed.some((scope: string) => !parameters.scopes.includes(scope)))
throw new BadRequestException('不能授予客户端未请求的权限');
// The owner explicitly chooses the ordinary permission level on the consent page.
// Hidden access still requires a corresponding client request and separate consent.
if (
allowed.some(
(scope: string) => scope.startsWith('hidden_') && !parameters.scopes.includes(scope),
)
)
throw new BadRequestException('不能授予客户端未请求的隐藏权限');
const code = secret();
const changed = await this.db.agentAuthorization.updateMany({
where: { id, status: 'pending', expiresAt: { gt: new Date() } },
+157
View File
@@ -0,0 +1,157 @@
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID, createHash } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
test(
'OAuth read-only requests offer all three ordinary levels; explicit owner consent determines token scopes',
{ skip: process.env.TEST_ISOLATED !== 'true' },
async () => {
const db = new PrismaClient(),
ids: string[] = [];
const base = process.env.TEST_API_URL!,
root = base.replace(/\/api$/, '');
const resource = process.env.MCP_PUBLIC_URL!,
redirect = 'http://127.0.0.1:47891/callback';
const clientId = randomUUID();
async function web(cookie: string, id: string, body?: unknown) {
const r = await fetch(base + '/agent/authorizations/' + id, {
method: body ? 'POST' : 'GET',
headers: {
Cookie: cookie,
Origin: process.env.WEB_ORIGIN!,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: r.status, data: await r.json() };
}
async function request(scopes = ['read']) {
const id = randomUUID(),
verifier = randomBytes(32).toString('base64url');
await db.agentAuthorization.create({
data: {
id,
clientId,
expiresAt: new Date(Date.now() + 600000),
parameters: {
redirectUri: redirect,
resource,
scopes,
codeChallenge: createHash('sha256').update(verifier).digest('base64url'),
},
},
});
return { id, verifier };
}
try {
await db.agentClient.create({
data: {
id: clientId,
metadata: {
client_id: clientId,
client_name: 'Three permissions regression',
redirect_uris: [redirect],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
},
},
});
for (const role of ['user', 'readonly'] as const) {
const u = await db.user.create({
data: { username: 'oauth_levels_' + randomUUID(), passwordHash: 'unused', role },
});
ids.push(u.id);
const session = randomBytes(32).toString('hex');
await db.session.create({
data: {
id: createHash('sha256').update(session).digest('hex'),
userId: u.id,
expiresAt: new Date(Date.now() + 3600000),
},
});
const cookie = 'wp_session=' + session;
for (const level of ['read', 'draft', 'write']) {
const a = await request();
const preview = await web(cookie, a.id);
assert.equal(preview.status, 200);
assert.deepEqual(preview.data.scopes, ['read']);
assert.deepEqual(preview.data.availableScopes, ['read', 'draft', 'write']);
assert.equal(preview.data.canWrite, role !== 'readonly');
const selected = level === 'read' ? ['read'] : ['read', level];
const approved = await web(cookie, a.id, { approve: true, scopes: selected, days: 7 });
if (role === 'readonly' && level !== 'read') {
assert.equal(approved.status, 403);
assert.equal(
(await db.agentAuthorization.findUniqueOrThrow({ where: { id: a.id } })).status,
'pending',
);
continue;
}
assert.equal(approved.status, 201, JSON.stringify(approved.data));
const code = new URL(approved.data.redirect).searchParams.get('code')!;
const response = await fetch(root + '/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
grant_type: 'authorization_code',
code,
code_verifier: a.verifier,
redirect_uri: redirect,
resource,
}),
});
const token = await response.json();
assert.equal(response.status, 200, JSON.stringify(token));
assert.equal(token.scope, selected.join(' '));
const row = await db.agentGrant.findFirstOrThrow({
where: {
userId: u.id,
accessDigest: createHash('sha256').update(token.access_token).digest('hex'),
},
});
assert.deepEqual(row.scopes, selected);
}
if (role === 'user') {
const noHidden = await request();
assert.equal(
(
await web(cookie, noHidden.id, {
approve: true,
scopes: ['read', 'write', 'hidden_read'],
})
).status,
400,
);
const requestedHidden = await request(['read', 'hidden_read', 'hidden_write']);
assert.equal(
(
await web(cookie, requestedHidden.id, {
approve: true,
scopes: ['read', 'write', 'hidden_read', 'hidden_write'],
})
).status,
201,
);
const defaults = await request();
assert.equal((await web(cookie, defaults.id, { approve: true })).status, 201);
assert.deepEqual(
(await db.agentAuthorization.findUniqueOrThrow({ where: { id: defaults.id } }))
.parameters &&
(
(await db.agentAuthorization.findUniqueOrThrow({ where: { id: defaults.id } }))
.parameters as any
).scopes,
['read'],
);
}
}
} finally {
await db.user.deleteMany({ where: { id: { in: ids } } });
await db.agentClient.deleteMany({ where: { id: clientId } });
await db.$disconnect();
}
},
);