mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
88916f4f03
* feat: add agent oauth consent management UI Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a * test: cover agent oauth consent edge paths Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a * fix: route agent oauth consent through rpc Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a * test: cover agent oauth consent rpc on workers Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a * test: cover agent oauth grant-use middleware Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a --------- Co-authored-by: Iris Tan <iris-tan@mails.agent-kanban.dev>
259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import {
|
|
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
|
|
AGENT_OAUTH_CLIENT_ID,
|
|
AGENT_OAUTH_CLIENT_NAME,
|
|
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
|
|
} from '@shared/agent-oauth'
|
|
import { AuthorizationScope } from '@shared/authorization'
|
|
import { eq, sql } from 'drizzle-orm'
|
|
import { describe, expect, it } from 'vitest'
|
|
import * as authSchema from '../db/auth-schema.js'
|
|
import { authedHeaders, createTestApp } from '../test/setup.js'
|
|
|
|
type TestContext = Awaited<ReturnType<typeof createTestApp>>
|
|
|
|
async function getUserAndPersonalOrg(db: TestContext['db'], email: string) {
|
|
const rows = await db.all<{ userId: string; orgId: string }>(sql`
|
|
SELECT u.id AS userId, o.id AS orgId
|
|
FROM user u
|
|
INNER JOIN member m ON m.user_id = u.id
|
|
INNER JOIN organization o ON o.id = m.organization_id
|
|
WHERE u.email = ${email} AND o.metadata LIKE '%"type":"personal"%'
|
|
LIMIT 1
|
|
`)
|
|
if (!rows[0]) throw new Error(`expected personal org for ${email}`)
|
|
return rows[0]
|
|
}
|
|
|
|
async function insertTeamOrg(db: TestContext['db'], orgId: string, userId: string) {
|
|
const now = Date.now()
|
|
await db.run(sql`
|
|
INSERT INTO organization (id, name, slug, metadata, created_at, updated_at)
|
|
VALUES (${orgId}, ${`Team ${orgId}`}, ${orgId}, '{"type":"team"}', ${now}, ${now})
|
|
`)
|
|
await db.run(sql`
|
|
INSERT INTO member (id, organization_id, user_id, role, created_at)
|
|
VALUES (${`${orgId}-member`}, ${orgId}, ${userId}, 'owner', ${now})
|
|
`)
|
|
}
|
|
|
|
async function insertGrant(
|
|
db: TestContext['db'],
|
|
input: { userId: string; orgId: string; scopes: AuthorizationScope[] },
|
|
) {
|
|
const now = new Date('2026-07-29T12:00:00.000Z')
|
|
await db.insert(authSchema.oauthConsent).values({
|
|
id: 'grant-1',
|
|
clientId: AGENT_OAUTH_CLIENT_ID,
|
|
userId: input.userId,
|
|
referenceId: input.orgId,
|
|
scopes: JSON.stringify(input.scopes),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
await db.insert(authSchema.oauthRefreshToken).values({
|
|
id: 'refresh-1',
|
|
token: 'hashed-refresh',
|
|
clientId: AGENT_OAUTH_CLIENT_ID,
|
|
userId: input.userId,
|
|
referenceId: input.orgId,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
createdAt: now,
|
|
scopes: JSON.stringify(input.scopes),
|
|
})
|
|
await db.insert(authSchema.oauthAccessToken).values({
|
|
id: 'access-1',
|
|
token: hashStoredToken('live-agent-token'),
|
|
clientId: AGENT_OAUTH_CLIENT_ID,
|
|
userId: input.userId,
|
|
referenceId: input.orgId,
|
|
refreshId: 'refresh-1',
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
createdAt: now,
|
|
scopes: JSON.stringify(input.scopes),
|
|
})
|
|
}
|
|
|
|
describe('Agent OAuth grants API integration', () => {
|
|
it('returns server-owned Agent OAuth consent context for the active workspace', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await authedHeaders(app, 'agent-consent@example.com')
|
|
const { orgId } = await getUserAndPersonalOrg(db, 'agent-consent@example.com')
|
|
const oauthQuery = new URLSearchParams({
|
|
client_id: AGENT_OAUTH_CLIENT_ID,
|
|
redirect_uri: 'http://127.0.0.1:8484/callback',
|
|
response_type: 'code',
|
|
scope: `${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ} openid offline_access`,
|
|
}).toString()
|
|
|
|
const res = await app.request(`/api/agent-oauth-consent?oauthQuery=${encodeURIComponent(oauthQuery)}`, { headers })
|
|
|
|
expect(res.status).toBe(200)
|
|
await expect(res.json()).resolves.toEqual({
|
|
clientId: AGENT_OAUTH_CLIENT_ID,
|
|
clientName: AGENT_OAUTH_CLIENT_NAME,
|
|
instanceOrigin: 'http://localhost',
|
|
workspace: { id: orgId, name: expect.any(String) },
|
|
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
|
|
standardScopes: ['openid', 'offline_access'],
|
|
redirectUri: 'http://127.0.0.1:8484/callback',
|
|
grantLifetime: {
|
|
accessTokenSeconds: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
|
|
refreshTokenSeconds: AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
|
|
},
|
|
})
|
|
})
|
|
|
|
it('revalidates OAuth consent submission through the Agent Access API', async () => {
|
|
const { app } = await createTestApp()
|
|
const headers = await authedHeaders(app, 'agent-submit@example.com')
|
|
|
|
const res = await app.request('/api/agent-oauth-consent', {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ accept: true, oauthQuery: 'client_id=zpan-agent&response_type=token' }),
|
|
})
|
|
|
|
expect(res.status).toBe(400)
|
|
await expect(res.json()).resolves.toMatchObject({
|
|
error: {
|
|
message: 'Invalid Agent OAuth request',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('submits full OAuth consent through the Agent Access API', async () => {
|
|
const { app } = await createTestApp()
|
|
const headers = await authedHeaders(app, 'agent-submit-success@example.com')
|
|
const oauthParams = new URLSearchParams({
|
|
client_id: AGENT_OAUTH_CLIENT_ID,
|
|
redirect_uri: 'http://127.0.0.1:8484/callback',
|
|
response_type: 'code',
|
|
scope: `${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ} openid offline_access`,
|
|
state: 'agent-submit-success',
|
|
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
|
code_challenge_method: 'S256',
|
|
})
|
|
const authorize = await app.request(`/api/auth/oauth2/authorize?${oauthParams}`, {
|
|
headers: { ...headers, Origin: 'http://localhost' },
|
|
})
|
|
const consentLocation = authorize.headers.get('location')
|
|
expect(authorize.status).toBe(302)
|
|
expect(consentLocation).toMatch(/^\/settings\/agent-access\?/)
|
|
|
|
const consent = await app.request('/api/agent-oauth-consent', {
|
|
method: 'POST',
|
|
headers: { ...headers, Origin: 'http://localhost', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
accept: true,
|
|
oauthQuery: consentLocation?.slice(consentLocation.indexOf('?') + 1),
|
|
}),
|
|
})
|
|
const consentBody = await consent.text()
|
|
|
|
expect(consent.status, consentBody).toBe(200)
|
|
expect(JSON.parse(consentBody)).toMatchObject({
|
|
url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:8484\/callback\?code=/),
|
|
})
|
|
})
|
|
|
|
it('lists and revokes the current user grant family', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await authedHeaders(app, 'agent-grants@example.com')
|
|
const { userId, orgId } = await getUserAndPersonalOrg(db, 'agent-grants@example.com')
|
|
await insertGrant(db, { userId, orgId, scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ] })
|
|
|
|
const list = await app.request('/api/agent-oauth-grants', { headers })
|
|
expect(list.status).toBe(200)
|
|
await expect(list.json()).resolves.toEqual({
|
|
items: [
|
|
{
|
|
id: 'grant-1',
|
|
clientId: AGENT_OAUTH_CLIENT_ID,
|
|
clientName: 'ZPan Agent',
|
|
userId,
|
|
orgId,
|
|
workspaceName: expect.any(String),
|
|
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
|
|
createdAt: '2026-07-29T12:00:00.000Z',
|
|
lastUsedAt: null,
|
|
status: 'active',
|
|
},
|
|
],
|
|
})
|
|
|
|
const revoke = await app.request('/api/agent-oauth-grants/grant-1', { method: 'DELETE', headers })
|
|
expect(revoke.status).toBe(204)
|
|
expect(await db.select().from(authSchema.oauthConsent)).toHaveLength(0)
|
|
expect(await db.select().from(authSchema.oauthAccessToken)).toHaveLength(0)
|
|
const [refresh] = await db.select().from(authSchema.oauthRefreshToken)
|
|
expect(refresh.revoked).not.toBeNull()
|
|
})
|
|
|
|
it('enforces live grant membership and fixed workspace for Agent OAuth bearer access', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await authedHeaders(app, 'agent-scope@example.com')
|
|
const { userId, orgId } = await getUserAndPersonalOrg(db, 'agent-scope@example.com')
|
|
await insertTeamOrg(db, 'other-workspace', userId)
|
|
await insertGrant(db, { userId, orgId, scopes: [AuthorizationScope.OBJECTS_READ] })
|
|
|
|
const list = await app.request('/api/agent-oauth-grants', { headers })
|
|
expect(list.status).toBe(200)
|
|
await expect(list.json()).resolves.toMatchObject({ items: [{ id: 'grant-1', lastUsedAt: null }] })
|
|
|
|
const bearer = { Authorization: 'Bearer live-agent-token' }
|
|
const allowed = await app.request('/api/objects', { headers: bearer })
|
|
expect(allowed.status).toBe(200)
|
|
const [usedGrant] = await db
|
|
.select({ lastUsedAt: authSchema.oauthConsent.lastUsedAt })
|
|
.from(authSchema.oauthConsent)
|
|
.where(eq(authSchema.oauthConsent.id, 'grant-1'))
|
|
expect(usedGrant.lastUsedAt).toBeInstanceOf(Date)
|
|
|
|
const wrongWorkspace = await app.request('/api/objects?orgId=other-workspace', { headers: bearer })
|
|
expect(wrongWorkspace.status).toBe(403)
|
|
|
|
const revoke = await app.request('/api/agent-oauth-grants/grant-1', { method: 'DELETE', headers })
|
|
expect(revoke.status).toBe(204)
|
|
|
|
const revoked = await app.request('/api/objects', { headers: bearer })
|
|
expect(revoked.status).toBe(401)
|
|
})
|
|
|
|
it('blocks generic Better Auth OAuth consent mutation endpoints', async () => {
|
|
const { app } = await createTestApp()
|
|
|
|
for (const path of ['/api/auth/oauth2/update-consent', '/api/auth/oauth2/delete-consent']) {
|
|
const res = await app.request(path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ client_id: AGENT_OAUTH_CLIENT_ID }),
|
|
})
|
|
|
|
expect(res.status).toBe(403)
|
|
await expect(res.json()).resolves.toMatchObject({
|
|
error_description: 'Manage Agent OAuth grants from the Agent Access API',
|
|
})
|
|
}
|
|
})
|
|
|
|
it('returns 404 when revoking a missing Agent OAuth grant', async () => {
|
|
const { app } = await createTestApp()
|
|
const headers = await authedHeaders(app, 'agent-missing-grant@example.com')
|
|
|
|
const revoke = await app.request('/api/agent-oauth-grants/missing-grant', { method: 'DELETE', headers })
|
|
|
|
expect(revoke.status).toBe(404)
|
|
await expect(revoke.json()).resolves.toMatchObject({
|
|
error: {
|
|
message: 'Agent OAuth grant not found',
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
function hashStoredToken(token: string): string {
|
|
return createHash('sha256').update(token).digest('base64url')
|
|
}
|