refactor(oauth): replace agent access naming (#549)

This commit is contained in:
Jasper Van
2026-08-01 00:32:31 -04:00
committed by GitHub
parent d15ca86226
commit 1caa4e580c
66 changed files with 1356 additions and 1374 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ Core architecture: clients upload directly to S3-compatible storage via presigne
- [docs/architecture.md](docs/architecture.md) — system architecture, tech decisions, platform abstraction
- [docs/design/admin-form-ui.md](docs/design/admin-form-ui.md) — admin form layout, density, required/help/placeholder rules
- [docs/design/upload-policies.md](docs/design/upload-policies.md) — proposed selector-based upload placement policies for multiple storage backends
- [docs/design/agent-authentication.md](docs/design/agent-authentication.md) — Agent OAuth, CI API keys, protocol-neutral authorization, Restish credential handling, and future Agent Auth compatibility
- [docs/design/oauth-server.md](docs/design/oauth-server.md) — OAuth, CI API keys, protocol-neutral authorization, Restish credential handling, and future Agent Auth compatibility
- [V2_ROADMAP.md](V2_ROADMAP.md) — product positioning, release plan (v2.0v2.9)
- [docs/roadmap/](docs/roadmap/) — per-version technical specs (v2.0.mdv2.9.md)
- [docs/design/spaces-quota-sharing.md](docs/design/spaces-quota-sharing.md) — spaces/quota/sharing design decisions (team billing, allocation, cross-space transfer, no per-item ACL)
+1 -1
View File
@@ -37,7 +37,7 @@ Each version ships 12 major features. Ship small, ship often.
| [v2.6](docs/roadmap/v2.6.md) | **Pro / Business Launch** | Cloud binding, entitlement system, white-label, audit log, quota store machinery, site announcements, retroactive gates |
| [v2.7](docs/roadmap/v2.7.md) | **WebDAV & File Processing** | WebDAV protocol access, small-file zip compression/extraction, and Community remote-download orchestration through Aria2, qBittorrent, and future adapters |
| [v2.8](docs/roadmap/v2.8.md) | **Admin Analytics & Dashboard** | Admin overview, usage/cost/reliability metrics, share analytics, and offline result coverage |
| [v2.9](docs/roadmap/v2.9.md) | **Agent Access** | Unified OpenAPI + Restish + upload plugin + Skill, protocol-neutral authorization, delegated OAuth, and workspace-scoped CI keys |
| [v2.9](docs/roadmap/v2.9.md) | **External OAuth Access** | Self-describing OpenAPI and upload workflows, delegated OAuth, DPoP, and protocol-neutral authorization |
| [v2.10](docs/roadmap/v2.10.md) | **Desktop Sync Foundation** | Sync device model, change log, sync API contract, conflict model, and protocol tests for external clients |
| Future | **Native Client Projects** | macOS File Provider, Windows/Linux sync clients, Flutter/mobile clients, and other OS-specific apps in separate repositories |
| Future | **Enterprise Identity & Governance** | SAML, LDAP / SCIM, advanced RBAC/custom roles, retention, and admin support mode if demand proves real |
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
# External Agent Access — Design
# External OAuth Apps — Design
> Status: Implemented
> Scope: dynamic OAuth clients, external resource authorization, DPoP, resource
@@ -13,8 +13,7 @@ grant for a DPoP-bound ZPan resource token.
ZPan does not ship or require:
- a fixed first-party Agent OAuth client;
- an Agent-specific API key;
- a fixed first-party OAuth client;
- Restish credential profiles in OpenAPI;
- a Restish upload plugin;
- a ZPan-specific Agent skill.
@@ -100,7 +99,7 @@ Each consent is bound to:
The request cannot replace that workspace with a query or body field. Team
membership and role checks still apply. Revoking a consent removes its access
tokens, revokes its refresh tokens, and deletes the consent. The Agent Access
tokens, revokes its refresh tokens, and deletes the consent. The OAuth Apps
page lists the real client name and workspace for every current-user grant.
## External Resource Token Flow
@@ -116,7 +115,7 @@ FlareAuth-style controllers use three credentials with separate purposes:
The exchanged access token is a JWT containing the user, workspace,
`zpan_actor`, delegated actor (`act`), audience, scopes, client ID, expiry, and
JTI. API requests use `Authorization: DPoP` plus a proof bound to the method,
URL, access token, and Agent key. ZPan verifies issuer, audience, signature,
URL, access token, and DPoP key. ZPan verifies issuer, audience, signature,
expiry, scopes, DPoP proof, and JTI revocation.
Revoking an exchanged JWT stores its JTI until token expiry. The resource API
@@ -125,7 +124,7 @@ assertions are intentionally not part of this path.
## Scope Model
Resource scopes use stable `<resource>:<action>` names. The external Agent scope
Resource scopes use stable `<resource>:<action>` names. The external OAuth scope
catalog includes:
| Scope | Authority |
@@ -151,13 +150,13 @@ OAuth is a credential adapter, not a business-logic fork. Middleware resolves a
protocol-neutral principal, bound workspace, scope set, and audit actor before
calling the same file use cases used by other authenticated clients.
Upgrades that add Agent scopes do not mutate OAuth tables during authentication
Upgrades that add OAuth resource scopes do not mutate OAuth tables during authentication
startup. Before deploying such an upgrade, operators run the idempotent scope
backfill in dry-run mode and then apply it:
```sh
pnpm agent-oauth-scopes:backfill -- --d1 zpan-db --remote
pnpm agent-oauth-scopes:backfill -- --d1 zpan-db --remote --apply
pnpm oauth-scopes:backfill -- --d1 zpan-db --remote
pnpm oauth-scopes:backfill -- --d1 zpan-db --remote --apply
```
For Node/SQLite deployments, replace the D1 arguments with
@@ -197,8 +196,8 @@ re-presigning re-enter ZPan authorization and workspace checks.
## Compatibility Boundary
The legacy `zpan-cli` device flow remains limited to downloader registration.
Ordinary human-created API keys remain available for their existing product
uses, but there is no Agent API-key template or Agent key management UI.
API keys remain available for image hosting, WebDAV, and remote-download access.
They are separate credentials and are not used by OAuth applications.
Future client-registration approval can be added around dynamically registered
client records without changing resource discovery, consent, token exchange,
+6 -7
View File
@@ -1,4 +1,4 @@
# v2.9 — External Agent Access
# v2.9 — External OAuth Apps
## Goal
@@ -19,9 +19,9 @@ ZPan owns:
- scope-aware file APIs, Arazzo workflows, and structured direct-upload
instructions.
The external controller owns Agent identity, approval of Agent access, delegated
credential injection, and tool orchestration. ZPan does not ship a fixed Agent
client or an Agent API-key product.
The external controller owns Agent identity, approval of application access, delegated
credential injection, and tool orchestration. ZPan does not ship a fixed OAuth
client.
## Deliverables
@@ -102,8 +102,7 @@ controller or client ID.
## Removed Compatibility Surfaces
- fixed, system-managed Agent OAuth client;
- Agent API-key template, endpoints, settings forms, and tests;
- fixed, system-managed OAuth client;
- OpenAPI `x-cli-config` profiles;
- `restish-zpan` command plugin and release artifact;
- repository-hosted ZPan Agent skill.
@@ -120,7 +119,7 @@ single-use downloader-registration bootstrap.
5. Tool-neutral OpenAPI authorization metadata.
6. Discoverable Arazzo upload workflows.
7. Self-describing single/multipart upload responses.
8. Remove fixed-client, Agent API-key, profile, plugin, and skill surfaces.
8. Remove fixed-client, profile, plugin, and skill surfaces.
9. Complete local gates and real FlareAuth acceptance.
## Acceptance Criteria
@@ -4,11 +4,11 @@ import { signUpAndGoToFiles } from './helpers'
const oauthQuery =
'client_id=dynamic-client&redirect_uri=https%3A%2F%2Fbroker.example.com%2Fcallback&response_type=code&scope=openid%20offline_access%20objects%3Aread%20shares%3Acreate%20quota%3Aread'
test.describe('Agent Access OAuth UI', () => {
test.describe('OAuth Apps UI', () => {
test('renders consent details and submits full approval @desktop', async ({ page }) => {
await signUpAndGoToFiles(page)
await page.route('**/api/agent-oauth-consent?*', async (route) => {
await page.route('**/api/oauth-consent?*', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
@@ -23,7 +23,7 @@ test.describe('Agent Access OAuth UI', () => {
}),
})
})
await page.route('**/api/agent-oauth-consent', async (route) => {
await page.route('**/api/oauth-consent', async (route) => {
if (route.request().method() !== 'POST') return route.fallback()
expect(route.request().method()).toBe('POST')
const body = route.request().postDataJSON() as { accept: boolean; oauthQuery?: string; scope?: string }
@@ -37,7 +37,7 @@ test.describe('Agent Access OAuth UI', () => {
await route.fulfill({ contentType: 'text/html', body: '<main>Returned to FlareAuth</main>' })
})
await page.goto(`/settings/agent-access?${oauthQuery}`)
await page.goto(`/settings/oauth-apps?${oauthQuery}`)
await expect(page.getByRole('heading', { name: 'Authorize Application' })).toBeVisible()
await expect(page.getByText('http://localhost:5185')).toBeVisible()
@@ -55,7 +55,7 @@ test.describe('Agent Access OAuth UI', () => {
await signUpAndGoToFiles(page)
let revoked = false
await page.route('**/api/agent-oauth-grants', async (route) => {
await page.route('**/api/oauth-grants', async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill({
contentType: 'application/json',
@@ -79,15 +79,15 @@ test.describe('Agent Access OAuth UI', () => {
}),
})
})
await page.route('**/api/agent-oauth-grants/grant-e2e', async (route) => {
await page.route('**/api/oauth-grants/grant-e2e', async (route) => {
expect(route.request().method()).toBe('DELETE')
revoked = true
await route.fulfill({ status: 204 })
})
await page.goto('/settings/agent-access')
await page.goto('/settings/oauth-apps')
await expect(page.getByText('Delegated OAuth Grants')).toBeVisible()
await expect(page.getByText('Authorized OAuth Apps')).toBeVisible()
await expect(page.getByRole('cell', { name: 'FlareAuth' })).toBeVisible()
await expect(page.getByText('Shares: create shares')).toBeVisible()
@@ -95,13 +95,13 @@ test.describe('Agent Access OAuth UI', () => {
await revokeButtons.last().click()
await expect(page.getByRole('dialog', { name: 'Revoke OAuth Grant' })).toBeVisible()
await page.getByRole('dialog').getByRole('button', { name: 'Revoke' }).click()
await expect(page.getByText('No delegated OAuth grants yet')).toBeVisible()
await expect(page.getByText('No authorized OAuth apps yet')).toBeVisible()
})
test('keeps consent and delegated grants usable on narrow screens @mobile', async ({ page }) => {
await signUpAndGoToFiles(page)
await page.route('**/api/agent-oauth-consent?*', async (route) => {
await page.route('**/api/oauth-consent?*', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
@@ -116,7 +116,7 @@ test.describe('Agent Access OAuth UI', () => {
}),
})
})
await page.route('**/api/agent-oauth-grants', async (route) => {
await page.route('**/api/oauth-grants', async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill({
contentType: 'application/json',
@@ -139,7 +139,7 @@ test.describe('Agent Access OAuth UI', () => {
})
})
await page.goto(`/settings/agent-access?${oauthQuery}`)
await page.goto(`/settings/oauth-apps?${oauthQuery}`)
await expect(page.getByRole('heading', { name: 'Authorize Application' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Approve Access' })).toBeVisible()
await expect(page.getByText('Files: read objects')).toBeVisible()
@@ -148,8 +148,8 @@ test.describe('Agent Access OAuth UI', () => {
.poll(async () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1))
.toBe(true)
await page.goto('/settings/agent-access')
await expect(page.getByText('Delegated OAuth Grants')).toBeVisible()
await page.goto('/settings/oauth-apps')
await expect(page.getByText('Authorized OAuth Apps')).toBeVisible()
await expect(page.getByRole('cell', { name: 'FlareAuth' })).toBeVisible()
const grantsTableContainer = page.locator('[data-slot="table-container"]').last()
await expect(grantsTableContainer).toBeVisible()
+1 -1
View File
@@ -29,7 +29,7 @@
"storage:backfill": "tsx scripts/backfill-storage-usage.ts",
"storage-status:backfill": "tsx scripts/backfill-storage-enabled-status.ts",
"api-key-scopes:backfill": "tsx scripts/backfill-api-key-scopes.ts",
"agent-oauth-scopes:backfill": "tsx scripts/backfill-agent-oauth-scopes.ts",
"oauth-scopes:backfill": "tsx scripts/backfill-oauth-scopes.ts",
"typecheck": "tsc --noEmit -p server/tsconfig.json && tsc --noEmit -p src/tsconfig.json",
"test": "vitest run --project unit --project integration",
"test:cf": "vitest run --project cloudflare",
@@ -2,16 +2,16 @@
import { execFileSync } from 'node:child_process'
import Database from 'better-sqlite3'
import { AGENT_OAUTH_SCOPES } from '../shared/agent-oauth'
import { OAUTH_SCOPES } from '../shared/oauth'
import { AuthorizationScope } from '../shared/authorization'
export type AgentOAuthScopeBackfillTarget =
export type OAuthScopeBackfillTarget =
| { kind: 'sqlite'; path: string }
| { kind: 'd1'; database: string; remote: boolean; env?: string }
export interface AgentOAuthScopeBackfillOptions {
export interface OAuthScopeBackfillOptions {
apply: boolean
target: AgentOAuthScopeBackfillTarget
target: OAuthScopeBackfillTarget
}
interface OAuthResourceRow {
@@ -25,16 +25,16 @@ interface OAuthClientRow {
scopes: string | null
}
export interface AgentOAuthScopeBackfill {
export interface OAuthScopeBackfill {
resources: Array<{ id: string; scopes: string }>
clients: Array<{ id: string; scopes: string }>
}
export function buildAgentOAuthScopeBackfill(
export function buildOAuthScopeBackfill(
resources: OAuthResourceRow[],
clients: OAuthClientRow[],
): AgentOAuthScopeBackfill {
const resourceScopes = JSON.stringify(AGENT_OAUTH_SCOPES)
): OAuthScopeBackfill {
const resourceScopes = JSON.stringify(OAUTH_SCOPES)
return {
resources: resources.flatMap((resource) =>
resource.name === 'ZPan API' && resource.allowedScopes !== resourceScopes
@@ -60,7 +60,7 @@ function parseScopes(value: string | null): string[] {
return parsed
}
export function parseAgentOAuthScopeBackfillOptions(argv: string[]): AgentOAuthScopeBackfillOptions {
export function parseOAuthScopeBackfillOptions(argv: string[]): OAuthScopeBackfillOptions {
const sqliteIndex = argv.indexOf('--sqlite')
const d1Index = argv.indexOf('--d1')
if ((sqliteIndex >= 0) === (d1Index >= 0)) usage()
@@ -85,11 +85,11 @@ export function parseAgentOAuthScopeBackfillOptions(argv: string[]): AgentOAuthS
function usage(): never {
throw new Error(
'Usage: pnpm agent-oauth-scopes:backfill -- (--sqlite <path> | --d1 <database> [--remote] [--env <name>]) [--apply]',
'Usage: pnpm oauth-scopes:backfill -- (--sqlite <path> | --d1 <database> [--remote] [--env <name>]) [--apply]',
)
}
function d1Args(target: Extract<AgentOAuthScopeBackfillTarget, { kind: 'd1' }>): string[] {
function d1Args(target: Extract<OAuthScopeBackfillTarget, { kind: 'd1' }>): string[] {
return [
'exec',
'wrangler',
@@ -101,27 +101,27 @@ function d1Args(target: Extract<AgentOAuthScopeBackfillTarget, { kind: 'd1' }>):
]
}
function executeD1(target: Extract<AgentOAuthScopeBackfillTarget, { kind: 'd1' }>, sql: string, json = false): string {
function executeD1(target: Extract<OAuthScopeBackfillTarget, { kind: 'd1' }>, sql: string, json = false): string {
return execFileSync('pnpm', [...d1Args(target), '--command', sql, ...(json ? ['--json'] : [])], {
encoding: 'utf8',
stdio: json ? 'pipe' : 'inherit',
}) as string
}
export type AgentOAuthScopeD1Executor = typeof executeD1
export type OAuthScopeD1Executor = typeof executeD1
function d1Rows<T>(
target: Extract<AgentOAuthScopeBackfillTarget, { kind: 'd1' }>,
target: Extract<OAuthScopeBackfillTarget, { kind: 'd1' }>,
sql: string,
execute: AgentOAuthScopeD1Executor,
execute: OAuthScopeD1Executor,
): T[] {
const payload = JSON.parse(execute(target, sql, true)) as Array<{ results?: T[] }>
return payload.flatMap((entry) => entry.results ?? [])
}
function readRows(
target: AgentOAuthScopeBackfillTarget,
execute: AgentOAuthScopeD1Executor,
target: OAuthScopeBackfillTarget,
execute: OAuthScopeD1Executor,
): { resources: OAuthResourceRow[]; clients: OAuthClientRow[] } {
const resourceSql = 'SELECT id, name, allowed_scopes AS allowedScopes FROM oauthResource;'
const clientSql = 'SELECT id, scopes FROM oauthClient;'
@@ -147,9 +147,9 @@ function sqlString(value: string): string {
}
function applyBackfill(
target: AgentOAuthScopeBackfillTarget,
changes: AgentOAuthScopeBackfill,
execute: AgentOAuthScopeD1Executor,
target: OAuthScopeBackfillTarget,
changes: OAuthScopeBackfill,
execute: OAuthScopeD1Executor,
): void {
if (target.kind === 'd1') {
for (const resource of changes.resources) {
@@ -180,18 +180,18 @@ function applyBackfill(
}
}
function countChanges(changes: AgentOAuthScopeBackfill): number {
function countChanges(changes: OAuthScopeBackfill): number {
return changes.resources.length + changes.clients.length
}
export function runAgentOAuthScopeBackfill(
export function runOAuthScopeBackfill(
argv: string[],
log: (message: string) => void = console.log,
execute: AgentOAuthScopeD1Executor = executeD1,
execute: OAuthScopeD1Executor = executeD1,
): void {
const options = parseAgentOAuthScopeBackfillOptions(argv)
const options = parseOAuthScopeBackfillOptions(argv)
const rows = readRows(options.target, execute)
const changes = buildAgentOAuthScopeBackfill(rows.resources, rows.clients)
const changes = buildOAuthScopeBackfill(rows.resources, rows.clients)
log(
JSON.stringify(
{
@@ -206,8 +206,8 @@ export function runAgentOAuthScopeBackfill(
if (!options.apply) return
applyBackfill(options.target, changes, execute)
const after = readRows(options.target, execute)
const remaining = buildAgentOAuthScopeBackfill(after.resources, after.clients)
if (countChanges(remaining) > 0) throw new Error(`agent_oauth_scope_backfill_failed:${countChanges(remaining)}`)
const remaining = buildOAuthScopeBackfill(after.resources, after.clients)
if (countChanges(remaining) > 0) throw new Error(`oauth_scope_backfill_failed:${countChanges(remaining)}`)
}
if (process.argv[1]?.endsWith('backfill-agent-oauth-scopes.ts')) runAgentOAuthScopeBackfill(process.argv.slice(2))
if (process.argv[1]?.endsWith('backfill-oauth-scopes.ts')) runOAuthScopeBackfill(process.argv.slice(2))
+4 -2
View File
@@ -58,9 +58,11 @@ export function idempotentSystemEventValues(input: {
}
function normalizeActorType(value: string | null, userId?: string | null): AuditActorType {
// Audit rows written before OAuth was generalized used the old compound actor type.
if (value === ['agent', 'oauth'].join('_')) return 'oauth'
if (
value === 'api_key' ||
value === 'agent_oauth' ||
value === 'oauth' ||
value === 'agent' ||
value === 'anonymous' ||
value === 'system' ||
@@ -75,7 +77,7 @@ function normalizeActorType(value: string | null, userId?: string | null): Audit
function actorDisplayName(actorType: AuditActorType, actorRef: string | null): string {
if (actorType === 'anonymous') return 'Anonymous'
if (actorType === 'api_key') return actorRef ? `API key:${actorRef}` : 'API key'
if (actorType === 'agent_oauth') return actorRef ? `Agent OAuth:${actorRef}` : 'Agent OAuth'
if (actorType === 'oauth') return actorRef ? `OAuth:${actorRef}` : 'OAuth'
if (actorType === 'agent') return actorRef ? `Agent:${actorRef}` : 'Agent'
if (actorType === 'system') return actorRef ? `System:${actorRef}` : 'System'
if (actorType === 'downloader') return actorRef ? `Downloader:${actorRef}` : 'Downloader'
@@ -3,27 +3,27 @@ import { isNull } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../../db/auth-schema'
import { createTestApp } from '../../test/setup'
import { createAgentOAuthGateway } from './agent-oauth'
import { createOAuthGateway } from './oauth'
const CLIENT_ID = 'dynamic-client'
describe('Agent OAuth gateway', () => {
describe('OAuth gateway', () => {
it('finds and lists dynamically registered applications', async () => {
const { db } = await createTestApp()
await insertClient(db, CLIENT_ID, 'FlareAuth')
await insertClient(db, 'retired-system-client', 'Retired', 'system')
await expect(createAgentOAuthGateway().findClient(db, CLIENT_ID)).resolves.toMatchObject({
await expect(createOAuthGateway().findClient(db, CLIENT_ID)).resolves.toMatchObject({
clientId: CLIENT_ID,
clientName: 'FlareAuth',
disabled: false,
redirectUris: ['https://flareauth.example/callback'],
responseTypes: ['code'],
})
await expect(createAgentOAuthGateway().listRegisteredApplications(db)).resolves.toEqual([
await expect(createOAuthGateway().listRegisteredApplications(db)).resolves.toEqual([
expect.objectContaining({ clientId: CLIENT_ID, name: 'FlareAuth' }),
])
await expect(createAgentOAuthGateway().findClient(db, 'retired-system-client')).resolves.toBeNull()
await expect(createOAuthGateway().findClient(db, 'retired-system-client')).resolves.toBeNull()
})
it('lists workspace-bound grants with their registered application names', async () => {
@@ -54,7 +54,7 @@ describe('Agent OAuth gateway', () => {
},
])
await expect(createAgentOAuthGateway().listGrants(db, userId)).resolves.toEqual([
await expect(createOAuthGateway().listGrants(db, userId)).resolves.toEqual([
{
id: 'grant-1',
clientId: CLIENT_ID,
@@ -106,7 +106,7 @@ describe('Agent OAuth gateway', () => {
})
await expect(
createAgentOAuthGateway().revokeGrant(db, {
createOAuthGateway().revokeGrant(db, {
userId,
grantId: 'grant-1',
now: new Date('2026-07-29T12:30:00.000Z'),
@@ -127,7 +127,7 @@ describe('Agent OAuth gateway', () => {
JSON.stringify({ jti: 'token-1', client_id: CLIENT_ID, exp: Math.floor(Date.now() / 1000) + 60 }),
).toString('base64url')
const token = `e30.${payload}.signature`
const gateway = createAgentOAuthGateway()
const gateway = createOAuthGateway()
await gateway.revokeJwtAccessToken(db, token)
@@ -9,9 +9,9 @@ import {
oauthRefreshToken,
} from '../../db/auth-schema'
import { executeWriteTransaction } from '../../db/transaction'
import type { AgentOAuthClient, AgentOAuthGateway } from '../../usecases/ports'
import type { OAuthClient, OAuthGateway } from '../../usecases/ports'
export function createAgentOAuthGateway(): AgentOAuthGateway {
export function createOAuthGateway(): OAuthGateway {
return {
async findClient(db, clientId) {
const [row] = await db
@@ -35,7 +35,7 @@ export function createAgentOAuthGateway(): AgentOAuthGateway {
redirectUris: parseStringArray(row.redirectUris),
responseTypes: parseStringArray(row.responseTypes),
scopes: parseStringArray(row.scopes),
} satisfies AgentOAuthClient
} satisfies OAuthClient
},
async listRegisteredApplications(db) {
-16
View File
@@ -159,19 +159,3 @@ describe('isPersonalOrg', () => {
expect(result).toBe(false)
})
})
describe('canManageAgentAccess', () => {
it.each([
['owner', true],
['admin', true],
['editor', false],
['viewer', false],
])('allows Agent Access management for %s: %s', async (role, expected) => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const orgId = await insertOrg(db, { metadata: '{"type":"team"}' })
await insertMember(db, orgId, userId, role)
expect(await createOrgRepo(db).canManageAgentAccess(userId, orgId)).toBe(expected)
})
})
+1 -7
View File
@@ -65,11 +65,5 @@ export function createOrgRepo(db: Database): OrgRepo {
return orgId === (await findPersonalOrg(userId))
}
async function canManageAgentAccess(userId: string, orgId: string): Promise<boolean> {
const role = await getMemberRole(orgId, userId)
if (role !== null) return role === 'owner' || role === 'admin'
return orgId === (await findPersonalOrg(userId))
}
return { findPersonalOrg, getMemberRole, getOrgNames, canReadOrg, canWriteToOrg, canManageAgentAccess, isPersonalOrg }
return { findPersonalOrg, getMemberRole, getOrgNames, canReadOrg, canWriteToOrg, isPersonalOrg }
}
+8 -8
View File
@@ -1,7 +1,7 @@
import { release as osRelease } from 'node:os'
import { OpenAPIHono } from '@hono/zod-openapi'
import { Scalar } from '@scalar/hono-api-reference'
import { AGENT_OAUTH_SCOPE_DESCRIPTIONS, AGENT_OAUTH_SCOPES } from '@shared/agent-oauth'
import { OAUTH_SCOPE_DESCRIPTIONS, OAUTH_SCOPES } from '@shared/oauth'
import type { Context } from 'hono'
import { cors } from 'hono/cors'
import type { Auth } from './auth'
@@ -10,7 +10,6 @@ import { createDeps } from './composition'
import { isPotentialWebDavPublicRequest, isWebDavPublicRequest } from './domain/webdav-public-url'
import { adminOverview } from './http/admin-overview'
import { adminStats } from './http/admin-stats'
import { agentOAuthGrants } from './http/agent-oauth-grants'
import { ARAZZO_DOCUMENT_PATH, ARAZZO_MEDIA_TYPE, createArazzoDocument } from './http/arazzo'
import { serveAvatarBlob } from './http/avatar-blobs'
import backgroundJobs from './http/background-jobs'
@@ -22,6 +21,7 @@ import ihostConfig from './http/image-hosting/config'
import ihost from './http/image-hosting/images'
import internal from './http/internal'
import { notifications } from './http/notifications'
import { oauthGrants } from './http/oauth-grants'
import { oauthResourceScopes } from './http/oauth-resource-scopes'
import objects from './http/objects'
import { adminQuotas, userQuotas } from './http/quotas'
@@ -146,7 +146,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
if (error?.error === 'unsupported_token_type') {
const token = (await revokeRequest.formData()).get('token')
if (typeof token === 'string') {
await c.get('deps').agentOAuth.revokeJwtAccessToken(c.get('platform').db, token)
await c.get('deps').oauth.revokeJwtAccessToken(c.get('platform').db, token)
return new Response(null, {
status: 200,
headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' },
@@ -172,7 +172,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
resource: `${origin}/api`,
authorization_servers: [authorizationServer],
bearer_methods_supported: ['header'],
scopes_supported: AGENT_OAUTH_SCOPES.filter((scope) => scope.includes(':')),
scopes_supported: OAUTH_SCOPES.filter((scope) => scope.includes(':')),
dpop_signing_alg_values_supported: ['ES256', 'EdDSA'],
resource_name: 'ZPan API',
})
@@ -237,7 +237,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
...(doc.components.securitySchemes ?? {}),
cookieAuth: { type: 'apiKey', in: 'cookie', name: 'zp.session_token' },
bearerAuth: { type: 'http', scheme: 'bearer' },
agentOAuth2: {
oauth2: {
type: 'oauth2',
flows: {
authorizationCode: {
@@ -357,7 +357,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
app.route('/api/objects', objects)
app.route('/api/shares', authedShares)
app.route('/api/trash', trash)
app.route('/api', agentOAuthGrants)
app.route('/api', oauthGrants)
app.route('/api/teams', teams)
app.route('/api/teams', adminTeams)
app.route('/api/site/storages', storages)
@@ -462,7 +462,7 @@ function getCorsOrigins(platform: Platform): Set<string> {
}
function agentScopeDescriptions(): Record<string, string> {
return { ...AGENT_OAUTH_SCOPE_DESCRIPTIONS }
return { ...OAUTH_SCOPE_DESCRIPTIONS }
}
export type AppType = ReturnType<typeof createApp>
@@ -508,5 +508,5 @@ export type AdminAuditRoute = typeof adminAudit
export type AdminOverviewRoute = typeof adminOverview
export type AdminStatsRoute = typeof adminStats
export type StorageUsageRoute = typeof storageUsage
export type AgentOAuthGrantsRoute = typeof agentOAuthGrants
export type OAuthGrantsRoute = typeof oauthGrants
export type OAuthResourceScopesRoute = typeof oauthResourceScopes
+13 -13
View File
@@ -795,7 +795,7 @@ describe('Cloudflare Workers preview auth origins', () => {
})
})
describe('Agent OAuth consent guards', () => {
describe('OAuth consent guards', () => {
it('publishes the external resource discovery contract at the exact API URL', async () => {
const ctx = await createTestApp()
const resource = await ctx.app.request('http://localhost:3000/api')
@@ -927,7 +927,7 @@ describe('Agent OAuth consent guards', () => {
const ctx = await createTestApp()
ctx.app.get('/api/test-agent-audit', async (c) => {
const principal = c.get('principal')
if (principal?.kind !== 'agent-oauth') return c.json({ error: 'agent principal required' }, 401)
if (principal?.kind !== 'oauth') return c.json({ error: 'agent principal required' }, 401)
await c.get('deps').audit.record({
...auditActor(principal),
orgId: principal.orgId,
@@ -937,15 +937,15 @@ describe('Agent OAuth consent guards', () => {
})
return c.json({ ok: true })
})
const { privateKey: agentPrivateKey, publicKey: agentPublicKey } = await generateKeyPair('ES256')
const agentPublicJwk = { ...(await exportJWK(agentPublicKey)), kid: 'agent-key', use: 'sig', alg: 'ES256' }
const { privateKey: actorPrivateKey, publicKey: actorPublicKey } = await generateKeyPair('ES256')
const actorPublicJwk = { ...(await exportJWK(actorPublicKey)), kid: 'actor-key', use: 'sig', alg: 'ES256' }
const getJwks = ctx.auth.api.getJwks
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
const url = input instanceof Request ? input.url : String(input)
if (url === 'https://broker.example.com/api/auth/jwks') {
return Response.json({ keys: [agentPublicJwk] })
return Response.json({ keys: [actorPublicJwk] })
}
if (url === 'http://localhost:3000/api/auth/jwks') {
return Response.json(await getJwks())
@@ -1000,7 +1000,7 @@ describe('Agent OAuth consent guards', () => {
)
const consentLocation = authorize.headers.get('location')
expect(authorize.status).toBe(302)
expect(consentLocation).toMatch(/^\/settings\/agent-access\?/)
expect(consentLocation).toMatch(/^\/settings\/oauth-apps\?/)
const consent = await ctx.app.request('http://localhost:3000/api/auth/oauth2/consent', {
method: 'POST',
headers: { Cookie: cookie, Origin: 'http://localhost:3000', 'Content-Type': 'application/json' },
@@ -1032,14 +1032,14 @@ describe('Agent OAuth consent guards', () => {
const now = Math.floor(Date.now() / 1000)
const assertion = await new SignJWT({})
.setProtectedHeader({ typ: 'JWT', alg: 'ES256', kid: 'agent-key' })
.setProtectedHeader({ typ: 'JWT', alg: 'ES256', kid: 'actor-key' })
.setIssuer('https://broker.example.com/api/auth')
.setSubject('agent-123')
.setAudience(tokenEndpoint)
.setIssuedAt(now)
.setExpirationTime(now + 300)
.setJti(crypto.randomUUID())
.sign(agentPrivateKey)
.sign(actorPrivateKey)
const actorResponse = await ctx.app.request(tokenEndpoint, {
method: 'POST',
headers: { Authorization: basic, 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -1102,7 +1102,7 @@ describe('Agent OAuth consent guards', () => {
.from(schema.auditEvents)
.where(eq(schema.auditEvents.action, 'agent_identity_probe'))
expect(auditEvent).toMatchObject({
actorType: 'agent_oauth',
actorType: 'oauth',
actorRef: 'agent-123',
actorIssuer: 'https://broker.example.com/api/auth',
})
@@ -1138,7 +1138,7 @@ describe('Agent OAuth consent guards', () => {
const previewOrigin = 'https://preview-zpan.example.com'
const auth = await createAuth(ctx.platform, 'test-secret', 'https://zpan-staging.example.com', [previewOrigin])
const app = createApp(ctx.platform, auth)
const signUpResponse = await signUp({ ...ctx, app }, 'agent-oauth-consent@example.com')
const signUpResponse = await signUp({ ...ctx, app }, 'oauth-consent@example.com')
const cookie = signUpResponse.headers
.getSetCookie()
.map((value) => value.split(';', 1)[0])
@@ -1171,7 +1171,7 @@ describe('Agent OAuth consent guards', () => {
})
const consentLocation = authorize.headers.get('location')
expect(authorize.status).toBe(302)
expect(consentLocation).toMatch(/^\/settings\/agent-access\?/)
expect(consentLocation).toMatch(/^\/settings\/oauth-apps\?/)
const consent = await app.request(`${previewOrigin}/api/auth/oauth2/consent`, {
method: 'POST',
@@ -1193,7 +1193,7 @@ describe('Agent OAuth consent guards', () => {
})
})
it('blocks partial Agent OAuth consent changes through the Better Auth endpoint', async () => {
it('blocks partial OAuth consent changes through the Better Auth endpoint', async () => {
const ctx = await createTestApp()
const res = await ctx.app.request('/api/auth/oauth2/consent', {
@@ -1205,7 +1205,7 @@ describe('Agent OAuth consent guards', () => {
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({
error: 'invalid_request',
error_description: 'Partial Agent OAuth consent is not supported',
error_description: 'Partial OAuth consent is not supported',
})
})
})
+6 -6
View File
@@ -19,7 +19,6 @@ import { genericOAuth } from 'better-auth/plugins/generic-oauth'
import { adminAc, memberAc, ownerAc } from 'better-auth/plugins/organization/access'
import { count, eq, like } from 'drizzle-orm'
import { customAlphabet, nanoid } from 'nanoid'
import { AGENT_OAUTH_SCOPES, JWT_BEARER_GRANT_TYPE, TOKEN_EXCHANGE_GRANT_TYPE } from '../shared/agent-oauth'
import {
API_KEY_TEMPLATES,
ApiKeyTemplate,
@@ -32,6 +31,7 @@ import {
WEBDAV_RATE_LIMITER_BINDING,
} from '../shared/api-key-templates'
import { DEFAULT_ORG_QUOTA, DEFAULT_ORG_TRAFFIC_QUOTA, SignupMode } from '../shared/constants'
import { JWT_BEARER_GRANT_TYPE, OAUTH_SCOPES, TOKEN_EXCHANGE_GRANT_TYPE } from '../shared/oauth'
import {
BUILTIN_PROVIDER_IDS,
OAUTH_PROVIDER_KEY_PATTERN,
@@ -54,7 +54,7 @@ import { createSiteInvitationRepo } from './adapters/repos/site-invitations'
import { initialStorageUsageProjectionQueries } from './adapters/repos/storage-usage-breakdown'
import { createSystemOptionsRepo } from './adapters/repos/system-options'
import { recordUserActivity } from './adapters/repos/user-activity'
import { createAgentOAuthProviderOptions } from './auth/agent-oauth-provider'
import { createOAuthProviderOptions } from './auth/oauth-provider'
import * as authSchema from './db/auth-schema'
import { orgQuotaEntitlements, orgQuotas, systemOptions } from './db/schema'
import { executeWriteTransaction } from './db/transaction'
@@ -520,7 +520,7 @@ export async function createAuth(
if (body?.scope !== undefined) {
throw new APIError('BAD_REQUEST', {
error: 'invalid_request',
error_description: 'Partial Agent OAuth consent is not supported',
error_description: 'Partial OAuth consent is not supported',
})
}
return
@@ -528,14 +528,14 @@ export async function createAuth(
if (ctx.path === '/oauth2/register') {
const body = ctx.body as Record<string, unknown> | undefined
if (body && isExternalResourceClientRegistration(body)) {
body.scope = AGENT_OAUTH_SCOPES.join(' ')
body.scope = OAUTH_SCOPES.join(' ')
}
return
}
if (ctx.path === '/oauth2/update-consent' || ctx.path === '/oauth2/delete-consent') {
throw new APIError('FORBIDDEN', {
error: 'invalid_request',
error_description: 'Manage Agent OAuth grants from the Agent Access API',
error_description: 'Manage OAuth grants from the OAuth grants API',
})
}
if (ctx.path !== '/api-key/create') return
@@ -721,7 +721,7 @@ export async function createAuth(
validateClient: async (clientId) => clientId === LEGACY_DOWNLOADER_CLIENT_ID,
}),
jwt(),
oauthProvider(createAgentOAuthProviderOptions({ db, resourceAudience })),
oauthProvider(createOAuthProviderOptions({ db, resourceAudience })),
apiKey([
{
configId: ApiKeyTemplate.IHOST,
@@ -1,7 +1,7 @@
import { AGENT_OAUTH_ACCESS_TOKEN_SECONDS, AGENT_OAUTH_SCOPES } from '@shared/agent-oauth'
import { AuthorizationScope } from '@shared/authorization'
import { OAUTH_ACCESS_TOKEN_SECONDS, OAUTH_SCOPES } from '@shared/oauth'
import { describe, expect, it, vi } from 'vitest'
import { createAgentOAuthProviderOptions } from './agent-oauth-provider'
import { createOAuthProviderOptions } from './oauth-provider'
const db = {} as never
@@ -9,7 +9,7 @@ function createOptions(input?: {
findPersonalOrg?: (userId: string) => Promise<string | null>
getMemberRole?: (orgId: string, userId: string) => Promise<string | null>
}) {
return createAgentOAuthProviderOptions({
return createOAuthProviderOptions({
db,
orgs: {
findPersonalOrg: input?.findPersonalOrg ?? vi.fn(async () => 'personal-org'),
@@ -18,18 +18,18 @@ function createOptions(input?: {
})
}
describe('createAgentOAuthProviderOptions', () => {
describe('createOAuthProviderOptions', () => {
it('configures a dynamic-client OAuth provider contract', async () => {
const options = createOptions()
expect(options).toMatchObject({
loginPage: '/sign-in',
consentPage: '/settings/agent-access',
accessTokenExpiresIn: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
consentPage: '/settings/oauth-apps',
accessTokenExpiresIn: OAUTH_ACCESS_TOKEN_SECONDS,
grantTypes: ['authorization_code', 'refresh_token'],
postLogin: { page: '/settings/agent-access' },
postLogin: { page: '/settings/oauth-apps' },
})
expect(options.scopes).toEqual([...AGENT_OAUTH_SCOPES])
expect(options.scopes).toEqual([...OAUTH_SCOPES])
expect(options.cachedTrustedClients).toBeUndefined()
expect(options.allowDynamicClientRegistration).toBe(true)
expect(options.allowUnauthenticatedClientRegistration).toBe(true)
@@ -79,7 +79,7 @@ describe('createAgentOAuthProviderOptions', () => {
scopes: [AuthorizationScope.OBJECTS_READ],
} as never),
).rejects.toMatchObject({
body: expect.objectContaining({ error_description: 'A workspace is required for Agent OAuth' }),
body: expect.objectContaining({ error_description: 'A workspace is required for OAuth' }),
})
await expect(
@@ -92,7 +92,7 @@ describe('createAgentOAuthProviderOptions', () => {
scopes: [AuthorizationScope.OBJECTS_READ],
} as never),
).rejects.toMatchObject({
body: expect.objectContaining({ error_description: 'Workspace access is required for Agent OAuth' }),
body: expect.objectContaining({ error_description: 'Workspace access is required for OAuth' }),
})
})
@@ -103,7 +103,7 @@ describe('createAgentOAuthProviderOptions', () => {
user: { id: 'user-1' },
referenceId: 'team-org',
} as never),
).resolves.toEqual({ zpan_org_id: 'team-org', zpan_actor: 'agent_oauth' })
).resolves.toEqual({ zpan_org_id: 'team-org', zpan_actor: 'oauth' })
})
it('omits ZPan resource claims without user or workspace context', async () => {
@@ -7,42 +7,42 @@ import {
} from '@better-auth/oauth-provider'
import { APIError, type User } from 'better-auth'
import { createLocalJWKSet, createRemoteJWKSet, type JSONWebKeySet, jwtVerify } from 'jose'
import { isAuthorizationScope } from '../../shared/authorization'
import {
AGENT_ACTOR_RESOURCE,
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
AGENT_OAUTH_ACTOR_TOKEN_SECONDS,
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
AGENT_OAUTH_SCOPES,
AGENT_OAUTH_STANDARD_SCOPES,
JWT_BEARER_GRANT_TYPE,
OAUTH_ACCESS_TOKEN_SECONDS,
OAUTH_ACCESS_TOKEN_TYPE,
OAUTH_ACTOR_TOKEN_SECONDS,
OAUTH_REFRESH_TOKEN_SECONDS,
OAUTH_SCOPES,
OAUTH_STANDARD_SCOPES,
TOKEN_EXCHANGE_GRANT_TYPE,
} from '../../shared/agent-oauth'
import { isAuthorizationScope } from '../../shared/authorization'
} from '../../shared/oauth'
import { createOrgRepo } from '../adapters/repos/org'
import type { Database } from '../platform/interface'
type AgentOAuthOrgLookup = Pick<ReturnType<typeof createOrgRepo>, 'findPersonalOrg' | 'getMemberRole'>
type AgentOAuthProviderOptions = Parameters<typeof oauthProvider>[0]
type OAuthOrgLookup = Pick<ReturnType<typeof createOrgRepo>, 'findPersonalOrg' | 'getMemberRole'>
type OAuthProviderOptions = Parameters<typeof oauthProvider>[0]
export function createAgentOAuthProviderOptions(input: {
export function createOAuthProviderOptions(input: {
db: Database
resourceAudience?: string
orgs?: AgentOAuthOrgLookup
}): AgentOAuthProviderOptions {
orgs?: OAuthOrgLookup
}): OAuthProviderOptions {
const orgs = input.orgs ?? createOrgRepo(input.db)
const resources = input.resourceAudience
? [
{
identifier: input.resourceAudience,
name: 'ZPan API',
accessTokenTtl: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
allowedScopes: [...AGENT_OAUTH_SCOPES],
accessTokenTtl: OAUTH_ACCESS_TOKEN_SECONDS,
allowedScopes: [...OAUTH_SCOPES],
},
{
identifier: AGENT_ACTOR_RESOURCE,
name: 'ZPan Agent Actor',
accessTokenTtl: AGENT_OAUTH_ACTOR_TOKEN_SECONDS,
accessTokenTtl: OAUTH_ACTOR_TOKEN_SECONDS,
allowedScopes: ['openid'],
},
]
@@ -50,42 +50,42 @@ export function createAgentOAuthProviderOptions(input: {
return {
loginPage: '/sign-in',
consentPage: '/settings/agent-access',
accessTokenExpiresIn: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
m2mAccessTokenExpiresIn: AGENT_OAUTH_ACTOR_TOKEN_SECONDS,
refreshTokenExpiresIn: AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
consentPage: '/settings/oauth-apps',
accessTokenExpiresIn: OAUTH_ACCESS_TOKEN_SECONDS,
m2mAccessTokenExpiresIn: OAUTH_ACTOR_TOKEN_SECONDS,
refreshTokenExpiresIn: OAUTH_REFRESH_TOKEN_SECONDS,
grantTypes: ['authorization_code', 'refresh_token'],
scopes: [...AGENT_OAUTH_SCOPES],
scopes: [...OAUTH_SCOPES],
resources,
enforcePerClientResources: false,
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
clientRegistrationRequirePKCE: true,
clientRegistrationAllowedScopes: [...AGENT_OAUTH_SCOPES],
clientRegistrationDefaultScopes: [...AGENT_OAUTH_STANDARD_SCOPES],
clientRegistrationAllowedScopes: [...OAUTH_SCOPES],
clientRegistrationDefaultScopes: [...OAUTH_STANDARD_SCOPES],
extensions: input.resourceAudience ? [externalResourceGrantExtension(input.resourceAudience)] : [],
advertisedMetadata: { scopes_supported: [...AGENT_OAUTH_SCOPES] },
advertisedMetadata: { scopes_supported: [...OAUTH_SCOPES] },
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
postLogin: {
page: '/settings/agent-access',
page: '/settings/oauth-apps',
shouldRedirect: async () => false,
consentReferenceId: async ({ user, session, scopes }) => {
const clientScopes = scopes.filter((scope) => scope !== 'openid' && scope !== 'profile' && scope !== 'email')
const grantableScopes = new Set<string>(AGENT_OAUTH_SCOPES)
const grantableScopes = new Set<string>(OAUTH_SCOPES)
if (clientScopes.some((scope) => !grantableScopes.has(scope))) {
throw oauthError('invalid_scope', 'Scope is not grantable')
}
const orgId = typeof session.activeOrganizationId === 'string' ? session.activeOrganizationId : null
const selectedOrgId = orgId || (await orgs.findPersonalOrg(user.id))
if (!selectedOrgId) throw oauthError('invalid_request', 'A workspace is required for Agent OAuth')
if (!selectedOrgId) throw oauthError('invalid_request', 'A workspace is required for OAuth')
const role = await orgs.getMemberRole(selectedOrgId, user.id)
if (!role && selectedOrgId !== (await orgs.findPersonalOrg(user.id))) {
throw new APIError('FORBIDDEN', {
error: 'access_denied',
error_description: 'Workspace access is required for Agent OAuth',
error_description: 'Workspace access is required for OAuth',
})
}
return selectedOrgId
@@ -95,7 +95,7 @@ export function createAgentOAuthProviderOptions(input: {
if (!user?.id || !referenceId) return {}
return {
zpan_org_id: referenceId,
zpan_actor: 'agent_oauth',
zpan_actor: 'oauth',
}
},
}
+2 -2
View File
@@ -15,7 +15,6 @@ import { createZipGateway } from './adapters/gateways/zip'
import { createChangelogProvider } from './adapters/providers/changelog'
import { createImageDomainProviderGateway } from './adapters/providers/image-domain-provider'
import { createAdminStatsRepo } from './adapters/repos/admin-stats'
import { createAgentOAuthGateway } from './adapters/repos/agent-oauth'
import { createAnnouncementRepo } from './adapters/repos/announcement'
import { createApiKeyGateway } from './adapters/repos/api-keys'
import { createArchiveTargetFolderRepo } from './adapters/repos/archive-target-folder'
@@ -35,6 +34,7 @@ import { createLicenseBindingRepo } from './adapters/repos/license-binding'
import { createMatterRepo } from './adapters/repos/matter'
import { createMemberCountRepo } from './adapters/repos/member-count'
import { createNotificationRepo } from './adapters/repos/notification'
import { createOAuthGateway } from './adapters/repos/oauth'
import { createObjectUploadSessionRepo } from './adapters/repos/object-upload-session'
import { createOrgRepo } from './adapters/repos/org'
import { createProfileRepo } from './adapters/repos/profile'
@@ -82,7 +82,7 @@ export function createDeps(platform: Platform, options: CreateDepsOptions = {}):
return {
audit: createAuditRepo(db),
adminStats: createAdminStatsRepo(db),
agentOAuth: createAgentOAuthGateway(),
oauth: createOAuthGateway(),
announcements: createAnnouncementRepo(db),
apiKeys: createApiKeyGateway(),
archiveJobs: createArchiveJobsGateway(platform),
+1 -1
View File
@@ -94,7 +94,7 @@ describe('downloaderBootstrapCredential table', () => {
})
})
describe('Agent OAuth tables', () => {
describe('OAuth tables', () => {
it('declares the managed client columns and indexes', () => {
const { foreignKeys, indexes } = getTableConfig(oauthClient)
+6 -6
View File
@@ -74,14 +74,14 @@ describe('[CF] Auth API', () => {
expect(rejected.status).toBe(403)
})
it('completes managed Agent OAuth consent on D1', async () => {
it('completes managed OAuth consent on D1', async () => {
const app = await buildApp()
const signUp = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'CF Agent OAuth',
email: `cf-agent-oauth-${Date.now()}@example.com`,
name: 'CF OAuth',
email: `cf-oauth-${Date.now()}@example.com`,
password: 'password123456',
}),
})
@@ -108,7 +108,7 @@ describe('[CF] Auth API', () => {
redirect_uri: 'https://broker.example.com/callback',
response_type: 'code',
scope: 'openid offline_access objects:read quota:read',
state: 'cf-agent-oauth',
state: 'cf-oauth',
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
code_challenge_method: 'S256',
})
@@ -117,9 +117,9 @@ describe('[CF] Auth API', () => {
})
const consentLocation = authorize.headers.get('location')
expect(authorize.status).toBe(302)
expect(consentLocation).toMatch(/^\/settings\/agent-access\?/)
expect(consentLocation).toMatch(/^\/settings\/oauth-apps\?/)
const consent = await app.request('/api/agent-oauth-consent', {
const consent = await app.request('/api/oauth-consent', {
method: 'POST',
headers: { Cookie: cookie, Origin: 'http://localhost', 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -1,5 +1,5 @@
import { AGENT_OAUTH_ACCESS_TOKEN_SECONDS, AGENT_OAUTH_REFRESH_TOKEN_SECONDS } from '@shared/agent-oauth'
import { AuthorizationScope } from '@shared/authorization'
import { OAUTH_ACCESS_TOKEN_SECONDS, OAUTH_REFRESH_TOKEN_SECONDS } from '@shared/oauth'
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../db/auth-schema.js'
@@ -97,14 +97,14 @@ function oauthQuery() {
}).toString()
}
describe('Agent OAuth grants API integration', () => {
describe('OAuth grants API integration', () => {
it('returns consent context for a dynamically registered application', async () => {
const { app, db } = await createTestApp()
await insertClient(db)
const headers = await authedHeaders(app, 'agent-consent@example.com')
const { orgId } = await getUserAndPersonalOrg(db, 'agent-consent@example.com')
const res = await app.request(`/api/agent-oauth-consent?oauthQuery=${encodeURIComponent(oauthQuery())}`, {
const res = await app.request(`/api/oauth-consent?oauthQuery=${encodeURIComponent(oauthQuery())}`, {
headers,
})
@@ -118,8 +118,8 @@ describe('Agent OAuth grants API integration', () => {
standardScopes: ['openid', 'offline_access'],
redirectUri: REDIRECT_URI,
grantLifetime: {
accessTokenSeconds: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
refreshTokenSeconds: AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
accessTokenSeconds: OAUTH_ACCESS_TOKEN_SECONDS,
refreshTokenSeconds: OAUTH_REFRESH_TOKEN_SECONDS,
},
})
})
@@ -129,14 +129,14 @@ describe('Agent OAuth grants API integration', () => {
await insertClient(db)
const headers = await authedHeaders(app, 'agent-submit@example.com')
const res = await app.request('/api/agent-oauth-consent', {
const res = await app.request('/api/oauth-consent', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ accept: true, oauthQuery: `client_id=${CLIENT_ID}&response_type=token` }),
})
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({ error: { message: 'Invalid Agent OAuth request' } })
await expect(res.json()).resolves.toMatchObject({ error: { message: 'Invalid OAuth request' } })
})
it('lists and revokes the current user dynamic-client grant family', async () => {
@@ -146,7 +146,7 @@ describe('Agent OAuth grants API integration', () => {
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 })
const list = await app.request('/api/oauth-grants', { headers })
expect(list.status).toBe(200)
await expect(list.json()).resolves.toEqual({
items: [
@@ -165,7 +165,7 @@ describe('Agent OAuth grants API integration', () => {
],
})
const revoke = await app.request('/api/agent-oauth-grants/grant-1', { method: 'DELETE', headers })
const revoke = await app.request('/api/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)
@@ -177,9 +177,9 @@ describe('Agent OAuth grants API integration', () => {
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 })
const revoke = await app.request('/api/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' } })
await expect(revoke.json()).resolves.toMatchObject({ error: { message: 'OAuth grant not found' } })
})
})
@@ -1,30 +1,30 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import {
agentOAuthConsentContextSchema,
agentOAuthConsentResultSchema,
agentOAuthConsentSubmitSchema,
agentOAuthGrantListSchema,
oauthConsentContextSchema,
oauthConsentResultSchema,
oauthConsentSubmitSchema,
oauthGrantListSchema,
} from '@shared/schemas'
import type { Env } from '../middleware/platform'
import { getAgentOAuthConsentContext } from '../usecases/agent-oauth-consent'
import { listAgentOAuthGrants, revokeAgentOAuthGrant } from '../usecases/agent-oauth-grants'
import { getOAuthConsentContext } from '../usecases/oauth-consent'
import { listOAuthGrants, revokeOAuthGrant } from '../usecases/oauth-grants'
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
const paramsSchema = z.object({ grantId: z.string().min(1) })
const consentContextQuerySchema = z.object({ oauthQuery: z.string().min(1) })
const consentContextRoute = authRoute(
{ scopes: [AuthorizationScope.AGENT_OAUTH_GRANTS_CREATE] },
{ scopes: [AuthorizationScope.OAUTH_GRANTS_CREATE] },
{
operationId: 'getAgentOAuthConsentContext',
summary: 'Get pending Agent OAuth consent context',
tags: ['Agent Access'],
operationId: 'getOAuthConsentContext',
summary: 'Get pending OAuth consent context',
tags: ['OAuth Apps'],
method: 'get',
path: '/agent-oauth-consent',
path: '/oauth-consent',
request: { query: consentContextQuerySchema },
responses: {
200: jsonContent(agentOAuthConsentContextSchema, 'Agent OAuth consent context'),
200: jsonContent(oauthConsentContextSchema, 'OAuth consent context'),
400: errorResponse('Invalid OAuth request'),
403: errorResponse('Workspace access is required'),
},
@@ -32,16 +32,16 @@ const consentContextRoute = authRoute(
)
const consentSubmitRoute = authRoute(
{ scopes: [AuthorizationScope.AGENT_OAUTH_GRANTS_CREATE] },
{ scopes: [AuthorizationScope.OAUTH_GRANTS_CREATE] },
{
operationId: 'submitAgentOAuthConsent',
summary: 'Submit Agent OAuth consent decision',
tags: ['Agent Access'],
operationId: 'submitOAuthConsent',
summary: 'Submit OAuth consent decision',
tags: ['OAuth Apps'],
method: 'post',
path: '/agent-oauth-consent',
request: jsonBody(agentOAuthConsentSubmitSchema),
path: '/oauth-consent',
request: jsonBody(oauthConsentSubmitSchema),
responses: {
200: jsonContent(agentOAuthConsentResultSchema, 'Agent OAuth consent result'),
200: jsonContent(oauthConsentResultSchema, 'OAuth consent result'),
400: errorResponse('Invalid OAuth request'),
403: errorResponse('Workspace access is required'),
},
@@ -49,39 +49,39 @@ const consentSubmitRoute = authRoute(
)
const listRoute = authRoute(
{ scopes: [AuthorizationScope.AGENT_OAUTH_GRANTS_READ] },
{ scopes: [AuthorizationScope.OAUTH_GRANTS_READ] },
{
operationId: 'listAgentOAuthGrants',
summary: 'List Agent OAuth grants',
tags: ['Agent Access'],
operationId: 'listOAuthGrants',
summary: 'List OAuth grants',
tags: ['OAuth Apps'],
method: 'get',
path: '/agent-oauth-grants',
path: '/oauth-grants',
responses: {
200: jsonContent(agentOAuthGrantListSchema, 'Agent OAuth grants'),
200: jsonContent(oauthGrantListSchema, 'OAuth grants'),
},
},
)
const revokeRoute = authRoute(
{ scopes: [AuthorizationScope.AGENT_OAUTH_GRANTS_DELETE] },
{ scopes: [AuthorizationScope.OAUTH_GRANTS_DELETE] },
{
operationId: 'revokeAgentOAuthGrant',
summary: 'Revoke an Agent OAuth grant',
tags: ['Agent Access'],
operationId: 'revokeOAuthGrant',
summary: 'Revoke an OAuth grant',
tags: ['OAuth Apps'],
method: 'delete',
path: '/agent-oauth-grants/{grantId}',
path: '/oauth-grants/{grantId}',
request: { params: paramsSchema },
responses: {
204: { description: 'Revoked' },
404: errorResponse('Agent OAuth grant not found'),
404: errorResponse('OAuth grant not found'),
},
},
)
export const agentOAuthGrants = new OpenAPIHono<Env>()
export const oauthGrants = new OpenAPIHono<Env>()
.openapi(consentContextRoute, async (c) => {
const { oauthQuery } = c.req.valid('query')
const context = await getAgentOAuthConsentContext(c.get('deps'), {
const context = await getOAuthConsentContext(c.get('deps'), {
db: c.get('platform').db,
userId: c.get('userId')!,
orgId: c.get('orgId'),
@@ -92,7 +92,7 @@ export const agentOAuthGrants = new OpenAPIHono<Env>()
})
.openapi(consentSubmitRoute, async (c) => {
const { accept, oauthQuery } = c.req.valid('json')
await getAgentOAuthConsentContext(c.get('deps'), {
await getOAuthConsentContext(c.get('deps'), {
db: c.get('platform').db,
userId: c.get('userId')!,
orgId: c.get('orgId'),
@@ -111,15 +111,15 @@ export const agentOAuthGrants = new OpenAPIHono<Env>()
)
const body = await response.json().catch(() => null)
if (!response.ok) return c.json(body ?? { error: response.statusText }, response.status as 400 | 403)
return c.json(agentOAuthConsentResultSchema.parse(body), 200)
return c.json(oauthConsentResultSchema.parse(body), 200)
})
.openapi(listRoute, async (c) => {
const result = await listAgentOAuthGrants(c.get('deps'), c.get('platform').db, { userId: c.get('userId')! })
const result = await listOAuthGrants(c.get('deps'), c.get('platform').db, { userId: c.get('userId')! })
return c.json(result, 200)
})
.openapi(revokeRoute, async (c) => {
const { grantId } = c.req.valid('param')
await revokeAgentOAuthGrant(c.get('deps'), c.get('platform').db, {
await revokeOAuthGrant(c.get('deps'), c.get('platform').db, {
userId: c.get('userId')!,
grantId,
})
+4 -4
View File
@@ -1,5 +1,5 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AGENT_OAUTH_RESOURCE_SCOPES, AGENT_OAUTH_SCOPE_DESCRIPTIONS } from '@shared/agent-oauth'
import { OAUTH_RESOURCE_SCOPES, OAUTH_SCOPE_DESCRIPTIONS } from '@shared/oauth'
import type { Env } from '../middleware/platform'
import { authRoute, jsonContent } from './openapi'
@@ -28,7 +28,7 @@ const route = authRoute(
// security. The empty alternative truthfully documents that this catalog
// endpoint itself is public. Protected business operations remain unbound so a
// delegated credential hook can sign them before Restish's built-in auth runs.
const scopeCatalogSecurity: Record<string, string[]>[] = [{ agentOAuth2: [...AGENT_OAUTH_RESOURCE_SCOPES] }, {}]
const scopeCatalogSecurity: Record<string, string[]>[] = [{ oauth2: [...OAUTH_RESOURCE_SCOPES] }, {}]
const scopeCatalogRoute = Object.assign(route, {
security: scopeCatalogSecurity,
'x-mcp-ignore': true,
@@ -37,9 +37,9 @@ const scopeCatalogRoute = Object.assign(route, {
export const oauthResourceScopes = new OpenAPIHono<Env>().openapi(scopeCatalogRoute, (c) =>
c.json(
{
scopes: AGENT_OAUTH_RESOURCE_SCOPES.map((value) => ({
scopes: OAUTH_RESOURCE_SCOPES.map((value) => ({
value,
description: AGENT_OAUTH_SCOPE_DESCRIPTIONS[value],
description: OAUTH_SCOPE_DESCRIPTIONS[value],
})),
},
200,
+3 -3
View File
@@ -1,5 +1,5 @@
import { createRoute, type RouteConfig, type z } from '@hono/zod-openapi'
import { AGENT_OAUTH_SCOPES } from '@shared/agent-oauth'
import { OAUTH_SCOPES } from '@shared/oauth'
import { errorResponseSchema } from '@shared/schemas'
import { authorize, type RouteAuthorizationDeclaration, type ScopedAuthorizationPolicy } from '../middleware/authz'
@@ -24,7 +24,7 @@ export const jsonBody = <T extends z.ZodType>(schema: T) => ({
// `jsonError`; this just documents the response shape in the OpenAPI document.
export const errorResponse = (description: string) => jsonContent(errorResponseSchema, description)
const AGENT_OAUTH_SCOPE_SET = new Set<string>(AGENT_OAUTH_SCOPES)
const OAUTH_SCOPE_SET = new Set<string>(OAUTH_SCOPES)
export function authRoute<P extends string, T extends Omit<RouteConfig, 'path'> & { path: P }>(
auth: RouteAuthorizationDeclaration,
@@ -86,7 +86,7 @@ function openApiPolicySecurity(policy: ScopedAuthorizationPolicy): Record<string
}
function isAgentCallablePolicy(policy: ScopedAuthorizationPolicy): boolean {
return policy.scopes.every((scope) => AGENT_OAUTH_SCOPE_SET.has(scope))
return policy.scopes.every((scope) => OAUTH_SCOPE_SET.has(scope))
}
function openApiPolicyMetadata(policy: ScopedAuthorizationPolicy): Record<string, unknown> {
@@ -29,6 +29,31 @@ describe('GET /api/site/audit-events — auth guards', () => {
})
describe('GET /api/site/audit-events — licensed admin', () => {
it('normalizes the historical OAuth actor type', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
const { auditEvents } = await import('../../db/schema.js')
await db.insert(auditEvents).values({
id: 'evt-legacy-oauth',
orgId: 'org-oauth',
userId: 'oauth-user',
actorType: ['agent', 'oauth'].join('_'),
actorRef: 'controller-1',
action: 'objects_list',
targetType: 'file',
targetId: null,
targetName: 'OAuth request',
metadata: null,
createdAt: new Date(),
})
const res = await app.request('/api/site/audit-events?action=objects_list', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { items: Array<{ actorType: string; user: { name: string } }> }
expect(body.items[0]).toMatchObject({ actorType: 'oauth', user: { name: 'OAuth:controller-1' } })
})
it('returns an empty list when no events match the filter [spec: audit/empty]', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
+1 -1
View File
@@ -13,7 +13,7 @@ const auditEventSchema = z
id: z.string(),
orgId: z.string(),
userId: z.string().nullable(),
actorType: z.enum(['user', 'api_key', 'agent_oauth', 'agent', 'anonymous', 'system', 'downloader', 'task-upload']),
actorType: z.enum(['user', 'api_key', 'oauth', 'agent', 'anonymous', 'system', 'downloader', 'task-upload']),
actorRef: z.string().nullable(),
actorIssuer: z.string().nullable(),
action: z.string(),
+1 -1
View File
@@ -75,7 +75,7 @@ const activityEventSchema = z
id: z.string(),
orgId: z.string(),
userId: z.string().nullable(),
actorType: z.enum(['user', 'api_key', 'agent_oauth', 'agent', 'anonymous', 'system', 'downloader', 'task-upload']),
actorType: z.enum(['user', 'api_key', 'oauth', 'agent', 'anonymous', 'system', 'downloader', 'task-upload']),
actorRef: z.string().nullable(),
actorIssuer: z.string().nullable(),
action: z.string(),
+3 -3
View File
@@ -3,9 +3,9 @@ import { auditActor } from './audit-actor'
import type { AuthPrincipal } from './platform'
describe('auditActor', () => {
it('records Agent OAuth principals as delegated Agent actors', () => {
it('records OAuth principals as delegated Agent actors', () => {
const principal: AuthPrincipal = {
kind: 'agent-oauth',
kind: 'oauth',
userId: 'user-1',
actorIssuer: 'https://id.realmroot.dev/api/auth',
actorSubject: 'agt_agent-1',
@@ -17,7 +17,7 @@ describe('auditActor', () => {
expect(auditActor(principal)).toEqual({
userId: 'user-1',
actorType: 'agent_oauth',
actorType: 'oauth',
actorRef: 'agt_agent-1',
actorIssuer: 'https://id.realmroot.dev/api/auth',
})
+2 -2
View File
@@ -11,10 +11,10 @@ export function auditActor(principal: AuthPrincipal | null): AuditActor {
if (principal.kind === 'api-key') {
return { userId: principal.userId, actorType: 'api_key', actorRef: principal.keyId, actorIssuer: null }
}
if (principal.kind === 'agent-oauth') {
if (principal.kind === 'oauth') {
return {
userId: principal.userId,
actorType: 'agent_oauth',
actorType: 'oauth',
actorRef: principal.actorSubject,
actorIssuer: principal.actorIssuer,
}
+4 -4
View File
@@ -47,14 +47,14 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
}
if (
typeof payload.jti !== 'string' ||
(await c.get('deps').agentOAuth.isJwtAccessTokenRevoked(c.get('platform').db, payload.jti))
(await c.get('deps').oauth.isJwtAccessTokenRevoked(c.get('platform').db, payload.jti))
) {
throw dpopUnauthorized(audience)
}
if (await c.get('deps').userAdmin.isBanned(userId)) throw unauthorized('Unauthorized')
const scopes = typeof payload.scope === 'string' ? payload.scope.split(/\s+/).filter(isAuthorizationScope) : []
c.set('principal', {
kind: 'agent-oauth',
kind: 'oauth',
actorIssuer,
actorSubject,
clientId,
@@ -64,11 +64,11 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
authMethod: 'dpop',
})
c.set('authzContext', {
credential: 'agent_oauth',
credential: 'oauth',
userId,
workspace: { mode: 'bound', orgId },
grantedScopes: new Set(scopes),
actor: { type: 'agent_oauth', ref: actorSubject, issuer: actorIssuer },
actor: { type: 'oauth', ref: actorSubject, issuer: actorIssuer },
state: { clientId },
})
c.set('userId', userId)
+3 -3
View File
@@ -53,7 +53,7 @@ export type AuthPrincipal =
authMethod: 'api-key'
}
| {
kind: 'agent-oauth'
kind: 'oauth'
actorIssuer: string
actorSubject: string
clientId: string
@@ -115,11 +115,11 @@ export type AuthzContext =
state: { configId: string; enabled: true }
}
| {
credential: 'agent_oauth'
credential: 'oauth'
userId: string
workspace: { mode: 'bound'; orgId: string }
grantedScopes: ReadonlySet<AuthorizationScope>
actor: { type: 'agent_oauth'; ref: string; issuer: string }
actor: { type: 'oauth'; ref: string; issuer: string }
state: { clientId: string }
}
| {
+4 -4
View File
@@ -144,7 +144,7 @@ describe('global OpenAPI document', () => {
'x-cli-config'?: unknown
}
expect(doc.components?.securitySchemes?.agentOAuth2).toMatchObject({
expect(doc.components?.securitySchemes?.oauth2).toMatchObject({
type: 'oauth2',
flows: {
authorizationCode: {
@@ -197,7 +197,7 @@ describe('global OpenAPI document', () => {
expect(document.paths['/api/oauth-resource-scopes']?.get).toMatchObject({
security: [
{
agentOAuth2: expect.arrayContaining([
oauth2: expect.arrayContaining([
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.OBJECTS_CREATE,
AuthorizationScope.OBJECTS_UPDATE,
@@ -450,8 +450,8 @@ describe('global OpenAPI document', () => {
}
const ignoredOperations = [
doc.paths['/api/agent-oauth-grants']?.get,
doc.paths['/api/agent-oauth-grants/{grantId}']?.delete,
doc.paths['/api/oauth-grants']?.get,
doc.paths['/api/oauth-grants/{grantId}']?.delete,
doc.paths['/api/site/storages']?.post,
doc.paths['/api/auth/sign-in/email']?.post,
doc.paths['/api/auth/sign-out']?.post,
@@ -4,12 +4,12 @@ import { join } from 'node:path'
import Database from 'better-sqlite3'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildAgentOAuthScopeBackfill,
parseAgentOAuthScopeBackfillOptions,
runAgentOAuthScopeBackfill,
} from '../../scripts/backfill-agent-oauth-scopes'
import { AGENT_OAUTH_SCOPES } from '../../shared/agent-oauth'
buildOAuthScopeBackfill,
parseOAuthScopeBackfillOptions,
runOAuthScopeBackfill,
} from '../../scripts/backfill-oauth-scopes'
import { AuthorizationScope } from '../../shared/authorization'
import { OAUTH_SCOPES } from '../../shared/oauth'
const tempDirs: string[] = []
@@ -18,7 +18,7 @@ afterEach(() => {
})
function createScopeDatabase() {
const dir = mkdtempSync(join(tmpdir(), 'zpan-agent-oauth-backfill-'))
const dir = mkdtempSync(join(tmpdir(), 'zpan-oauth-backfill-'))
tempDirs.push(dir)
const path = join(dir, 'zpan.db')
const db = new Database(path)
@@ -29,9 +29,9 @@ function createScopeDatabase() {
return { db, path }
}
describe('buildAgentOAuthScopeBackfill', () => {
describe('buildOAuthScopeBackfill', () => {
it('updates ZPan resources and upload clients without expanding read-only clients', () => {
const changes = buildAgentOAuthScopeBackfill(
const changes = buildOAuthScopeBackfill(
[
{
id: 'zpan-resource',
@@ -57,7 +57,7 @@ describe('buildAgentOAuthScopeBackfill', () => {
)
expect(changes).toEqual({
resources: [{ id: 'zpan-resource', scopes: JSON.stringify(AGENT_OAUTH_SCOPES) }],
resources: [{ id: 'zpan-resource', scopes: JSON.stringify(OAUTH_SCOPES) }],
clients: [
{
id: 'upload-client',
@@ -68,8 +68,8 @@ describe('buildAgentOAuthScopeBackfill', () => {
})
it('is idempotent after the scopes are current', () => {
const changes = buildAgentOAuthScopeBackfill(
[{ id: 'zpan-resource', name: 'ZPan API', allowedScopes: JSON.stringify(AGENT_OAUTH_SCOPES) }],
const changes = buildOAuthScopeBackfill(
[{ id: 'zpan-resource', name: 'ZPan API', allowedScopes: JSON.stringify(OAUTH_SCOPES) }],
[
{
id: 'upload-client',
@@ -82,26 +82,26 @@ describe('buildAgentOAuthScopeBackfill', () => {
})
it('rejects malformed client scope documents', () => {
expect(() => buildAgentOAuthScopeBackfill([], [{ id: 'bad', scopes: '{' }])).toThrow(SyntaxError)
expect(() => buildAgentOAuthScopeBackfill([], [{ id: 'bad', scopes: '["objects:create", 1]' }])).toThrow(
expect(() => buildOAuthScopeBackfill([], [{ id: 'bad', scopes: '{' }])).toThrow(SyntaxError)
expect(() => buildOAuthScopeBackfill([], [{ id: 'bad', scopes: '["objects:create", 1]' }])).toThrow(
'invalid_oauth_client_scopes',
)
expect(buildAgentOAuthScopeBackfill([], [{ id: 'empty', scopes: null }])).toEqual({ resources: [], clients: [] })
expect(buildOAuthScopeBackfill([], [{ id: 'empty', scopes: null }])).toEqual({ resources: [], clients: [] })
})
it('parses sqlite and D1 targets and rejects ambiguous invocations', () => {
expect(parseAgentOAuthScopeBackfillOptions(['--sqlite', '/tmp/zpan.db', '--apply'])).toEqual({
expect(parseOAuthScopeBackfillOptions(['--sqlite', '/tmp/zpan.db', '--apply'])).toEqual({
apply: true,
target: { kind: 'sqlite', path: '/tmp/zpan.db' },
})
expect(parseAgentOAuthScopeBackfillOptions(['--d1', 'zpan-db', '--remote', '--env', 'staging'])).toEqual({
expect(parseOAuthScopeBackfillOptions(['--d1', 'zpan-db', '--remote', '--env', 'staging'])).toEqual({
apply: false,
target: { kind: 'd1', database: 'zpan-db', remote: true, env: 'staging' },
})
expect(() => parseAgentOAuthScopeBackfillOptions([])).toThrow('Usage:')
expect(() => parseAgentOAuthScopeBackfillOptions(['--sqlite', 'a', '--d1', 'b'])).toThrow('Usage:')
expect(() => parseAgentOAuthScopeBackfillOptions(['--sqlite'])).toThrow('Usage:')
expect(() => parseAgentOAuthScopeBackfillOptions(['--d1'])).toThrow('Usage:')
expect(() => parseOAuthScopeBackfillOptions([])).toThrow('Usage:')
expect(() => parseOAuthScopeBackfillOptions(['--sqlite', 'a', '--d1', 'b'])).toThrow('Usage:')
expect(() => parseOAuthScopeBackfillOptions(['--sqlite'])).toThrow('Usage:')
expect(() => parseOAuthScopeBackfillOptions(['--d1'])).toThrow('Usage:')
})
it('dry-runs and applies the SQLite backfill end to end', () => {
@@ -118,13 +118,13 @@ describe('buildAgentOAuthScopeBackfill', () => {
db.close()
const logs: string[] = []
runAgentOAuthScopeBackfill(['--sqlite', path], (message) => logs.push(message))
runOAuthScopeBackfill(['--sqlite', path], (message) => logs.push(message))
expect(JSON.parse(logs[0])).toEqual({ mode: 'dry-run', resources: 1, clients: 1 })
runAgentOAuthScopeBackfill(['--sqlite', path, '--apply'], (message) => logs.push(message))
runOAuthScopeBackfill(['--sqlite', path, '--apply'], (message) => logs.push(message))
const after = new Database(path, { readonly: true })
expect(after.prepare('SELECT allowed_scopes FROM oauthResource WHERE id = ?').pluck().get('zpan-resource')).toBe(
JSON.stringify(AGENT_OAUTH_SCOPES),
JSON.stringify(OAUTH_SCOPES),
)
expect(after.prepare('SELECT scopes FROM oauthClient WHERE id = ?').pluck().get('upload-client')).toBe(
JSON.stringify([AuthorizationScope.OBJECTS_CREATE, AuthorizationScope.QUOTA_PURCHASE]),
@@ -134,7 +134,7 @@ describe('buildAgentOAuthScopeBackfill', () => {
})
it('reads and applies a remote D1 backfill with escaped identifiers', () => {
const resourceScopes = JSON.stringify(AGENT_OAUTH_SCOPES)
const resourceScopes = JSON.stringify(OAUTH_SCOPES)
const clientScopes = JSON.stringify([AuthorizationScope.OBJECTS_CREATE, AuthorizationScope.QUOTA_PURCHASE])
const execute = vi
.fn()
@@ -151,7 +151,7 @@ describe('buildAgentOAuthScopeBackfill', () => {
)
.mockReturnValueOnce(JSON.stringify([{ results: [{ id: 'client-1', scopes: clientScopes }] }]))
runAgentOAuthScopeBackfill(['--d1', 'zpan-db', '--remote', '--env', 'production', '--apply'], () => {}, execute)
runOAuthScopeBackfill(['--d1', 'zpan-db', '--remote', '--env', 'production', '--apply'], () => {}, execute)
expect(execute).toHaveBeenCalledTimes(6)
expect(execute.mock.calls[0]?.[0]).toEqual({
+2 -2
View File
@@ -4,7 +4,6 @@
import type {
AdminStatsRepo,
AgentOAuthGateway,
AnnouncementRepo,
ApiKeyGateway,
ArchiveJobsGateway,
@@ -31,6 +30,7 @@ import type {
MatterRepo,
MemberCountRepo,
NotificationRepo,
OAuthGateway,
ObjectUploadSessionRepo,
OrgRepo,
ProfileRepo,
@@ -58,7 +58,7 @@ import type {
export interface Deps {
audit: AuditRepo
adminStats: AdminStatsRepo
agentOAuth: AgentOAuthGateway
oauth: OAuthGateway
announcements: AnnouncementRepo
apiKeys: ApiKeyGateway
archiveJobs: ArchiveJobsGateway
@@ -1,7 +1,7 @@
import { AuthorizationScope } from '@shared/authorization'
import { describe, expect, it, vi } from 'vitest'
import { getAgentOAuthConsentContext } from './agent-oauth-consent'
import type { AgentOAuthGateway, OrgRepo } from './ports'
import { getOAuthConsentContext } from './oauth-consent'
import type { OAuthGateway, OrgRepo } from './ports'
const db = {} as never
const CLIENT_ID = 'dynamic-client'
@@ -14,7 +14,6 @@ function org(overrides: Partial<OrgRepo> = {}): OrgRepo {
getOrgNames: vi.fn(async () => new Map([['org-1', 'Personal']])),
canReadOrg: vi.fn(async () => true),
canWriteToOrg: vi.fn(),
canManageAgentAccess: vi.fn(),
isPersonalOrg: vi.fn(),
...overrides,
}
@@ -31,7 +30,7 @@ function deps(
) {
return {
org: orgRepo,
agentOAuth: {
oauth: {
findClient: vi.fn(async () => ({
clientId: client.clientId ?? CLIENT_ID,
clientName: client.clientName ?? CLIENT_NAME,
@@ -45,7 +44,7 @@ function deps(
AuthorizationScope.QUOTA_READ,
],
})),
} as unknown as AgentOAuthGateway,
} as unknown as OAuthGateway,
}
}
@@ -59,14 +58,14 @@ function oauthQuery(overrides: Record<string, string> = {}) {
}).toString()
}
describe('Agent OAuth consent usecase', () => {
describe('OAuth consent usecase', () => {
it('resolves a dynamically registered client instead of hard-coding its identity', async () => {
const dynamicQuery = oauthQuery({
client_id: 'dynamic-client',
redirect_uri: 'https://broker.example.com/oauth/callback',
})
await expect(
getAgentOAuthConsentContext(
getOAuthConsentContext(
deps(org(), {
clientId: 'dynamic-client',
clientName: 'Broker',
@@ -76,7 +75,7 @@ describe('Agent OAuth consent usecase', () => {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: dynamicQuery,
},
),
@@ -89,11 +88,11 @@ describe('Agent OAuth consent usecase', () => {
it('builds server-owned consent context for the active workspace', async () => {
await expect(
getAgentOAuthConsentContext(deps(org()), {
getOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: oauthQuery(),
}),
).resolves.toEqual({
@@ -113,11 +112,11 @@ describe('Agent OAuth consent usecase', () => {
it('keeps the active workspace id when the workspace name is unavailable', async () => {
await expect(
getAgentOAuthConsentContext(deps(org({ getOrgNames: vi.fn(async () => new Map()) })), {
getOAuthConsentContext(deps(org({ getOrgNames: vi.fn(async () => new Map()) })), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: oauthQuery(),
}),
).resolves.toMatchObject({
@@ -127,11 +126,11 @@ describe('Agent OAuth consent usecase', () => {
it('rejects requests that are not the managed authorization-code client flow', async () => {
await expect(
getAgentOAuthConsentContext(deps(org()), {
getOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: oauthQuery({ response_type: 'token' }),
}),
).rejects.toMatchObject({ httpStatus: 400 })
@@ -139,21 +138,21 @@ describe('Agent OAuth consent usecase', () => {
it('rejects untrusted redirect URIs and non-grantable scopes', async () => {
await expect(
getAgentOAuthConsentContext(deps(org()), {
getOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: oauthQuery({ redirect_uri: 'https://evil.example/callback' }),
}),
).rejects.toMatchObject({ httpStatus: 400 })
await expect(
getAgentOAuthConsentContext(deps(org()), {
getOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: oauthQuery({ scope: 'objects:purge' }),
}),
).rejects.toMatchObject({ httpStatus: 400 })
@@ -161,11 +160,11 @@ describe('Agent OAuth consent usecase', () => {
it('rejects missing or inaccessible workspaces', async () => {
await expect(
getAgentOAuthConsentContext(deps(org({ canReadOrg: vi.fn(async () => false) })), {
getOAuthConsentContext(deps(org({ canReadOrg: vi.fn(async () => false) })), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: oauthQuery(),
}),
).rejects.toMatchObject({ httpStatus: 403 })
@@ -1,18 +1,14 @@
import {
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
AGENT_OAUTH_STANDARD_SCOPES,
} from '@shared/agent-oauth'
import { isAuthorizationScope } from '@shared/authorization'
import { type AgentOAuthConsentContext, type OAuthResourceScope, oauthResourceScopeSchema } from '@shared/schemas'
import { OAUTH_ACCESS_TOKEN_SECONDS, OAUTH_REFRESH_TOKEN_SECONDS, OAUTH_STANDARD_SCOPES } from '@shared/oauth'
import { type OAuthConsentContext, type OAuthResourceScope, oauthResourceScopeSchema } from '@shared/schemas'
import type { Database } from '../platform/interface'
import type { Deps } from './deps'
import { badRequest, forbidden } from './ports'
export async function getAgentOAuthConsentContext(
deps: Pick<Deps, 'agentOAuth' | 'org'>,
export async function getOAuthConsentContext(
deps: Pick<Deps, 'oauth' | 'org'>,
input: { db: Database; userId: string; orgId: string | null; requestUrl: string; oauthQuery: string },
): Promise<AgentOAuthConsentContext> {
): Promise<OAuthConsentContext> {
const params = new URLSearchParams(input.oauthQuery)
const clientId = params.get('client_id')
const redirectUri = params.get('redirect_uri')
@@ -20,34 +16,32 @@ export async function getAgentOAuthConsentContext(
const scopeValue = params.get('scope') ?? ''
if (!clientId || responseType !== 'code' || !redirectUri) {
throw badRequest('Invalid Agent OAuth request')
throw badRequest('Invalid OAuth request')
}
const client = await deps.agentOAuth.findClient(input.db, clientId)
const client = await deps.oauth.findClient(input.db, clientId)
if (
!client ||
client.disabled ||
!client.responseTypes.includes('code') ||
!client.redirectUris.includes(redirectUri)
) {
throw badRequest('Invalid Agent OAuth redirect URI')
throw badRequest('Invalid OAuth redirect URI')
}
const requestedScopes = scopeValue.split(/\s+/).filter(Boolean)
const standardScopes = requestedScopes.filter((scope) =>
(AGENT_OAUTH_STANDARD_SCOPES as readonly string[]).includes(scope),
)
const standardScopes = requestedScopes.filter((scope) => (OAUTH_STANDARD_SCOPES as readonly string[]).includes(scope))
const scopes = requestedScopes.filter(isOAuthResourceScope)
if (
scopes.length === 0 ||
requestedScopes.length !== standardScopes.length + scopes.length ||
requestedScopes.some((scope) => !client.scopes.includes(scope))
) {
throw badRequest('Invalid Agent OAuth scope')
throw badRequest('Invalid OAuth scope')
}
const orgId = input.orgId
if (!orgId || !(await deps.org.canReadOrg(input.userId, orgId))) {
throw forbidden('Workspace access is required for Agent OAuth')
throw forbidden('Workspace access is required for OAuth')
}
const names = await deps.org.getOrgNames([orgId])
@@ -60,8 +54,8 @@ export async function getAgentOAuthConsentContext(
standardScopes,
redirectUri,
grantLifetime: {
accessTokenSeconds: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
refreshTokenSeconds: AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
accessTokenSeconds: OAUTH_ACCESS_TOKEN_SECONDS,
refreshTokenSeconds: OAUTH_REFRESH_TOKEN_SECONDS,
},
}
}
@@ -1,10 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { listAgentOAuthGrants, revokeAgentOAuthGrant } from './agent-oauth-grants'
import type { AgentOAuthGateway, OrgRepo } from './ports'
import { listOAuthGrants, revokeOAuthGrant } from './oauth-grants'
import type { OAuthGateway, OrgRepo } from './ports'
const db = {} as never
function gateway(overrides: Partial<AgentOAuthGateway> = {}): AgentOAuthGateway {
function gateway(overrides: Partial<OAuthGateway> = {}): OAuthGateway {
return {
findClient: vi.fn(),
listRegisteredApplications: vi.fn(),
@@ -23,15 +23,14 @@ function org(overrides: Partial<OrgRepo> = {}): OrgRepo {
getOrgNames: vi.fn(async () => new Map([['org-1', 'Personal']])),
canReadOrg: vi.fn(),
canWriteToOrg: vi.fn(),
canManageAgentAccess: vi.fn(),
isPersonalOrg: vi.fn(),
...overrides,
}
}
describe('Agent OAuth grant usecases', () => {
describe('OAuth grant usecases', () => {
it('lists grants through the gateway', async () => {
const agentOAuth = gateway({
const oauth = gateway({
listGrants: vi.fn(async () => [
{
id: 'grant-1',
@@ -46,7 +45,7 @@ describe('Agent OAuth grant usecases', () => {
]),
})
await expect(listAgentOAuthGrants({ agentOAuth, org: org() }, db, { userId: 'user-1' })).resolves.toEqual({
await expect(listOAuthGrants({ oauth, org: org() }, db, { userId: 'user-1' })).resolves.toEqual({
items: [
{
id: 'grant-1',
@@ -65,13 +64,11 @@ describe('Agent OAuth grant usecases', () => {
})
it('throws not found when revoke does not remove a grant', async () => {
const agentOAuth = gateway({ revokeGrant: vi.fn(async () => false) })
const oauth = gateway({ revokeGrant: vi.fn(async () => false) })
await expect(
revokeAgentOAuthGrant({ agentOAuth }, db, { userId: 'user-1', grantId: 'missing' }),
).rejects.toMatchObject({
await expect(revokeOAuthGrant({ oauth }, db, { userId: 'user-1', grantId: 'missing' })).rejects.toMatchObject({
httpStatus: 404,
message: 'Agent OAuth grant not found',
message: 'OAuth grant not found',
})
})
})
@@ -1,23 +1,23 @@
import {
type AgentOAuthGrant as AgentOAuthGrantDTO,
agentOAuthGrantDTO,
type OAuthGrant as OAuthGrantDTO,
type OAuthResourceScope,
oauthGrantDTO,
oauthResourceScopeSchema,
} from '@shared/schemas'
import type { Database } from '../platform/interface'
import type { Deps } from './deps'
import { notFound } from './ports'
export async function listAgentOAuthGrants(
deps: Pick<Deps, 'agentOAuth' | 'org'>,
export async function listOAuthGrants(
deps: Pick<Deps, 'oauth' | 'org'>,
db: Database,
input: { userId: string },
): Promise<{ items: AgentOAuthGrantDTO[] }> {
const items = await deps.agentOAuth.listGrants(db, input.userId)
): Promise<{ items: OAuthGrantDTO[] }> {
const items = await deps.oauth.listGrants(db, input.userId)
const orgNames = await deps.org.getOrgNames(items.map((item) => item.orgId))
return {
items: items.map((item) =>
agentOAuthGrantDTO({
oauthGrantDTO({
...item,
scopes: item.scopes.filter(isOAuthResourceScope),
workspaceName: orgNames.get(item.orgId) ?? null,
@@ -30,15 +30,15 @@ function isOAuthResourceScope(scope: string): scope is OAuthResourceScope {
return oauthResourceScopeSchema.safeParse(scope).success
}
export async function revokeAgentOAuthGrant(
deps: Pick<Deps, 'agentOAuth'>,
export async function revokeOAuthGrant(
deps: Pick<Deps, 'oauth'>,
db: Database,
input: { userId: string; grantId: string; now?: Date },
): Promise<void> {
const revoked = await deps.agentOAuth.revokeGrant(db, {
const revoked = await deps.oauth.revokeGrant(db, {
userId: input.userId,
grantId: input.grantId,
now: input.now ?? new Date(),
})
if (!revoked) throw notFound('Agent OAuth grant not found')
if (!revoked) throw notFound('OAuth grant not found')
}
+1 -1
View File
@@ -4,7 +4,6 @@
// resource owns its own file under ports/.
export * from './ports/admin-stats'
export * from './ports/agent-oauth'
export * from './ports/announcement'
export * from './ports/api-keys'
export * from './ports/app-error'
@@ -30,6 +29,7 @@ export * from './ports/licensing-cloud'
export * from './ports/matter'
export * from './ports/member-count'
export * from './ports/notification'
export * from './ports/oauth'
export * from './ports/object-upload-session'
export * from './ports/org'
export * from './ports/profile'
+1 -1
View File
@@ -3,7 +3,7 @@
export type AuditActorType =
| 'user'
| 'api_key'
| 'agent_oauth'
| 'oauth'
| 'agent'
| 'anonymous'
| 'system'
@@ -1,7 +1,7 @@
import type { AuthorizationScope } from '@shared/authorization'
import type { Database } from '../../platform/interface'
export interface AgentOAuthGrant {
export interface OAuthGrant {
id: string
clientId: string
clientName: string
@@ -12,7 +12,7 @@ export interface AgentOAuthGrant {
lastUsedAt: string | null
}
export interface AgentOAuthClient {
export interface OAuthClient {
clientId: string
clientName: string
disabled: boolean
@@ -32,11 +32,11 @@ export interface RegisteredOAuthApplication {
createdAt: string
}
export interface AgentOAuthGateway {
findClient(db: Database, clientId: string): Promise<AgentOAuthClient | null>
export interface OAuthGateway {
findClient(db: Database, clientId: string): Promise<OAuthClient | null>
listRegisteredApplications(db: Database): Promise<RegisteredOAuthApplication[]>
revokeJwtAccessToken(db: Database, token: string): Promise<void>
isJwtAccessTokenRevoked(db: Database, tokenId: string): Promise<boolean>
listGrants(db: Database, userId: string): Promise<AgentOAuthGrant[]>
listGrants(db: Database, userId: string): Promise<OAuthGrant[]>
revokeGrant(db: Database, input: { userId: string; grantId: string; now: Date }): Promise<boolean>
}
-1
View File
@@ -4,6 +4,5 @@ export interface OrgRepo {
getOrgNames(orgIds: string[]): Promise<Map<string, string>>
canReadOrg(userId: string, orgId: string): Promise<boolean>
canWriteToOrg(userId: string, orgId: string): Promise<boolean>
canManageAgentAccess(userId: string, orgId: string): Promise<boolean>
isPersonalOrg(orgId: string): Promise<boolean>
}
+3 -3
View File
@@ -2,7 +2,7 @@ import { FREE_SOCIAL_LOGIN_LIMIT } from '@shared/constants'
import type { BindingState } from '@shared/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Database } from '../../platform/interface'
import type { AgentOAuthGateway, LicenseBindingRepo, SystemOption, SystemOptionsRepo } from '../ports'
import type { LicenseBindingRepo, OAuthGateway, SystemOption, SystemOptionsRepo } from '../ports'
import {
type AuthProviderDeps,
deleteAuthProvider,
@@ -150,10 +150,10 @@ describe('auth-provider usecase', () => {
},
]
const listRegisteredApplications = vi.fn(async () => registeredApplications)
const agentOAuth = { listRegisteredApplications } as unknown as AgentOAuthGateway
const oauth = { listRegisteredApplications } as unknown as OAuthGateway
const db = {} as Database
const result = await listAuthProviderSettings({ ...deps, agentOAuth }, db, listOptions)
const result = await listAuthProviderSettings({ ...deps, oauth }, db, listOptions)
expect(result.items).toHaveLength(1)
expect(result.registeredApplications).toEqual(registeredApplications)
+3 -3
View File
@@ -22,12 +22,12 @@ import type { AuthProvider } from '@shared/types'
import { hasFeature } from '../../domain/licensing'
import type { Database } from '../../platform/interface'
import {
type AgentOAuthGateway,
type AppError,
badRequest,
type CacheService,
featureBlocked,
type LicenseBindingRepo,
type OAuthGateway,
type SystemOptionsRepo,
} from '../ports'
import { invalidateSiteConfig } from './config-cache'
@@ -108,13 +108,13 @@ export async function listAuthProviders(
}
export async function listAuthProviderSettings(
deps: Pick<AuthProviderDeps, 'systemOptions'> & { agentOAuth: AgentOAuthGateway },
deps: Pick<AuthProviderDeps, 'systemOptions'> & { oauth: OAuthGateway },
db: Database,
{ authOrigin }: { authOrigin: string },
) {
const [{ items }, registeredApplications] = await Promise.all([
listAuthProviders(deps, { authOrigin }),
deps.agentOAuth.listRegisteredApplications(db),
deps.oauth.listRegisteredApplications(db),
])
return { items, registeredApplications }
}
-1
View File
@@ -90,7 +90,6 @@ function makeDeps(
getOrgNames: async () => new Map(),
canReadOrg: async () => false,
canWriteToOrg: async () => false,
canManageAgentAccess: async () => false,
isPersonalOrg: async () => false,
...overrides.org,
},
+2 -2
View File
@@ -1,5 +1,4 @@
import { describe, expect, it } from 'vitest'
import { AGENT_OAUTH_RESOURCE_SCOPES } from './agent-oauth'
import { WEBDAV_API_KEY_PERMISSIONS } from './api-key-templates'
import {
AuthorizationScope,
@@ -7,6 +6,7 @@ import {
CANONICAL_AUTHORIZATION_SCOPES,
scopePermissions,
} from './authorization'
import { OAUTH_RESOURCE_SCOPES } from './oauth'
describe('authorization scope registry', () => {
it('uses lowercase resource:action scopes without wildcard semantics', () => {
@@ -22,7 +22,7 @@ describe('authorization scope registry', () => {
it('keeps permanent object purge out of agent-grantable scopes', () => {
expect(CANONICAL_AUTHORIZATION_SCOPES).toContain(AuthorizationScope.OBJECTS_PURGE)
expect(AGENT_OAUTH_RESOURCE_SCOPES).not.toContain(AuthorizationScope.OBJECTS_PURGE)
expect(OAUTH_RESOURCE_SCOPES).not.toContain(AuthorizationScope.OBJECTS_PURGE)
expect(scopePermissions([AuthorizationScope.OBJECTS_DELETE])).toEqual({ objects: ['delete'] })
})
+3 -3
View File
@@ -15,9 +15,9 @@ export const AuthorizationScope = {
DOWNLOAD_TASKS_CREATE: 'download-tasks:create',
DOWNLOAD_TASKS_CANCEL: 'download-tasks:cancel',
SITE_ANALYTICS_READ: 'site-analytics:read',
AGENT_OAUTH_GRANTS_READ: 'agent-oauth-grants:read',
AGENT_OAUTH_GRANTS_CREATE: 'agent-oauth-grants:create',
AGENT_OAUTH_GRANTS_DELETE: 'agent-oauth-grants:delete',
OAUTH_GRANTS_READ: 'oauth-grants:read',
OAUTH_GRANTS_CREATE: 'oauth-grants:create',
OAUTH_GRANTS_DELETE: 'oauth-grants:delete',
BACKGROUND_JOBS_READ: 'background-jobs:read',
BACKGROUND_JOBS_CREATE: 'background-jobs:create',
BACKGROUND_JOBS_UPDATE: 'background-jobs:update',
+7 -7
View File
@@ -1,14 +1,14 @@
import { AuthorizationScope } from './authorization'
export const AGENT_OAUTH_ACCESS_TOKEN_SECONDS = 15 * 60
export const AGENT_OAUTH_REFRESH_TOKEN_SECONDS = 30 * 24 * 60 * 60
export const AGENT_OAUTH_ACTOR_TOKEN_SECONDS = 5 * 60
export const OAUTH_ACCESS_TOKEN_SECONDS = 15 * 60
export const OAUTH_REFRESH_TOKEN_SECONDS = 30 * 24 * 60 * 60
export const OAUTH_ACTOR_TOKEN_SECONDS = 5 * 60
export const JWT_BEARER_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
export const TOKEN_EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'
export const OAUTH_ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'
export const AGENT_ACTOR_RESOURCE = 'urn:zpan:oauth:agent-actor'
export const AGENT_OAUTH_STANDARD_SCOPES = ['openid', 'profile', 'email', 'offline_access'] as const
export const AGENT_OAUTH_RESOURCE_SCOPES = [
export const OAUTH_STANDARD_SCOPES = ['openid', 'profile', 'email', 'offline_access'] as const
export const OAUTH_RESOURCE_SCOPES = [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.OBJECTS_CREATE,
AuthorizationScope.OBJECTS_UPDATE,
@@ -20,8 +20,8 @@ export const AGENT_OAUTH_RESOURCE_SCOPES = [
AuthorizationScope.QUOTA_PURCHASE,
AuthorizationScope.STORAGE_USAGE_READ,
] as const
export const AGENT_OAUTH_SCOPES = [...AGENT_OAUTH_STANDARD_SCOPES, ...AGENT_OAUTH_RESOURCE_SCOPES] as const
export const AGENT_OAUTH_SCOPE_DESCRIPTIONS: Record<(typeof AGENT_OAUTH_RESOURCE_SCOPES)[number], string> = {
export const OAUTH_SCOPES = [...OAUTH_STANDARD_SCOPES, ...OAUTH_RESOURCE_SCOPES] as const
export const OAUTH_SCOPE_DESCRIPTIONS: Record<(typeof OAUTH_RESOURCE_SCOPES)[number], string> = {
[AuthorizationScope.OBJECTS_READ]: 'List, inspect, and download objects',
[AuthorizationScope.OBJECTS_CREATE]: 'Create folders and upload objects',
[AuthorizationScope.OBJECTS_UPDATE]: 'Rename, move, and copy objects',
-64
View File
@@ -1,64 +0,0 @@
import { z } from 'zod'
import { AGENT_OAUTH_ACCESS_TOKEN_SECONDS, AGENT_OAUTH_REFRESH_TOKEN_SECONDS } from '../agent-oauth'
import { oauthResourceScopeSchema } from './oauth-resource'
export const agentOAuthGrantStatusSchema = z.enum(['active'])
export type AgentOAuthGrantStatus = z.infer<typeof agentOAuthGrantStatusSchema>
export const agentOAuthGrantSchema = z.object({
id: z.string(),
clientId: z.string(),
clientName: z.string(),
userId: z.string(),
orgId: z.string(),
workspaceName: z.string().nullable(),
scopes: z.array(oauthResourceScopeSchema),
createdAt: z.string(),
lastUsedAt: z.string().nullable(),
status: agentOAuthGrantStatusSchema,
})
export type AgentOAuthGrant = z.infer<typeof agentOAuthGrantSchema>
export const agentOAuthGrantListSchema = z.object({ items: z.array(agentOAuthGrantSchema) })
export type AgentOAuthGrantList = z.infer<typeof agentOAuthGrantListSchema>
export const agentOAuthConsentContextSchema = z.object({
clientId: z.string(),
clientName: z.string(),
instanceOrigin: z.string(),
workspace: z.object({
id: z.string(),
name: z.string().nullable(),
}),
scopes: z.array(oauthResourceScopeSchema),
standardScopes: z.array(z.string()),
redirectUri: z.string(),
grantLifetime: z.object({
accessTokenSeconds: z.number().int().default(AGENT_OAUTH_ACCESS_TOKEN_SECONDS),
refreshTokenSeconds: z.number().int().default(AGENT_OAUTH_REFRESH_TOKEN_SECONDS),
}),
})
export type AgentOAuthConsentContext = z.infer<typeof agentOAuthConsentContextSchema>
export const agentOAuthConsentContextRequestSchema = z.object({
oauthQuery: z.string().min(1),
})
export type AgentOAuthConsentContextRequest = z.infer<typeof agentOAuthConsentContextRequestSchema>
export const agentOAuthConsentSubmitSchema = z.object({
accept: z.boolean(),
oauthQuery: z.string().min(1),
})
export type AgentOAuthConsentSubmit = z.infer<typeof agentOAuthConsentSubmitSchema>
export const agentOAuthConsentResultSchema = z.object({
url: z.string(),
})
export type AgentOAuthConsentResult = z.infer<typeof agentOAuthConsentResultSchema>
export function agentOAuthGrantDTO(input: Omit<AgentOAuthGrant, 'status'>): AgentOAuthGrant {
return {
...input,
status: 'active',
}
}
+19 -19
View File
@@ -9,25 +9,6 @@ export {
adminAnalyticsTrafficSchema,
adminOverviewSchema,
} from './admin-analytics'
export type {
AgentOAuthConsentContext,
AgentOAuthConsentContextRequest,
AgentOAuthConsentResult,
AgentOAuthConsentSubmit,
AgentOAuthGrant,
AgentOAuthGrantList,
AgentOAuthGrantStatus,
} from './agent-oauth-grants'
export {
agentOAuthConsentContextRequestSchema,
agentOAuthConsentContextSchema,
agentOAuthConsentResultSchema,
agentOAuthConsentSubmitSchema,
agentOAuthGrantDTO,
agentOAuthGrantListSchema,
agentOAuthGrantSchema,
agentOAuthGrantStatusSchema,
} from './agent-oauth-grants'
export type {
AnnouncementInput,
AnnouncementStatus,
@@ -162,6 +143,25 @@ export {
} from './errors'
export type { ListNotificationsQuery } from './notification'
export { listNotificationsQuerySchema } from './notification'
export type {
OAuthConsentContext,
OAuthConsentContextRequest,
OAuthConsentResult,
OAuthConsentSubmit,
OAuthGrant,
OAuthGrantList,
OAuthGrantStatus,
} from './oauth-grants'
export {
oauthConsentContextRequestSchema,
oauthConsentContextSchema,
oauthConsentResultSchema,
oauthConsentSubmitSchema,
oauthGrantDTO,
oauthGrantListSchema,
oauthGrantSchema,
oauthGrantStatusSchema,
} from './oauth-grants'
export type { OAuthResourceScope } from './oauth-resource'
export { oauthResourceScopeLabels, oauthResourceScopeSchema } from './oauth-resource'
export type { CursorPage, CursorPageQuery, Page, PageQuery } from './pagination'
+64
View File
@@ -0,0 +1,64 @@
import { z } from 'zod'
import { OAUTH_ACCESS_TOKEN_SECONDS, OAUTH_REFRESH_TOKEN_SECONDS } from '../oauth'
import { oauthResourceScopeSchema } from './oauth-resource'
export const oauthGrantStatusSchema = z.enum(['active'])
export type OAuthGrantStatus = z.infer<typeof oauthGrantStatusSchema>
export const oauthGrantSchema = z.object({
id: z.string(),
clientId: z.string(),
clientName: z.string(),
userId: z.string(),
orgId: z.string(),
workspaceName: z.string().nullable(),
scopes: z.array(oauthResourceScopeSchema),
createdAt: z.string(),
lastUsedAt: z.string().nullable(),
status: oauthGrantStatusSchema,
})
export type OAuthGrant = z.infer<typeof oauthGrantSchema>
export const oauthGrantListSchema = z.object({ items: z.array(oauthGrantSchema) })
export type OAuthGrantList = z.infer<typeof oauthGrantListSchema>
export const oauthConsentContextSchema = z.object({
clientId: z.string(),
clientName: z.string(),
instanceOrigin: z.string(),
workspace: z.object({
id: z.string(),
name: z.string().nullable(),
}),
scopes: z.array(oauthResourceScopeSchema),
standardScopes: z.array(z.string()),
redirectUri: z.string(),
grantLifetime: z.object({
accessTokenSeconds: z.number().int().default(OAUTH_ACCESS_TOKEN_SECONDS),
refreshTokenSeconds: z.number().int().default(OAUTH_REFRESH_TOKEN_SECONDS),
}),
})
export type OAuthConsentContext = z.infer<typeof oauthConsentContextSchema>
export const oauthConsentContextRequestSchema = z.object({
oauthQuery: z.string().min(1),
})
export type OAuthConsentContextRequest = z.infer<typeof oauthConsentContextRequestSchema>
export const oauthConsentSubmitSchema = z.object({
accept: z.boolean(),
oauthQuery: z.string().min(1),
})
export type OAuthConsentSubmit = z.infer<typeof oauthConsentSubmitSchema>
export const oauthConsentResultSchema = z.object({
url: z.string(),
})
export type OAuthConsentResult = z.infer<typeof oauthConsentResultSchema>
export function oauthGrantDTO(input: Omit<OAuthGrant, 'status'>): OAuthGrant {
return {
...input,
status: 'active',
}
}
+12 -12
View File
@@ -1,19 +1,19 @@
import { z } from 'zod'
import { AGENT_OAUTH_RESOURCE_SCOPES } from '../agent-oauth'
import { AuthorizationScope } from '../authorization'
import { OAUTH_RESOURCE_SCOPES } from '../oauth'
export const oauthResourceScopeSchema = z.enum(AGENT_OAUTH_RESOURCE_SCOPES)
export const oauthResourceScopeSchema = z.enum(OAUTH_RESOURCE_SCOPES)
export type OAuthResourceScope = z.infer<typeof oauthResourceScopeSchema>
export const oauthResourceScopeLabels = {
[AuthorizationScope.OBJECTS_READ]: 'settings.agentAccess.scope.objectsRead',
[AuthorizationScope.OBJECTS_CREATE]: 'settings.agentAccess.scope.objectsCreate',
[AuthorizationScope.OBJECTS_UPDATE]: 'settings.agentAccess.scope.objectsUpdate',
[AuthorizationScope.OBJECTS_DELETE]: 'settings.agentAccess.scope.objectsDelete',
[AuthorizationScope.SHARES_READ]: 'settings.agentAccess.scope.sharesRead',
[AuthorizationScope.SHARES_CREATE]: 'settings.agentAccess.scope.sharesCreate',
[AuthorizationScope.SHARES_DELETE]: 'settings.agentAccess.scope.sharesDelete',
[AuthorizationScope.QUOTA_READ]: 'settings.agentAccess.scope.quotaRead',
[AuthorizationScope.QUOTA_PURCHASE]: 'settings.agentAccess.scope.quotaPurchase',
[AuthorizationScope.STORAGE_USAGE_READ]: 'settings.agentAccess.scope.storageUsageRead',
[AuthorizationScope.OBJECTS_READ]: 'settings.oauthApps.scope.objectsRead',
[AuthorizationScope.OBJECTS_CREATE]: 'settings.oauthApps.scope.objectsCreate',
[AuthorizationScope.OBJECTS_UPDATE]: 'settings.oauthApps.scope.objectsUpdate',
[AuthorizationScope.OBJECTS_DELETE]: 'settings.oauthApps.scope.objectsDelete',
[AuthorizationScope.SHARES_READ]: 'settings.oauthApps.scope.sharesRead',
[AuthorizationScope.SHARES_CREATE]: 'settings.oauthApps.scope.sharesCreate',
[AuthorizationScope.SHARES_DELETE]: 'settings.oauthApps.scope.sharesDelete',
[AuthorizationScope.QUOTA_READ]: 'settings.oauthApps.scope.quotaRead',
[AuthorizationScope.QUOTA_PURCHASE]: 'settings.oauthApps.scope.quotaPurchase',
[AuthorizationScope.STORAGE_USAGE_READ]: 'settings.oauthApps.scope.storageUsageRead',
} as const satisfies Record<OAuthResourceScope, string>
+1 -1
View File
@@ -625,7 +625,7 @@ export interface Announcement {
export type AuditActorType =
| 'user'
| 'api_key'
| 'agent_oauth'
| 'oauth'
| 'agent'
| 'anonymous'
| 'system'
+1 -1
View File
@@ -111,7 +111,7 @@ function AdminAuditActivityItem({ event }: { event: AdminAuditEvent }) {
}
function formatActor(event: AdminAuditEvent): string {
if (event.actorType === 'agent_oauth') {
if (event.actorType === 'oauth') {
const identity = event.actorRef ?? 'unknown'
return event.actorIssuer ? `Agent:${identity} · ${event.actorIssuer}` : `Agent:${identity}`
}
+39 -43
View File
@@ -1133,7 +1133,7 @@
"settings.tabProfile": "Profile",
"settings.tabPassword": "Password",
"settings.tabApiKeys": "API Keys",
"settings.tabAgentAccess": "Agent Access",
"settings.tabOAuthApps": "OAuth Apps",
"settings.tabWebDav": "WebDAV",
"settings.tabImageHosting": "Image Hosting",
"settings.profile.section": "Profile",
@@ -1207,48 +1207,44 @@
"settings.apiKeys.revokeSuccess": "API key revoked",
"settings.apiKeys.orgRequired": "Select a workspace before creating this API key.",
"settings.apiKeys.manage": "Manage API Keys",
"settings.agentAccess.section": "Agent Access",
"settings.agentAccess.description": "Review and revoke delegated OAuth access to your workspaces.",
"settings.agentAccess.workspaceLabel": "Workspace",
"settings.agentAccess.scope.objectsRead": "Files: read objects",
"settings.agentAccess.scope.objectsCreate": "Files: create objects",
"settings.agentAccess.scope.objectsUpdate": "Files: update objects",
"settings.agentAccess.scope.objectsDelete": "Files: delete objects",
"settings.agentAccess.scope.sharesRead": "Shares: read shares",
"settings.agentAccess.scope.sharesCreate": "Shares: create shares",
"settings.agentAccess.scope.sharesDelete": "Shares: revoke shares",
"settings.agentAccess.scope.quotaRead": "Quota: read workspace quota",
"settings.agentAccess.scope.quotaPurchase": "Quota: purchase workspace storage capacity",
"settings.agentAccess.scope.storageUsageRead": "Storage usage: read workspace usage",
"settings.agentAccess.colWorkspace": "Workspace",
"settings.agentAccess.colScopes": "Scopes",
"settings.agentAccess.colCreated": "Created",
"settings.agentAccess.colLastUsed": "Last Used",
"settings.agentAccess.colActions": "Actions",
"settings.agentAccess.never": "Never",
"settings.agentAccess.revoke": "Revoke",
"settings.agentAccess.oauthConsentEyebrow": "Delegated OAuth access",
"settings.agentAccess.oauthConsentTitle": "Authorize Application",
"settings.agentAccess.oauthConsentDescription": "Review the exact workspace and scopes this application will receive before continuing.",
"settings.agentAccess.oauthClient": "Client",
"settings.agentAccess.oauthOrigin": "ZPan instance",
"settings.agentAccess.oauthReturn": "Return URL",
"settings.agentAccess.oauthLifetime": "Grant lifetime",
"settings.agentAccess.oauthLifetimeValue": "{{days}} days",
"settings.agentAccess.oauthScopesTitle": "Requested scopes",
"settings.agentAccess.oauthEffects": "This grant can read or change files and public shares only where the listed scopes allow it. Delete and share scopes can remove content or expose public links.",
"settings.agentAccess.oauthApprove": "Approve Access",
"settings.agentAccess.oauthDeny": "Deny",
"settings.agentAccess.oauthExpiredTitle": "OAuth request expired",
"settings.agentAccess.oauthExpiredDescription": "Start the connection again to create a fresh authorization request.",
"settings.agentAccess.oauthConsentFailed": "Could not finish OAuth consent.",
"settings.agentAccess.oauthWorkspaceFailed": "Could not switch workspace.",
"settings.agentAccess.oauthGrantsSection": "Delegated OAuth Grants",
"settings.agentAccess.oauthGrantsDescription": "Manage application OAuth grants connected to your workspaces.",
"settings.agentAccess.oauthNoGrants": "No delegated OAuth grants yet",
"settings.agentAccess.oauthGrantsError": "Could not load delegated OAuth grants.",
"settings.agentAccess.oauthGrantRevokeTitle": "Revoke OAuth Grant",
"settings.agentAccess.oauthGrantRevokeConfirm": "Revoke {{client}} access to {{workspace}}? Active sessions for this workspace will stop immediately.",
"settings.oauthApps.workspaceLabel": "Workspace",
"settings.oauthApps.scope.objectsRead": "Files: read objects",
"settings.oauthApps.scope.objectsCreate": "Files: create objects",
"settings.oauthApps.scope.objectsUpdate": "Files: update objects",
"settings.oauthApps.scope.objectsDelete": "Files: delete objects",
"settings.oauthApps.scope.sharesRead": "Shares: read shares",
"settings.oauthApps.scope.sharesCreate": "Shares: create shares",
"settings.oauthApps.scope.sharesDelete": "Shares: revoke shares",
"settings.oauthApps.scope.quotaRead": "Quota: read workspace quota",
"settings.oauthApps.scope.quotaPurchase": "Quota: purchase workspace storage capacity",
"settings.oauthApps.scope.storageUsageRead": "Storage usage: read workspace usage",
"settings.oauthApps.colWorkspace": "Workspace",
"settings.oauthApps.colScopes": "Scopes",
"settings.oauthApps.colCreated": "Created",
"settings.oauthApps.colLastUsed": "Last Used",
"settings.oauthApps.colActions": "Actions",
"settings.oauthApps.never": "Never",
"settings.oauthApps.revoke": "Revoke",
"settings.oauthApps.oauthConsentEyebrow": "Delegated OAuth access",
"settings.oauthApps.oauthConsentTitle": "Authorize Application",
"settings.oauthApps.oauthConsentDescription": "Review the exact workspace and scopes this application will receive before continuing.",
"settings.oauthApps.oauthClient": "Client",
"settings.oauthApps.oauthOrigin": "ZPan instance",
"settings.oauthApps.oauthReturn": "Return URL",
"settings.oauthApps.oauthLifetime": "Grant lifetime",
"settings.oauthApps.oauthLifetimeValue": "{{days}} days",
"settings.oauthApps.oauthApprove": "Approve Access",
"settings.oauthApps.oauthDeny": "Deny",
"settings.oauthApps.oauthExpiredTitle": "OAuth request expired",
"settings.oauthApps.oauthExpiredDescription": "Start the connection again to create a fresh authorization request.",
"settings.oauthApps.oauthConsentFailed": "Could not finish OAuth consent.",
"settings.oauthApps.oauthWorkspaceFailed": "Could not switch workspace.",
"settings.oauthApps.oauthGrantsSection": "Authorized OAuth Apps",
"settings.oauthApps.oauthGrantsDescription": "Review and revoke apps authorized to access your workspaces.",
"settings.oauthApps.oauthNoGrants": "No authorized OAuth apps yet",
"settings.oauthApps.oauthGrantsError": "Could not load delegated OAuth grants.",
"settings.oauthApps.oauthGrantRevokeTitle": "Revoke OAuth Grant",
"settings.oauthApps.oauthGrantRevokeConfirm": "Revoke {{client}} access to {{workspace}}? Active sessions for this workspace will stop immediately.",
"settings.appearance.theme.description": "Choose how ZPan looks. Follows your system setting by default.",
"settings.appearance.language.description": "The display language for the app.",
"settings.appearance.autoSaved": "Changes apply immediately.",
+39 -43
View File
@@ -1133,7 +1133,7 @@
"settings.tabProfile": "基本信息",
"settings.tabPassword": "密码",
"settings.tabApiKeys": "API Key",
"settings.tabAgentAccess": "Agent Access",
"settings.tabOAuthApps": "OAuth 应用",
"settings.tabWebDav": "WebDAV",
"settings.tabImageHosting": "图床",
"settings.profile.section": "个人资料",
@@ -1207,48 +1207,44 @@
"settings.apiKeys.revokeSuccess": "API Key 已撤销",
"settings.apiKeys.orgRequired": "创建该 API Key 前请先选择工作区。",
"settings.apiKeys.manage": "管理 API Key",
"settings.agentAccess.section": "Agent Access",
"settings.agentAccess.description": "查看并撤销应用对工作空间的 OAuth 委托访问。",
"settings.agentAccess.workspaceLabel": "工作空间",
"settings.agentAccess.scope.objectsRead": "文件:读取对象",
"settings.agentAccess.scope.objectsCreate": "文件:创建对象",
"settings.agentAccess.scope.objectsUpdate": "文件:更新对象",
"settings.agentAccess.scope.objectsDelete": "文件:删除对象",
"settings.agentAccess.scope.sharesRead": "分享:读取分享",
"settings.agentAccess.scope.sharesCreate": "分享:创建分享",
"settings.agentAccess.scope.sharesDelete": "分享:撤销分享",
"settings.agentAccess.scope.quotaRead": "配额:读取工作空间配额",
"settings.agentAccess.scope.quotaPurchase": "配额:购买工作空间存储容量",
"settings.agentAccess.scope.storageUsageRead": "存储用量:读取工作空间用量",
"settings.agentAccess.colWorkspace": "工作空间",
"settings.agentAccess.colScopes": "权限",
"settings.agentAccess.colCreated": "创建时间",
"settings.agentAccess.colLastUsed": "最近使用",
"settings.agentAccess.colActions": "操作",
"settings.agentAccess.never": "从未",
"settings.agentAccess.revoke": "撤销",
"settings.agentAccess.oauthConsentEyebrow": "委托 OAuth 访问",
"settings.agentAccess.oauthConsentTitle": "授权应用",
"settings.agentAccess.oauthConsentDescription": "继续前请确认该应用将获得的具体工作空间和权限。",
"settings.agentAccess.oauthClient": "客户端",
"settings.agentAccess.oauthOrigin": "ZPan 实例",
"settings.agentAccess.oauthReturn": "返回 URL",
"settings.agentAccess.oauthLifetime": "授权有效期",
"settings.agentAccess.oauthLifetimeValue": "{{days}} 天",
"settings.agentAccess.oauthScopesTitle": "请求权限",
"settings.agentAccess.oauthEffects": "该授权只能按列出的权限读取或更改文件与公开分享。删除和分享权限可能移除内容或公开链接。",
"settings.agentAccess.oauthApprove": "批准访问",
"settings.agentAccess.oauthDeny": "拒绝",
"settings.agentAccess.oauthExpiredTitle": "OAuth 请求已过期",
"settings.agentAccess.oauthExpiredDescription": "请重新发起连接,生成新的授权请求。",
"settings.agentAccess.oauthConsentFailed": "无法完成 OAuth 授权。",
"settings.agentAccess.oauthWorkspaceFailed": "无法切换工作空间。",
"settings.agentAccess.oauthGrantsSection": "委托 OAuth 授权",
"settings.agentAccess.oauthGrantsDescription": "管理连接到你工作空间的应用 OAuth 授权。",
"settings.agentAccess.oauthNoGrants": "暂无委托 OAuth 授权",
"settings.agentAccess.oauthGrantsError": "无法加载委托 OAuth 授权。",
"settings.agentAccess.oauthGrantRevokeTitle": "撤销 OAuth 授权",
"settings.agentAccess.oauthGrantRevokeConfirm": "撤销 {{client}} 对 {{workspace}} 的访问?该工作空间的活动会话将立即停止。",
"settings.oauthApps.workspaceLabel": "工作空间",
"settings.oauthApps.scope.objectsRead": "文件:读取对象",
"settings.oauthApps.scope.objectsCreate": "文件:创建对象",
"settings.oauthApps.scope.objectsUpdate": "文件:更新对象",
"settings.oauthApps.scope.objectsDelete": "文件:删除对象",
"settings.oauthApps.scope.sharesRead": "分享:读取分享",
"settings.oauthApps.scope.sharesCreate": "分享:创建分享",
"settings.oauthApps.scope.sharesDelete": "分享:撤销分享",
"settings.oauthApps.scope.quotaRead": "配额:读取工作空间配额",
"settings.oauthApps.scope.quotaPurchase": "配额:购买工作空间存储容量",
"settings.oauthApps.scope.storageUsageRead": "存储用量:读取工作空间用量",
"settings.oauthApps.colWorkspace": "工作空间",
"settings.oauthApps.colScopes": "权限",
"settings.oauthApps.colCreated": "创建时间",
"settings.oauthApps.colLastUsed": "最近使用",
"settings.oauthApps.colActions": "操作",
"settings.oauthApps.never": "从未",
"settings.oauthApps.revoke": "撤销",
"settings.oauthApps.oauthConsentEyebrow": "委托 OAuth 访问",
"settings.oauthApps.oauthConsentTitle": "授权应用",
"settings.oauthApps.oauthConsentDescription": "继续前请确认该应用将获得的具体工作空间和权限。",
"settings.oauthApps.oauthClient": "客户端",
"settings.oauthApps.oauthOrigin": "ZPan 实例",
"settings.oauthApps.oauthReturn": "返回 URL",
"settings.oauthApps.oauthLifetime": "授权有效期",
"settings.oauthApps.oauthLifetimeValue": "{{days}} 天",
"settings.oauthApps.oauthApprove": "批准访问",
"settings.oauthApps.oauthDeny": "拒绝",
"settings.oauthApps.oauthExpiredTitle": "OAuth 请求已过期",
"settings.oauthApps.oauthExpiredDescription": "请重新发起连接,生成新的授权请求。",
"settings.oauthApps.oauthConsentFailed": "无法完成 OAuth 授权。",
"settings.oauthApps.oauthWorkspaceFailed": "无法切换工作空间。",
"settings.oauthApps.oauthGrantsSection": "已授权的 OAuth 应用",
"settings.oauthApps.oauthGrantsDescription": "查看并撤销已获准访问你工作空间的应用。",
"settings.oauthApps.oauthNoGrants": "暂无已授权的 OAuth 应用",
"settings.oauthApps.oauthGrantsError": "无法加载委托 OAuth 授权。",
"settings.oauthApps.oauthGrantRevokeTitle": "撤销 OAuth 授权",
"settings.oauthApps.oauthGrantRevokeConfirm": "撤销 {{client}} 对 {{workspace}} 的访问?该工作空间的活动会话将立即停止。",
"settings.appearance.theme.description": "选择 ZPan 的外观,默认跟随系统。",
"settings.appearance.language.description": "界面显示语言。",
"settings.appearance.autoSaved": "修改即时生效。",
+16 -16
View File
@@ -47,7 +47,6 @@ import {
getAdminDashboardStorageStats,
getAdminDashboardTrafficStats,
getAdminOverview,
getAgentOAuthConsentContext,
getAnnouncement,
getBackgroundJob,
getChangelog,
@@ -59,6 +58,7 @@ import {
getInstanceInfo,
getLicenseEntitlements,
getLicensingStatus,
getOAuthConsentContext,
getObject,
getProfile,
getSession,
@@ -80,7 +80,6 @@ import {
listActiveAnnouncements,
listAdminAnnouncements,
listAdminAuditLogs,
listAgentOAuthGrants,
listAnnouncements,
listApiKeys,
listAuthProviders,
@@ -96,6 +95,7 @@ import {
listIhostImages,
listInviteCodes,
listNotifications,
listOAuthGrants,
listObjectsByPath,
listOrgEntitlements,
listQuotas,
@@ -122,8 +122,8 @@ import {
resetBrandingField,
restoreObject,
retryBackgroundJob,
revokeAgentOAuthGrant,
revokeIhostApiKey,
revokeOAuthGrant,
revokeOrgEntitlement,
revokeRemoteDownloadApiKey,
revokeShare,
@@ -138,7 +138,7 @@ import {
sendDownloaderHeartbeat,
serverEventsUrl,
setSharePrivacy,
submitAgentOAuthConsent,
submitOAuthConsent,
testEmail,
testImageDomainProvider,
transferObject,
@@ -3118,7 +3118,7 @@ describe('api', () => {
})
})
describe('Agent OAuth consent and grants', () => {
describe('OAuth consent and grants', () => {
const sampleGrantList = {
items: [
{
@@ -3149,11 +3149,11 @@ describe('api', () => {
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await getAgentOAuthConsentContext('client_id=dynamic-client&scope=objects%3Aread')
const result = await getOAuthConsentContext('client_id=dynamic-client&scope=objects%3Aread')
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/agent-oauth-consent')
expect(url).toContain('/api/oauth-consent')
expect(url).toContain('oauthQuery=client_id%3Ddynamic-client%26scope%3Dobjects%253Aread')
expect(init.method).toBe('GET')
})
@@ -3161,11 +3161,11 @@ describe('api', () => {
it('submits full OAuth consent through the Hono RPC wrapper without sending scope overrides', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ url: 'http://127.0.0.1:8484/callback?code=abc' }))
const result = await submitAgentOAuthConsent({ accept: true, oauthQuery: 'client_id=dynamic-client' })
const result = await submitOAuthConsent({ accept: true, oauthQuery: 'client_id=dynamic-client' })
expect(result).toEqual({ url: 'http://127.0.0.1:8484/callback?code=abc' })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/agent-oauth-consent')
expect(url).toBe('/api/oauth-consent')
expect(init.method).toBe('POST')
expect(init.credentials).toBe('include')
expect(JSON.parse(init.body as string)).toEqual({
@@ -3184,29 +3184,29 @@ describe('api', () => {
},
} as unknown as Response)
await expect(submitAgentOAuthConsent({ accept: false, oauthQuery: 'client_id=dynamic-client' })).rejects.toThrow(
await expect(submitOAuthConsent({ accept: false, oauthQuery: 'client_id=dynamic-client' })).rejects.toThrow(
ApiError,
)
})
it('lists delegated Agent OAuth grants', async () => {
it('lists delegated OAuth grants', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(sampleGrantList))
const result = await listAgentOAuthGrants()
const result = await listOAuthGrants()
expect(result).toEqual(sampleGrantList)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/agent-oauth-grants')
expect(url).toContain('/api/oauth-grants')
expect(init.method).toBe('GET')
})
it('revokes delegated Agent OAuth grants with DELETE', async () => {
it('revokes delegated OAuth grants with DELETE', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await revokeAgentOAuthGrant('grant-1')
await revokeOAuthGrant('grant-1')
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/agent-oauth-grants/grant-1')
expect(url).toContain('/api/oauth-grants/grant-1')
expect(init.method).toBe('DELETE')
})
})
+15 -15
View File
@@ -1,11 +1,6 @@
import { type ApiKeyMetadata, ApiKeyTemplate } from '@shared/api-key-templates'
import type { OAuthProviderConfig } from '@shared/oauth-providers'
import type {
AgentOAuthConsentContext,
AgentOAuthConsentResult,
AgentOAuthConsentSubmit,
AgentOAuthGrant,
AgentOAuthGrantList,
AllowedImageMime,
AnnouncementInput,
CloudCreditBalanceResponse,
@@ -21,6 +16,11 @@ import type {
DownloadTaskActionInput,
EmailSettings,
ImageDomainProviderResponse,
OAuthConsentContext,
OAuthConsentResult,
OAuthConsentSubmit,
OAuthGrant,
OAuthGrantList,
PatchStorageInput,
PresignObjectUploadPartsInput,
PublicProfile,
@@ -102,7 +102,6 @@ import {
adminQuotas,
adminSiteInvitations,
adminTeams,
agentOAuthGrantsApi,
announcementsApi,
authedSharesApi,
authProviders,
@@ -121,6 +120,7 @@ import {
licensingAdminApi,
licensingApi,
notificationsApi,
oauthGrantsApi,
objects,
publicSharesApi,
publicSiteInvitations,
@@ -1090,22 +1090,22 @@ export function deleteIhostConfig() {
})
}
export type { AgentOAuthConsentContext, AgentOAuthConsentResult, AgentOAuthGrant, AgentOAuthGrantList }
export type { OAuthConsentContext, OAuthConsentResult, OAuthGrant, OAuthGrantList }
export function getAgentOAuthConsentContext(oauthQuery: string) {
return unwrap<AgentOAuthConsentContext>(agentOAuthGrantsApi['agent-oauth-consent'].$get({ query: { oauthQuery } }))
export function getOAuthConsentContext(oauthQuery: string) {
return unwrap<OAuthConsentContext>(oauthGrantsApi['oauth-consent'].$get({ query: { oauthQuery } }))
}
export function submitAgentOAuthConsent(input: AgentOAuthConsentSubmit) {
return unwrap<AgentOAuthConsentResult>(agentOAuthGrantsApi['agent-oauth-consent'].$post({ json: input }))
export function submitOAuthConsent(input: OAuthConsentSubmit) {
return unwrap<OAuthConsentResult>(oauthGrantsApi['oauth-consent'].$post({ json: input }))
}
export function listAgentOAuthGrants() {
return unwrap<AgentOAuthGrantList>(agentOAuthGrantsApi['agent-oauth-grants'].$get())
export function listOAuthGrants() {
return unwrap<OAuthGrantList>(oauthGrantsApi['oauth-grants'].$get())
}
export function revokeAgentOAuthGrant(grantId: string) {
return discard(agentOAuthGrantsApi['agent-oauth-grants'][':grantId'].$delete({ param: { grantId } }))
export function revokeOAuthGrant(grantId: string) {
return discard(oauthGrantsApi['oauth-grants'][':grantId'].$delete({ param: { grantId } }))
}
// Image Host API Keys (via better-auth apiKey plugin)
+2 -2
View File
@@ -6,7 +6,6 @@ import type {
AdminSiteInvitationsRoute,
AdminStatsRoute,
AdminTeamsRoute,
AgentOAuthGrantsRoute,
AnnouncementsRoute,
AuthedSharesRoute,
AuthProvidersRoute,
@@ -25,6 +24,7 @@ import type {
LicensingAdminRoute,
LicensingRoute,
NotificationsRoute,
OAuthGrantsRoute,
ObjectsRoute,
PublicSharesRoute,
PublicSiteInvitationsRoute,
@@ -50,7 +50,7 @@ export const objects = hc<ObjectsRoute>('/api/objects', opts)
export const downloadTasksApi = hc<DownloadTasksRoute>('/api/downloads/tasks', opts)
export const downloaderSelfApi = hc<DownloaderSelfRoute>('/api/downloads/downloaders', opts)
export const trash = hc<TrashRoute>('/api/trash', opts)
export const agentOAuthGrantsApi = hc<AgentOAuthGrantsRoute>('/api', opts)
export const oauthGrantsApi = hc<OAuthGrantsRoute>('/api', opts)
export const storages = hc<StoragesRoute>('/api/site/storages', opts)
export const storageUsageApi = hc<StorageUsageRoute>('/api/storage', opts)
export const adminDownloadersApi = hc<DownloadersRoute>('/api/downloads/downloaders', opts)
+22 -23
View File
@@ -38,8 +38,8 @@ import { Route as AuthenticatedTeamsInviteRouteImport } from './routes/_authenti
import { Route as AuthenticatedSettingsWebdavRouteImport } from './routes/_authenticated/settings/webdav'
import { Route as AuthenticatedSettingsProfileRouteImport } from './routes/_authenticated/settings/profile'
import { Route as AuthenticatedSettingsPasswordRouteImport } from './routes/_authenticated/settings/password'
import { Route as AuthenticatedSettingsOauthAppsRouteImport } from './routes/_authenticated/settings/oauth-apps'
import { Route as AuthenticatedSettingsApiKeysRouteImport } from './routes/_authenticated/settings/api-keys'
import { Route as AuthenticatedSettingsAgentAccessRouteImport } from './routes/_authenticated/settings/agent-access'
import { Route as AuthenticatedAdminLicensingRouteImport } from './routes/_authenticated/admin/licensing'
import { Route as AuthenticatedAdminDownloadersRouteImport } from './routes/_authenticated/admin/downloaders'
import { Route as AuthenticatedAdminDashboardRouteImport } from './routes/_authenticated/admin/dashboard'
@@ -217,18 +217,18 @@ const AuthenticatedSettingsPasswordRoute =
path: '/password',
getParentRoute: () => AuthenticatedSettingsRouteRoute,
} as any)
const AuthenticatedSettingsOauthAppsRoute =
AuthenticatedSettingsOauthAppsRouteImport.update({
id: '/oauth-apps',
path: '/oauth-apps',
getParentRoute: () => AuthenticatedSettingsRouteRoute,
} as any)
const AuthenticatedSettingsApiKeysRoute =
AuthenticatedSettingsApiKeysRouteImport.update({
id: '/api-keys',
path: '/api-keys',
getParentRoute: () => AuthenticatedSettingsRouteRoute,
} as any)
const AuthenticatedSettingsAgentAccessRoute =
AuthenticatedSettingsAgentAccessRouteImport.update({
id: '/agent-access',
path: '/agent-access',
getParentRoute: () => AuthenticatedSettingsRouteRoute,
} as any)
const AuthenticatedAdminLicensingRoute =
AuthenticatedAdminLicensingRouteImport.update({
id: '/licensing',
@@ -383,8 +383,8 @@ export interface FileRoutesByFullPath {
'/admin/dashboard': typeof AuthenticatedAdminDashboardRoute
'/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute
'/admin/licensing': typeof AuthenticatedAdminLicensingRoute
'/settings/agent-access': typeof AuthenticatedSettingsAgentAccessRoute
'/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
'/settings/oauth-apps': typeof AuthenticatedSettingsOauthAppsRoute
'/settings/password': typeof AuthenticatedSettingsPasswordRoute
'/settings/profile': typeof AuthenticatedSettingsProfileRoute
'/settings/webdav': typeof AuthenticatedSettingsWebdavRoute
@@ -434,8 +434,8 @@ export interface FileRoutesByTo {
'/admin/dashboard': typeof AuthenticatedAdminDashboardRoute
'/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute
'/admin/licensing': typeof AuthenticatedAdminLicensingRoute
'/settings/agent-access': typeof AuthenticatedSettingsAgentAccessRoute
'/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
'/settings/oauth-apps': typeof AuthenticatedSettingsOauthAppsRoute
'/settings/password': typeof AuthenticatedSettingsPasswordRoute
'/settings/profile': typeof AuthenticatedSettingsProfileRoute
'/settings/webdav': typeof AuthenticatedSettingsWebdavRoute
@@ -490,8 +490,8 @@ export interface FileRoutesById {
'/_authenticated/admin/dashboard': typeof AuthenticatedAdminDashboardRoute
'/_authenticated/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute
'/_authenticated/admin/licensing': typeof AuthenticatedAdminLicensingRoute
'/_authenticated/settings/agent-access': typeof AuthenticatedSettingsAgentAccessRoute
'/_authenticated/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
'/_authenticated/settings/oauth-apps': typeof AuthenticatedSettingsOauthAppsRoute
'/_authenticated/settings/password': typeof AuthenticatedSettingsPasswordRoute
'/_authenticated/settings/profile': typeof AuthenticatedSettingsProfileRoute
'/_authenticated/settings/webdav': typeof AuthenticatedSettingsWebdavRoute
@@ -546,8 +546,8 @@ export interface FileRouteTypes {
| '/admin/dashboard'
| '/admin/downloaders'
| '/admin/licensing'
| '/settings/agent-access'
| '/settings/api-keys'
| '/settings/oauth-apps'
| '/settings/password'
| '/settings/profile'
| '/settings/webdav'
@@ -597,8 +597,8 @@ export interface FileRouteTypes {
| '/admin/dashboard'
| '/admin/downloaders'
| '/admin/licensing'
| '/settings/agent-access'
| '/settings/api-keys'
| '/settings/oauth-apps'
| '/settings/password'
| '/settings/profile'
| '/settings/webdav'
@@ -652,8 +652,8 @@ export interface FileRouteTypes {
| '/_authenticated/admin/dashboard'
| '/_authenticated/admin/downloaders'
| '/_authenticated/admin/licensing'
| '/_authenticated/settings/agent-access'
| '/_authenticated/settings/api-keys'
| '/_authenticated/settings/oauth-apps'
| '/_authenticated/settings/password'
| '/_authenticated/settings/profile'
| '/_authenticated/settings/webdav'
@@ -901,6 +901,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSettingsPasswordRouteImport
parentRoute: typeof AuthenticatedSettingsRouteRoute
}
'/_authenticated/settings/oauth-apps': {
id: '/_authenticated/settings/oauth-apps'
path: '/oauth-apps'
fullPath: '/settings/oauth-apps'
preLoaderRoute: typeof AuthenticatedSettingsOauthAppsRouteImport
parentRoute: typeof AuthenticatedSettingsRouteRoute
}
'/_authenticated/settings/api-keys': {
id: '/_authenticated/settings/api-keys'
path: '/api-keys'
@@ -908,13 +915,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSettingsApiKeysRouteImport
parentRoute: typeof AuthenticatedSettingsRouteRoute
}
'/_authenticated/settings/agent-access': {
id: '/_authenticated/settings/agent-access'
path: '/agent-access'
fullPath: '/settings/agent-access'
preLoaderRoute: typeof AuthenticatedSettingsAgentAccessRouteImport
parentRoute: typeof AuthenticatedSettingsRouteRoute
}
'/_authenticated/admin/licensing': {
id: '/_authenticated/admin/licensing'
path: '/licensing'
@@ -1117,8 +1117,8 @@ const AuthenticatedAdminRouteRouteWithChildren =
)
interface AuthenticatedSettingsRouteRouteChildren {
AuthenticatedSettingsAgentAccessRoute: typeof AuthenticatedSettingsAgentAccessRoute
AuthenticatedSettingsApiKeysRoute: typeof AuthenticatedSettingsApiKeysRoute
AuthenticatedSettingsOauthAppsRoute: typeof AuthenticatedSettingsOauthAppsRoute
AuthenticatedSettingsPasswordRoute: typeof AuthenticatedSettingsPasswordRoute
AuthenticatedSettingsProfileRoute: typeof AuthenticatedSettingsProfileRoute
AuthenticatedSettingsWebdavRoute: typeof AuthenticatedSettingsWebdavRoute
@@ -1127,9 +1127,8 @@ interface AuthenticatedSettingsRouteRouteChildren {
const AuthenticatedSettingsRouteRouteChildren: AuthenticatedSettingsRouteRouteChildren =
{
AuthenticatedSettingsAgentAccessRoute:
AuthenticatedSettingsAgentAccessRoute,
AuthenticatedSettingsApiKeysRoute: AuthenticatedSettingsApiKeysRoute,
AuthenticatedSettingsOauthAppsRoute: AuthenticatedSettingsOauthAppsRoute,
AuthenticatedSettingsPasswordRoute: AuthenticatedSettingsPasswordRoute,
AuthenticatedSettingsProfileRoute: AuthenticatedSettingsProfileRoute,
AuthenticatedSettingsWebdavRoute: AuthenticatedSettingsWebdavRoute,
@@ -131,7 +131,7 @@ describe('AuditLogsPage filters and pagination', () => {
vi.mocked(listAdminAuditLogs).mockResolvedValue(
auditPage(1, [
auditEvent({
actorType: 'agent_oauth',
actorType: 'oauth',
actorRef: 'agt_agent-1',
actorIssuer: 'https://id.realmroot.dev/api/auth',
}),
+1 -1
View File
@@ -93,7 +93,7 @@ function AuditRow({ event }: { event: AdminAuditEvent }) {
}
function formatActor(event: AdminAuditEvent): string {
if (event.actorType === 'agent_oauth') {
if (event.actorType === 'oauth') {
const identity = event.actorRef ?? 'unknown'
return event.actorIssuer ? `Agent:${identity} · ${event.actorIssuer}` : `Agent:${identity}`
}
@@ -20,17 +20,17 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
type AgentOAuthGrant,
getAgentOAuthConsentContext,
listAgentOAuthGrants,
revokeAgentOAuthGrant,
submitAgentOAuthConsent,
getOAuthConsentContext,
listOAuthGrants,
type OAuthGrant,
revokeOAuthGrant,
submitOAuthConsent,
} from '@/lib/api'
import { setActive, useListOrganizations } from '@/lib/auth-client'
import { redirectExternal } from '@/lib/browser-navigation'
export const Route = createFileRoute('/_authenticated/settings/agent-access')({
component: OAuthAccessSettingsPage,
export const Route = createFileRoute('/_authenticated/settings/oauth-apps')({
component: OAuthAppsSettingsPage,
})
interface Organization {
@@ -56,15 +56,15 @@ function OAuthConsentPanel({ oauthQuery, organizations }: { oauthQuery: string;
const [submitError, setSubmitError] = useState<string | null>(null)
const consentQuery = useQuery({
queryKey: ['oauth-consent', oauthQuery],
queryFn: () => getAgentOAuthConsentContext(oauthQuery),
queryFn: () => getOAuthConsentContext(oauthQuery),
enabled: !!oauthQuery,
retry: false,
})
const submitMutation = useMutation({
mutationFn: (accept: boolean) => submitAgentOAuthConsent({ accept, oauthQuery }),
mutationFn: (accept: boolean) => submitOAuthConsent({ accept, oauthQuery }),
onSuccess: (result) => redirectExternal(result.url),
onError: (error) =>
setSubmitError(error instanceof Error ? error.message : t('settings.agentAccess.oauthConsentFailed')),
setSubmitError(error instanceof Error ? error.message : t('settings.oauthApps.oauthConsentFailed')),
})
async function changeWorkspace(nextOrgId: string) {
@@ -75,7 +75,7 @@ function OAuthConsentPanel({ oauthQuery, organizations }: { oauthQuery: string;
if (error) throw error
await queryClient.invalidateQueries({ queryKey: ['oauth-consent', oauthQuery] })
} catch (error) {
toast.error(error instanceof Error ? error.message : t('settings.agentAccess.oauthWorkspaceFailed'))
toast.error(error instanceof Error ? error.message : t('settings.oauthApps.oauthWorkspaceFailed'))
} finally {
setSwitchingOrgId(null)
}
@@ -89,8 +89,8 @@ function OAuthConsentPanel({ oauthQuery, organizations }: { oauthQuery: string;
<div className="flex max-w-3xl items-start gap-3 rounded-md border bg-background p-6">
<ShieldAlert className="mt-0.5 size-5 text-destructive" />
<div className="space-y-2">
<h1 className="text-xl font-semibold">{t('settings.agentAccess.oauthExpiredTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('settings.agentAccess.oauthExpiredDescription')}</p>
<h1 className="text-xl font-semibold">{t('settings.oauthApps.oauthExpiredTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('settings.oauthApps.oauthExpiredDescription')}</p>
</div>
</div>
)
@@ -103,31 +103,31 @@ function OAuthConsentPanel({ oauthQuery, organizations }: { oauthQuery: string;
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Plug className="size-4" />
{t('settings.agentAccess.oauthConsentEyebrow')}
{t('settings.oauthApps.oauthConsentEyebrow')}
</div>
<h1 className="text-2xl font-semibold">{t('settings.agentAccess.oauthConsentTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('settings.agentAccess.oauthConsentDescription')}</p>
<h1 className="text-2xl font-semibold">{t('settings.oauthApps.oauthConsentTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('settings.oauthApps.oauthConsentDescription')}</p>
</div>
<dl className="mt-6 grid gap-4 rounded-md border bg-muted/30 p-4 sm:grid-cols-2">
<div>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.agentAccess.oauthClient')}</dt>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.oauthApps.oauthClient')}</dt>
<dd className="mt-1 font-medium">{context.clientName}</dd>
</div>
<div>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.agentAccess.oauthOrigin')}</dt>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.oauthApps.oauthOrigin')}</dt>
<dd className="mt-1 break-all font-medium">{context.instanceOrigin}</dd>
</div>
<div>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.agentAccess.oauthReturn')}</dt>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.oauthApps.oauthReturn')}</dt>
<dd className="mt-1 break-all font-medium">{context.redirectUri}</dd>
</div>
<div>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.agentAccess.oauthLifetime')}</dt>
<dd className="mt-1 font-medium">{t('settings.agentAccess.oauthLifetimeValue', { days: lifetimeDays })}</dd>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.oauthApps.oauthLifetime')}</dt>
<dd className="mt-1 font-medium">{t('settings.oauthApps.oauthLifetimeValue', { days: lifetimeDays })}</dd>
</div>
</dl>
<div className="mt-6 space-y-2">
<Label htmlFor="oauth-workspace">{t('settings.agentAccess.workspaceLabel')}</Label>
<Label htmlFor="oauth-workspace">{t('settings.oauthApps.workspaceLabel')}</Label>
<Select value={context.workspace.id} onValueChange={changeWorkspace} disabled={!!switchingOrgId}>
<SelectTrigger id="oauth-workspace">
<SelectValue />
@@ -161,7 +161,7 @@ function OAuthConsentPanel({ oauthQuery, organizations }: { oauthQuery: string;
onClick={() => submitMutation.mutate(false)}
>
<X className="size-4" />
{t('settings.agentAccess.oauthDeny')}
{t('settings.oauthApps.oauthDeny')}
</Button>
<Button
type="button"
@@ -169,19 +169,19 @@ function OAuthConsentPanel({ oauthQuery, organizations }: { oauthQuery: string;
onClick={() => submitMutation.mutate(true)}
>
<Check className="size-4" />
{submitMutation.isPending ? t('common.loading') : t('settings.agentAccess.oauthApprove')}
{submitMutation.isPending ? t('common.loading') : t('settings.oauthApps.oauthApprove')}
</Button>
</div>
</div>
)
}
function RevokeGrantDialog({ grant, onClose }: { grant: AgentOAuthGrant | null; onClose: () => void }) {
function RevokeGrantDialog({ grant, onClose }: { grant: OAuthGrant | null; onClose: () => void }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: async () => {
if (grant) await revokeAgentOAuthGrant(grant.id)
if (grant) await revokeOAuthGrant(grant.id)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['oauth-grants'] })
@@ -194,9 +194,9 @@ function RevokeGrantDialog({ grant, onClose }: { grant: AgentOAuthGrant | null;
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('settings.agentAccess.oauthGrantRevokeTitle')}</DialogTitle>
<DialogTitle>{t('settings.oauthApps.oauthGrantRevokeTitle')}</DialogTitle>
<DialogDescription>
{t('settings.agentAccess.oauthGrantRevokeConfirm', {
{t('settings.oauthApps.oauthGrantRevokeConfirm', {
client: grant.clientName,
workspace: grant.workspaceName ?? grant.orgId,
})}
@@ -208,7 +208,7 @@ function RevokeGrantDialog({ grant, onClose }: { grant: AgentOAuthGrant | null;
</Button>
<Button type="button" variant="destructive" disabled={mutation.isPending} onClick={() => mutation.mutate()}>
<Trash2 className="size-4" />
{mutation.isPending ? t('common.loading') : t('settings.agentAccess.revoke')}
{mutation.isPending ? t('common.loading') : t('settings.oauthApps.revoke')}
</Button>
</DialogFooter>
</DialogContent>
@@ -218,32 +218,32 @@ function RevokeGrantDialog({ grant, onClose }: { grant: AgentOAuthGrant | null;
function OAuthGrants() {
const { t } = useTranslation()
const [revoking, setRevoking] = useState<AgentOAuthGrant | null>(null)
const query = useQuery({ queryKey: ['oauth-grants'], queryFn: listAgentOAuthGrants })
const [revoking, setRevoking] = useState<OAuthGrant | null>(null)
const query = useQuery({ queryKey: ['oauth-grants'], queryFn: listOAuthGrants })
const grants = query.data?.items ?? []
return (
<Card>
<CardHeader>
<CardTitle>{t('settings.agentAccess.oauthGrantsSection')}</CardTitle>
<CardDescription>{t('settings.agentAccess.oauthGrantsDescription')}</CardDescription>
<CardTitle>{t('settings.oauthApps.oauthGrantsSection')}</CardTitle>
<CardDescription>{t('settings.oauthApps.oauthGrantsDescription')}</CardDescription>
</CardHeader>
<CardContent>
{query.isLoading ? (
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
) : query.isError ? (
<p className="py-6 text-center text-sm text-destructive">{t('settings.agentAccess.oauthGrantsError')}</p>
<p className="py-6 text-center text-sm text-destructive">{t('settings.oauthApps.oauthGrantsError')}</p>
) : grants.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">{t('settings.agentAccess.oauthNoGrants')}</p>
<p className="py-6 text-center text-sm text-muted-foreground">{t('settings.oauthApps.oauthNoGrants')}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('settings.agentAccess.oauthClient')}</TableHead>
<TableHead>{t('settings.agentAccess.colWorkspace')}</TableHead>
<TableHead>{t('settings.agentAccess.colScopes')}</TableHead>
<TableHead>{t('settings.agentAccess.colCreated')}</TableHead>
<TableHead>{t('settings.agentAccess.colLastUsed')}</TableHead>
<TableHead className="w-20 text-right">{t('settings.agentAccess.colActions')}</TableHead>
<TableHead>{t('settings.oauthApps.oauthClient')}</TableHead>
<TableHead>{t('settings.oauthApps.colWorkspace')}</TableHead>
<TableHead>{t('settings.oauthApps.colScopes')}</TableHead>
<TableHead>{t('settings.oauthApps.colCreated')}</TableHead>
<TableHead>{t('settings.oauthApps.colLastUsed')}</TableHead>
<TableHead className="w-20 text-right">{t('settings.oauthApps.colActions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -261,13 +261,13 @@ function OAuthGrants() {
</div>
</TableCell>
<TableCell>{formatDate(grant.createdAt)}</TableCell>
<TableCell>{formatDate(grant.lastUsedAt) ?? t('settings.agentAccess.never')}</TableCell>
<TableCell>{formatDate(grant.lastUsedAt) ?? t('settings.oauthApps.never')}</TableCell>
<TableCell className="text-right">
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t('settings.agentAccess.revoke')}
aria-label={t('settings.oauthApps.revoke')}
onClick={() => setRevoking(grant)}
>
<Trash2 className="size-4" />
@@ -284,7 +284,7 @@ function OAuthGrants() {
)
}
export function OAuthAccessSettingsPage() {
export function OAuthAppsSettingsPage() {
const { data } = useListOrganizations()
const organizations = (data ?? []) as Organization[]
const oauthQuery = oauthQueryFromLocation()
+1 -1
View File
@@ -20,7 +20,7 @@ export function SettingsLayout() {
? []
: [{ to: '/settings/webdav', label: t('settings.tabWebDav') }]),
{ to: '/settings/api-keys', label: t('settings.tabApiKeys') },
{ to: '/settings/agent-access', label: t('settings.tabAgentAccess') },
{ to: '/settings/oauth-apps', label: t('settings.tabOAuthApps') },
]
return (
@@ -79,7 +79,7 @@ function ActivityItem({ event }: { event: AuditEvent }) {
}
function formatActor(event: AuditEvent): string {
if (event.actorType === 'agent_oauth') {
if (event.actorType === 'oauth') {
const identity = event.actorRef ?? 'unknown'
return event.actorIssuer ? `Agent:${identity} · ${event.actorIssuer}` : `Agent:${identity}`
}