mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
refactor(oauth): replace agent access naming (#549)
This commit is contained in:
@@ -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.0–v2.9)
|
||||
- [docs/roadmap/](docs/roadmap/) — per-version technical specs (v2.0.md–v2.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
@@ -37,7 +37,7 @@ Each version ships 1–2 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 |
|
||||
|
||||
+724
-724
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,
|
||||
@@ -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
@@ -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))
|
||||
@@ -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) {
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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',
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
+11
-11
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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',
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+25
-25
@@ -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({
|
||||
@@ -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
|
||||
|
||||
+19
-20
@@ -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')
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ function makeDeps(
|
||||
getOrgNames: async () => new Map(),
|
||||
canReadOrg: async () => false,
|
||||
canWriteToOrg: async () => false,
|
||||
canManageAgentAccess: async () => false,
|
||||
isPersonalOrg: async () => false,
|
||||
...overrides.org,
|
||||
},
|
||||
|
||||
@@ -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'] })
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -625,7 +625,7 @@ export interface Announcement {
|
||||
export type AuditActorType =
|
||||
| 'user'
|
||||
| 'api_key'
|
||||
| 'agent_oauth'
|
||||
| 'oauth'
|
||||
| 'agent'
|
||||
| 'anonymous'
|
||||
| 'system'
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
}),
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
+44
-44
@@ -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()
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user