feat: add delegated agent OAuth provider (#539)

* feat: add delegated agent oauth provider

Agent-Profile: https://agent-kanban.dev/agents/e0a1ce35687e48ef

* test(auth): cover delegated OAuth configuration

* fix(auth): route OAuth metadata through worker

* fix(auth): advertise canonical OAuth issuer

* test: cover agent oauth provider integration

Agent-Profile: https://agent-kanban.dev/agents/e0a1ce35687e48ef

* test(auth): cover managed OAuth consent flow

---------

Co-authored-by: Ravi Shah <ravi-shah@mails.agent-kanban.dev>
Co-authored-by: saltbo <saltbo@foxmail.com>
This commit is contained in:
agent-kanban[bot]
2026-07-29 13:26:44 -04:00
committed by GitHub
parent f2aea1bedb
commit d22227ed2f
34 changed files with 13422 additions and 27 deletions
File diff suppressed because it is too large Load Diff
+23 -4
View File
@@ -48,6 +48,11 @@ Restish v2 natively supports authorization code + PKCE. It caches OAuth tokens
separately from HTTP responses, refreshes them, retries once after a `401`, and
supports explicit logout.
Restish v2.3 uses port `8484` and path `/callback` by default for browser
authorization-code callbacks. Restish sends `localhost` in the authorization
request; ZPan also registers the equivalent `127.0.0.1` loopback callback for
clients and tooling that distinguish loopback hostnames.
## 3. Why API Keys Still Exist
CI and unattended services are different: no human is present to complete
@@ -118,16 +123,30 @@ Properties:
- system-managed and not editable/deletable
- public client; no client secret
- authorization code grant with PKCE
- loopback redirect URI such as `http://localhost:8484/callback`
- loopback redirect URIs `http://localhost:8484/callback` and
`http://127.0.0.1:8484/callback`
- refresh-token support through `offline_access`
- Agent scopes only
Dynamic client registration is not required in v2.9. One first-party client is
enough for the versioned ZPan Skill and Restish integration.
The authorization server publishes discovery metadata. Clients must discover
authorization, token, revocation, and user-info or introspection endpoints
rather than hard-code them.
The authorization server publishes discovery metadata. Better Auth OAuth
Provider 1.6.x mounts the runtime endpoints below the Better Auth base path:
| Endpoint | Path |
|----------|------|
| Authorization | `/api/auth/oauth2/authorize` |
| Token and refresh | `/api/auth/oauth2/token` |
| Revocation | `/api/auth/oauth2/revoke` |
| Introspection | `/api/auth/oauth2/introspect` |
| UserInfo | `/api/auth/oauth2/userinfo` |
| Consent | `/api/auth/oauth2/consent` |
| Continue login flow | `/api/auth/oauth2/continue` |
Because Better Auth is mounted at `/api/auth`, ZPan forwards the required
well-known authorization-server and OIDC metadata at root locations and also
publishes protected-resource metadata for `/api`.
## 6. Workspace Grant
+9 -1
View File
@@ -195,10 +195,18 @@ contents continue to move through presigned URLs, never through an MCP result.
Create a system-managed public native client, for example `zpan-agent`, with:
- authorization code + PKCE
- loopback callback such as `http://localhost:8484/callback`
- Restish v2.3 loopback callbacks `http://localhost:8484/callback` and
`http://127.0.0.1:8484/callback`
- refresh-token support through `offline_access`
- only Agent API scopes
Better Auth OAuth Provider 1.6.x serves the flow below the auth base path:
`/api/auth/oauth2/authorize`, `/api/auth/oauth2/token`,
`/api/auth/oauth2/revoke`, `/api/auth/oauth2/introspect`, and
`/api/auth/oauth2/userinfo`. ZPan additionally forwards required root
well-known metadata for the `/api/auth` issuer and publishes protected-resource
metadata for `/api`.
The default Restish profile uses authorization code + PKCE. After
`restish api connect`, the first safe Agent API request opens browser consent;
Restish caches and refreshes the resulting tokens. `--rsh-no-browser` may be
+96
View File
@@ -0,0 +1,96 @@
CREATE TABLE `oauthAccessToken` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`client_id` text NOT NULL,
`session_id` text,
`user_id` text,
`reference_id` text,
`refresh_id` text,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`scopes` text NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauthClient`(`client_id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`refresh_id`) REFERENCES `oauthRefreshToken`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauthAccessToken_token_unique` ON `oauthAccessToken` (`token`);--> statement-breakpoint
CREATE INDEX `oauthAccessToken_client_id_idx` ON `oauthAccessToken` (`client_id`);--> statement-breakpoint
CREATE INDEX `oauthAccessToken_session_id_idx` ON `oauthAccessToken` (`session_id`);--> statement-breakpoint
CREATE INDEX `oauthAccessToken_user_id_idx` ON `oauthAccessToken` (`user_id`);--> statement-breakpoint
CREATE INDEX `oauthAccessToken_refresh_id_idx` ON `oauthAccessToken` (`refresh_id`);--> statement-breakpoint
CREATE INDEX `oauthAccessToken_token_idx` ON `oauthAccessToken` (`token`);--> statement-breakpoint
CREATE TABLE `oauthClient` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`client_secret` text,
`disabled` integer DEFAULT false,
`skip_consent` integer,
`enable_end_session` integer,
`subject_type` text,
`scopes` text,
`user_id` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`name` text,
`uri` text,
`icon` text,
`contacts` text,
`tos` text,
`policy` text,
`software_id` text,
`software_version` text,
`software_statement` text,
`redirect_uris` text NOT NULL,
`post_logout_redirect_uris` text,
`token_endpoint_auth_method` text,
`grant_types` text,
`response_types` text,
`public` integer,
`type` text,
`require_pkce` integer,
`reference_id` text,
`metadata` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauthClient_client_id_unique` ON `oauthClient` (`client_id`);--> statement-breakpoint
CREATE INDEX `oauthClient_client_id_idx` ON `oauthClient` (`client_id`);--> statement-breakpoint
CREATE INDEX `oauthClient_user_id_idx` ON `oauthClient` (`user_id`);--> statement-breakpoint
CREATE TABLE `oauthConsent` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`user_id` text,
`reference_id` text,
`scopes` text NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauthClient`(`client_id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `oauthConsent_client_id_idx` ON `oauthConsent` (`client_id`);--> statement-breakpoint
CREATE INDEX `oauthConsent_user_id_idx` ON `oauthConsent` (`user_id`);--> statement-breakpoint
CREATE TABLE `oauthRefreshToken` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`client_id` text NOT NULL,
`session_id` text,
`user_id` text NOT NULL,
`reference_id` text,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`revoked` integer,
`auth_time` integer,
`scopes` text NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauthClient`(`client_id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauthRefreshToken_token_unique` ON `oauthRefreshToken` (`token`);--> statement-breakpoint
CREATE INDEX `oauthRefreshToken_client_id_idx` ON `oauthRefreshToken` (`client_id`);--> statement-breakpoint
CREATE INDEX `oauthRefreshToken_session_id_idx` ON `oauthRefreshToken` (`session_id`);--> statement-breakpoint
CREATE INDEX `oauthRefreshToken_user_id_idx` ON `oauthRefreshToken` (`user_id`);--> statement-breakpoint
CREATE INDEX `oauthRefreshToken_token_idx` ON `oauthRefreshToken` (`token`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -561,6 +561,13 @@
"when": 1785289409896,
"tag": "0080_downloader-bootstrap-credentials",
"breakpoints": true
},
{
"idx": 81,
"version": "6",
"when": 1785337905649,
"tag": "0081_spotty_boomerang",
"breakpoints": true
}
]
}
+1
View File
@@ -57,6 +57,7 @@
"@aws-sdk/s3-request-presigner": "^3.1022.0",
"@azure/functions": "^4.12.0",
"@better-auth/api-key": "^1.6.14",
"@better-auth/oauth-provider": "1.6.14",
"@better-captcha/react": "^0.7.0",
"@dnd-kit/core": "^6.3.1",
"@hono/node-server": "^2.0.10",
+22
View File
@@ -38,6 +38,9 @@ importers:
'@better-auth/api-key':
specifier: ^1.6.14
version: 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(better-auth@1.6.14(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4))(better-call@1.3.5(zod@4.4.3))
'@better-auth/oauth-provider':
specifier: 1.6.14
version: 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-auth@1.6.14(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4))(better-call@1.3.5(zod@4.4.3))
'@better-captcha/react':
specifier: ^0.7.0
version: 0.7.0(react@19.2.5)(typescript@5.9.3)
@@ -626,6 +629,15 @@ packages:
mongodb:
optional: true
'@better-auth/oauth-provider@1.6.14':
resolution: {integrity: sha512-JL5UNKayERwRbYyZL7DsjOMtMjPWiOVnzUwztIuDNuYK5JZC2Pfm/16MdkEtic8+8YCv9qGK7dph/TTU5fdlKA==}
peerDependencies:
'@better-auth/core': 1.6.14
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
better-auth: ^1.6.14
better-call: 1.3.5
'@better-auth/prisma-adapter@1.6.14':
resolution: {integrity: sha512-9b9wSqhCthMmOYo0QdX+N/cOv+fNck/JE5CZQuuWwEJl5QeoYhCZesXjts5VfLAPMIf6vKw3QNBrn0SVMXXi2Q==}
peerDependencies:
@@ -6452,6 +6464,16 @@ snapshots:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-auth/oauth-provider@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-auth@1.6.14(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4))(better-call@1.3.5(zod@4.4.3))':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
better-auth: 1.6.14(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4)
better-call: 1.3.5(zod@4.4.3)
jose: 6.2.3
zod: 4.4.3
'@better-auth/prisma-adapter@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.1)':
dependencies:
'@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
+318
View File
@@ -0,0 +1,318 @@
import { createHash } from 'node:crypto'
import { AGENT_OAUTH_CLIENT_ID } from '@shared/agent-oauth'
import { AuthorizationScope } from '@shared/authorization'
import { eq, 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'
describe('Agent OAuth gateway', () => {
it('provisions the system public native client', async () => {
const { db } = await createTestApp()
const [client] = await db
.select()
.from(authSchema.oauthClient)
.where(eq(authSchema.oauthClient.clientId, AGENT_OAUTH_CLIENT_ID))
expect(client).toMatchObject({
clientId: AGENT_OAUTH_CLIENT_ID,
tokenEndpointAuthMethod: 'none',
public: true,
type: 'native',
requirePKCE: true,
disabled: false,
})
expect(JSON.parse(client.redirectUris)).toEqual([
'http://localhost:8484/callback',
'http://127.0.0.1:8484/callback',
])
expect(JSON.parse(client.grantTypes ?? '[]')).toEqual(['authorization_code', 'refresh_token'])
})
it('verifies access tokens only while consent is live and scoped to the workspace', async () => {
const { db } = await createTestApp()
const userId = 'oauth-user'
const orgId = 'oauth-org'
await insertUserAndOrg(db, userId, orgId)
await db.insert(authSchema.oauthConsent).values({
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date(),
updatedAt: new Date(),
})
await db.insert(authSchema.oauthAccessToken).values({
id: 'access-1',
token: hashStoredToken('opaque-token'),
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
})
const token = await createAgentOAuthGateway().verifyAccessToken(db, 'opaque-token')
expect(token).toEqual({
grantId: 'grant-1',
userId,
orgId,
clientId: AGENT_OAUTH_CLIENT_ID,
scopes: [AuthorizationScope.OBJECTS_READ],
})
await db.delete(authSchema.oauthConsent).where(eq(authSchema.oauthConsent.id, 'grant-1'))
await expect(createAgentOAuthGateway().verifyAccessToken(db, 'opaque-token')).resolves.toBeNull()
})
it('requires the managed client, workspace, and granted scopes before minting claims', async () => {
const { db } = await createTestApp()
const userId = 'oauth-user'
const orgId = 'oauth-org'
await insertUserAndOrg(db, userId, orgId)
await db.insert(authSchema.oauthConsent).values({
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date(),
updatedAt: new Date(),
})
await expect(
createAgentOAuthGateway().assertLiveGrant(db, {
userId,
clientId: AGENT_OAUTH_CLIENT_ID,
scopes: [AuthorizationScope.OBJECTS_READ],
}),
).rejects.toThrow('agent_oauth_workspace_required')
await expect(
createAgentOAuthGateway().assertLiveGrant(db, {
userId,
clientId: 'other-client',
orgId,
scopes: [AuthorizationScope.OBJECTS_READ],
}),
).rejects.toThrow('agent_oauth_client_denied')
await expect(
createAgentOAuthGateway().assertLiveGrant(db, {
userId,
clientId: AGENT_OAUTH_CLIENT_ID,
orgId,
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
}),
).rejects.toThrow('agent_oauth_scope_denied')
})
it('lists only workspace-bound grants for the managed client', async () => {
const { db } = await createTestApp()
const userId = 'oauth-user'
const orgId = 'oauth-org'
await insertUserAndOrg(db, userId, orgId)
await db.insert(authSchema.oauthConsent).values([
{
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date('2026-07-29T12:00:00.000Z'),
updatedAt: new Date('2026-07-29T12:01:00.000Z'),
},
{
id: 'grant-without-workspace',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: null,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date('2026-07-29T12:02:00.000Z'),
updatedAt: new Date('2026-07-29T12:03:00.000Z'),
},
])
await expect(createAgentOAuthGateway().listGrants(db, userId)).resolves.toEqual([
{
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
orgId,
scopes: [AuthorizationScope.OBJECTS_READ],
createdAt: '2026-07-29T12:00:00.000Z',
updatedAt: '2026-07-29T12:01:00.000Z',
},
])
})
it('revokes only the managed client grant for the selected workspace', async () => {
const { db } = await createTestApp()
const userId = 'oauth-user'
const orgId = 'oauth-org'
await insertUserAndOrg(db, userId, orgId)
await db.insert(authSchema.organization).values({ id: 'oauth-org-2', name: 'OAuth Org 2', slug: 'oauth-org-2' })
await db
.insert(authSchema.member)
.values({ id: 'oauth-org-2-member', organizationId: 'oauth-org-2', userId, role: 'owner' })
await db.insert(authSchema.oauthClient).values({
id: 'other-client',
clientId: 'other-client',
clientSecret: null,
disabled: false,
skipConsent: false,
enableEndSession: false,
subjectType: 'public',
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
name: 'Other Client',
redirectUris: JSON.stringify(['http://localhost/callback']),
tokenEndpointAuthMethod: 'none',
grantTypes: JSON.stringify(['authorization_code']),
responseTypes: JSON.stringify(['code']),
public: true,
type: 'native',
requirePKCE: true,
})
await db.insert(authSchema.oauthConsent).values({
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date(),
updatedAt: new Date(),
})
await db.insert(authSchema.oauthConsent).values([
{
id: 'grant-2',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: 'oauth-org-2',
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: 'other-grant',
clientId: 'other-client',
userId,
referenceId: orgId,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date(),
updatedAt: new Date(),
},
])
await db.insert(authSchema.oauthRefreshToken).values({
id: 'refresh-1',
token: 'hashed-refresh',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
})
await db.insert(authSchema.oauthRefreshToken).values([
{
id: 'refresh-2',
token: 'hashed-refresh-2',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: 'oauth-org-2',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
},
{
id: 'other-refresh',
token: 'hashed-other-refresh',
clientId: 'other-client',
userId,
referenceId: orgId,
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
},
])
await db.insert(authSchema.oauthAccessToken).values({
id: 'access-1',
token: 'hashed-access',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
refreshId: 'refresh-1',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
})
await db.insert(authSchema.oauthAccessToken).values([
{
id: 'access-2',
token: 'hashed-access-2',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: 'oauth-org-2',
refreshId: 'refresh-2',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
},
{
id: 'other-access',
token: 'hashed-other-access',
clientId: 'other-client',
userId,
referenceId: orgId,
refreshId: 'other-refresh',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
},
])
const revoked = await createAgentOAuthGateway().revokeGrant(db, {
userId,
grantId: 'grant-1',
now: new Date('2026-07-29T12:00:00.000Z'),
})
expect(revoked).toBe(true)
expect((await db.select().from(authSchema.oauthConsent)).map((row) => row.id).sort()).toEqual([
'grant-2',
'other-grant',
])
expect((await db.select().from(authSchema.oauthAccessToken)).map((row) => row.id).sort()).toEqual([
'access-2',
'other-access',
])
const [refresh] = await db
.select()
.from(authSchema.oauthRefreshToken)
.where(eq(authSchema.oauthRefreshToken.id, 'refresh-1'))
expect(refresh.revoked?.toISOString()).toBe('2026-07-29T12:00:00.000Z')
const liveRefreshes = await db
.select()
.from(authSchema.oauthRefreshToken)
.where(isNull(authSchema.oauthRefreshToken.revoked))
expect(liveRefreshes.map((row) => row.id).sort()).toEqual(['other-refresh', 'refresh-2'])
})
})
async function insertUserAndOrg(db: Awaited<ReturnType<typeof createTestApp>>['db'], userId: string, orgId: string) {
await db.insert(authSchema.user).values({
id: userId,
name: 'OAuth User',
email: `${userId}@example.com`,
emailVerified: true,
})
await db.insert(authSchema.organization).values({ id: orgId, name: 'OAuth Org', slug: orgId })
await db.insert(authSchema.member).values({ id: `${orgId}-member`, organizationId: orgId, userId, role: 'owner' })
}
function hashStoredToken(token: string): string {
return createHash('sha256').update(token).digest('base64url')
}
+233
View File
@@ -0,0 +1,233 @@
import { createHash } from 'node:crypto'
import {
AGENT_OAUTH_CLIENT_ID,
AGENT_OAUTH_CLIENT_NAME,
AGENT_OAUTH_SCOPES,
RESTISH_OAUTH_REDIRECT_URIS,
} from '@shared/agent-oauth'
import { type AuthorizationScope, isAuthorizationScope } from '@shared/authorization'
import { and, eq, gt, inArray, isNull } from 'drizzle-orm'
import { oauthAccessToken, oauthClient, oauthConsent, oauthRefreshToken, user as userTable } from '../../db/auth-schema'
import { executeWriteTransaction } from '../../db/transaction'
import type { Database } from '../../platform/interface'
import type { AgentOAuthGateway, AgentOAuthGrant } from '../../usecases/ports'
export function createAgentOAuthGateway(): AgentOAuthGateway {
return {
async ensureSystemClient(db) {
const now = new Date()
const row = {
id: AGENT_OAUTH_CLIENT_ID,
clientId: AGENT_OAUTH_CLIENT_ID,
clientSecret: null,
disabled: false,
skipConsent: false,
enableEndSession: false,
subjectType: 'public',
scopes: JSON.stringify([...AGENT_OAUTH_SCOPES]),
userId: null,
createdAt: now,
updatedAt: now,
name: AGENT_OAUTH_CLIENT_NAME,
uri: null,
icon: null,
contacts: null,
tos: null,
policy: null,
softwareId: 'zpan-agent',
softwareVersion: null,
softwareStatement: null,
redirectUris: JSON.stringify([...RESTISH_OAUTH_REDIRECT_URIS]),
postLogoutRedirectUris: null,
tokenEndpointAuthMethod: 'none',
grantTypes: JSON.stringify(['authorization_code', 'refresh_token']),
responseTypes: JSON.stringify(['code']),
public: true,
type: 'native',
requirePKCE: true,
referenceId: 'system',
metadata: JSON.stringify({ systemManaged: true }),
}
await db
.insert(oauthClient)
.values(row)
.onConflictDoUpdate({
target: oauthClient.clientId,
set: {
disabled: false,
scopes: row.scopes,
updatedAt: now,
redirectUris: row.redirectUris,
tokenEndpointAuthMethod: row.tokenEndpointAuthMethod,
grantTypes: row.grantTypes,
responseTypes: row.responseTypes,
public: true,
type: row.type,
requirePKCE: true,
metadata: row.metadata,
},
})
},
async assertLiveGrant(db, input) {
const orgId = input.orgId
if (!orgId) throw new Error('agent_oauth_workspace_required')
if (input.clientId !== AGENT_OAUTH_CLIENT_ID) throw new Error('agent_oauth_client_denied')
const requestedScopes = input.scopes.filter(isAuthorizationScope)
const consent = await findConsent(db, input.userId, input.clientId, orgId)
if (!consent) throw new Error('agent_oauth_grant_revoked')
const grantedScopes = parseScopes(consent.scopes).filter(isAuthorizationScope)
if (!requestedScopes.every((scope) => grantedScopes.includes(scope))) throw new Error('agent_oauth_scope_denied')
},
async verifyAccessToken(db, token) {
const rows = await db
.select({
userId: oauthAccessToken.userId,
clientId: oauthAccessToken.clientId,
orgId: oauthAccessToken.referenceId,
scopes: oauthAccessToken.scopes,
})
.from(oauthAccessToken)
.innerJoin(userTable, eq(userTable.id, oauthAccessToken.userId))
.innerJoin(oauthClient, eq(oauthClient.clientId, oauthAccessToken.clientId))
.where(
and(
eq(oauthAccessToken.token, hashStoredToken(token)),
eq(oauthAccessToken.clientId, AGENT_OAUTH_CLIENT_ID),
gt(oauthAccessToken.expiresAt, new Date()),
eq(oauthClient.disabled, false),
eq(userTable.banned, false),
),
)
.limit(1)
const result = rows[0]
if (!result?.userId || !result.orgId) return null
const scopes = parseScopes(result.scopes).filter(isAuthorizationScope)
const consent = await findConsent(db, result.userId, result.clientId, result.orgId)
if (!consent) return null
const grantedScopes = parseScopes(consent.scopes).filter(isAuthorizationScope)
return {
grantId: consent.id,
userId: result.userId,
orgId: result.orgId,
clientId: result.clientId,
scopes: scopes.filter((scope) => grantedScopes.includes(scope)),
}
},
async listGrants(db, userId) {
const rows = await db
.select({
id: oauthConsent.id,
clientId: oauthConsent.clientId,
userId: oauthConsent.userId,
orgId: oauthConsent.referenceId,
scopes: oauthConsent.scopes,
createdAt: oauthConsent.createdAt,
updatedAt: oauthConsent.updatedAt,
})
.from(oauthConsent)
.where(and(eq(oauthConsent.userId, userId), eq(oauthConsent.clientId, AGENT_OAUTH_CLIENT_ID)))
return rows.flatMap((row): AgentOAuthGrant[] => {
if (!row.userId || !row.orgId) return []
return [
{
id: row.id,
clientId: row.clientId,
userId: row.userId,
orgId: row.orgId,
scopes: parseScopes(row.scopes).filter(isAuthorizationScope),
createdAt: toIso(row.createdAt),
updatedAt: toIso(row.updatedAt),
},
]
})
},
async revokeGrant(db, input) {
const grants = await db
.select({
id: oauthConsent.id,
clientId: oauthConsent.clientId,
userId: oauthConsent.userId,
referenceId: oauthConsent.referenceId,
})
.from(oauthConsent)
.where(
and(
eq(oauthConsent.id, input.grantId),
eq(oauthConsent.userId, input.userId),
eq(oauthConsent.clientId, AGENT_OAUTH_CLIENT_ID),
),
)
.limit(1)
const grant = grants[0]
if (!grant?.userId || !grant.referenceId) return false
const refreshRows = await db
.select({ id: oauthRefreshToken.id })
.from(oauthRefreshToken)
.where(
and(
eq(oauthRefreshToken.clientId, grant.clientId),
eq(oauthRefreshToken.userId, grant.userId),
eq(oauthRefreshToken.referenceId, grant.referenceId),
isNull(oauthRefreshToken.revoked),
),
)
const refreshIds = refreshRows.map((row) => row.id)
await executeWriteTransaction(db, [
db
.delete(oauthAccessToken)
.where(
and(
eq(oauthAccessToken.clientId, grant.clientId),
eq(oauthAccessToken.userId, grant.userId),
eq(oauthAccessToken.referenceId, grant.referenceId),
),
),
...(refreshIds.length > 0
? [db.update(oauthRefreshToken).set({ revoked: input.now }).where(inArray(oauthRefreshToken.id, refreshIds))]
: []),
db.delete(oauthConsent).where(eq(oauthConsent.id, grant.id)),
])
return true
},
}
}
async function findConsent(db: Database, userId: string, clientId: string, orgId: string) {
const rows = await db
.select({ id: oauthConsent.id, scopes: oauthConsent.scopes })
.from(oauthConsent)
.innerJoin(userTable, eq(userTable.id, oauthConsent.userId))
.where(
and(
eq(oauthConsent.userId, userId),
eq(oauthConsent.clientId, clientId),
eq(oauthConsent.referenceId, orgId),
eq(userTable.banned, false),
),
)
.limit(1)
return rows[0] ?? null
}
function parseScopes(value: string | string[] | null): AuthorizationScope[] {
if (Array.isArray(value)) return value.filter(isAuthorizationScope)
if (!value) return []
const parsed = JSON.parse(value) as unknown
return Array.isArray(parsed)
? parsed.filter((scope): scope is AuthorizationScope => typeof scope === 'string' && isAuthorizationScope(scope))
: []
}
function toIso(value: Date | number | string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) throw new Error('invalid_agent_oauth_date')
return date.toISOString()
}
function hashStoredToken(token: string): string {
return createHash('sha256').update(token).digest('base64url')
}
+102 -1
View File
@@ -10,6 +10,7 @@ import { isPotentialWebDavPublicRequest, isWebDavPublicRequest } from './domain/
import { adminOverview } from './http/admin-overview'
import { adminStats } from './http/admin-stats'
import agentApiKeys from './http/agent-api-keys'
import { agentOAuthGrants } from './http/agent-oauth-grants'
import { serveAvatarBlob } from './http/avatar-blobs'
import backgroundJobs from './http/background-jobs'
import { configz } from './http/configz'
@@ -131,11 +132,41 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
}),
)
app.on(['POST', 'GET'], '/api/auth/*', async (c) => {
app.on(['POST', 'GET', 'HEAD'], '/api/auth/*', async (c) => {
const a = c.get('auth')
return a.handler(c.req.raw)
})
app.on(['GET', 'HEAD'], '/.well-known/oauth-authorization-server/api/auth', async (c) => {
return c.get('auth').handler(c.req.raw)
})
app.on(['GET', 'HEAD'], '/.well-known/openid-configuration/api/auth', async (c) => {
return c.get('auth').handler(c.req.raw)
})
app.on(['GET', 'HEAD'], '/.well-known/oauth-protected-resource/api', async (c) => {
const origin = new URL(c.req.url).origin
const authorizationServer = (await c.get('auth').$context).baseURL
return c.json({
resource: `${origin}/api`,
authorization_servers: [authorizationServer],
bearer_methods_supported: ['header'],
scopes_supported: [
'objects:read',
'objects:create',
'objects:update',
'objects:delete',
'shares:read',
'shares:create',
'shares:delete',
'quota:read',
'storage-usage:read',
],
resource_name: 'ZPan API',
})
})
// Global OpenAPI document. Aggregates every route defined with `.openapi()`
// across all mounted sub-apps — a route appears here as soon as its resource is
// converted to OpenAPIHono, no curation needed. better-auth endpoints (incl. the
@@ -166,10 +197,79 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
doc.paths[`/api/auth${path}`] = item as (typeof doc.paths)[string]
}
doc.components ??= {}
doc.components.securitySchemes = {
...(doc.components.securitySchemes ?? {}),
cookieAuth: { type: 'apiKey', in: 'cookie', name: 'zp.session_token' },
bearerAuth: { type: 'http', scheme: 'bearer' },
agentOAuth2: {
type: 'oauth2',
flows: {
authorizationCode: {
authorizationUrl: '/api/auth/oauth2/authorize',
tokenUrl: '/api/auth/oauth2/token',
refreshUrl: '/api/auth/oauth2/token',
scopes: {
'objects:read': 'List, inspect, and download objects',
'objects:create': 'Create folders and upload objects',
'objects:update': 'Rename, move, and copy objects',
'objects:delete': 'Soft-delete objects',
'shares:read': 'List and inspect shares',
'shares:create': 'Create public shares',
'shares:delete': 'Revoke shares',
'quota:read': 'Inspect workspace quota',
'storage-usage:read': 'Inspect workspace storage usage',
},
},
},
},
agentApiKey: { type: 'http', scheme: 'bearer', description: 'Workspace-scoped Agent API key' },
}
doc.components.schemas = {
...(authDoc.components?.schemas as typeof doc.components.schemas),
...doc.components.schemas,
}
Object.assign(doc, {
'x-cli-config': {
auth: {
reader: {
type: 'oauth-authorization-code',
params: {
authorize_url: '/api/auth/oauth2/authorize',
token_url: '/api/auth/oauth2/token',
client_id: 'zpan-agent',
scopes: 'openid offline_access objects:read shares:read quota:read storage-usage:read',
redirect_path: '/callback',
},
},
'file-manager': {
type: 'oauth-authorization-code',
params: {
authorize_url: '/api/auth/oauth2/authorize',
token_url: '/api/auth/oauth2/token',
client_id: 'zpan-agent',
scopes:
'openid offline_access objects:read objects:create objects:update objects:delete shares:read quota:read storage-usage:read',
redirect_path: '/callback',
},
},
publisher: {
type: 'oauth-authorization-code',
params: {
authorize_url: '/api/auth/oauth2/authorize',
token_url: '/api/auth/oauth2/token',
client_id: 'zpan-agent',
scopes:
'openid offline_access objects:read shares:read shares:create shares:delete quota:read storage-usage:read',
redirect_path: '/callback',
},
},
ci: {
type: 'http-bearer',
params: { token: 'env:ZPAN_AGENT_API_KEY' },
},
},
},
})
// better-auth's device-authorization plugin advertises POST /device/token as
// returning { session, user }, but its handler actually returns the OAuth
@@ -255,6 +355,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
app.route('/api/shares', authedShares)
app.route('/api/trash', trash)
app.route('/api/workspaces', agentApiKeys)
app.route('/api', agentOAuthGrants)
app.route('/api/teams', teams)
app.route('/api/teams', adminTeams)
app.route('/api/site/storages', storages)
+136 -1
View File
@@ -1,6 +1,6 @@
import { isPersonalOrgLike } from '@shared/org-slugs'
import { eq } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createInviteRepo } from './adapters/repos/invite.js'
import { createSiteInvitationRepo } from './adapters/repos/site-invitations.js'
import { createApp } from './app.js'
@@ -12,6 +12,10 @@ import { createTestApp, seedProLicense } from './test/setup.js'
type TestCtx = Awaited<ReturnType<typeof createTestApp>>
afterEach(() => {
vi.unstubAllGlobals()
})
async function signUp(ctx: TestCtx, email: string, extra?: Record<string, unknown>) {
return ctx.app.request('/api/auth/sign-up/email', {
method: 'POST',
@@ -595,6 +599,73 @@ describe('loadProviderConfigs — builtin social provider resolution', () => {
expect([200, 302]).toContain(res.status)
})
it('social sign-in with a configured and enabled OIDC provider returns a redirect', async () => {
const ctx = await createTestApp()
const oidcConfig = JSON.stringify({
providerId: 'my-oidc',
type: 'oidc',
clientId: 'oidc-client',
clientSecret: 'oidc-secret',
enabled: true,
discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration',
scopes: ['openid', 'email'],
})
await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_my-oidc', value: oidcConfig })
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
if (url === 'https://auth.example.com/.well-known/openid-configuration') {
return new Response(
JSON.stringify({
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/oauth2/authorize',
token_endpoint: 'https://auth.example.com/oauth2/token',
jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
}),
{
headers: { 'Content-Type': 'application/json' },
},
)
}
throw new Error(`unexpected fetch: ${url}`)
}),
)
const auth = await createAuth(ctx.platform, 'test-secret', 'http://localhost:3000')
const app = createApp(ctx.platform, auth)
const res = await app.request('/api/auth/sign-in/social', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'my-oidc', callbackURL: 'http://localhost:3000/callback' }),
})
expect([200, 302]).toContain(res.status)
})
it('social sign-in ignores a disabled builtin provider config', async () => {
const ctx = await createTestApp()
const builtinConfig = JSON.stringify({
providerId: 'github',
type: 'builtin',
clientId: 'gh-client',
clientSecret: 'gh-secret',
enabled: false,
})
await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_github', value: builtinConfig })
const auth = await createAuth(ctx.platform, 'test-secret', 'http://localhost:3000')
const app = createApp(ctx.platform, auth)
const res = await app.request('/api/auth/sign-in/social', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'github', callbackURL: 'http://localhost:3000/callback' }),
})
expect(res.status).not.toBe(200)
})
it('createAuth runs exactly one DB query during init (no per-provider I/O)', async () => {
const ctx = await createTestApp()
let selectCalls = 0
@@ -623,6 +694,70 @@ describe('loadProviderConfigs — builtin social provider resolution', () => {
})
})
describe('Agent OAuth consent guards', () => {
it('issues an authorization code after full consent for the managed PKCE client', async () => {
const ctx = await createTestApp()
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 cookie = signUpResponse.headers
.getSetCookie()
.map((value) => value.split(';', 1)[0])
.join('; ')
const params = new URLSearchParams({
client_id: 'zpan-agent',
redirect_uri: 'http://127.0.0.1:8484/callback',
response_type: 'code',
scope: 'openid offline_access objects:read quota:read',
state: 'oauth-consent-test',
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
code_challenge_method: 'S256',
})
const authorize = await app.request(`${previewOrigin}/api/auth/oauth2/authorize?${params}`, {
headers: { Cookie: cookie, Origin: previewOrigin },
})
const consentLocation = authorize.headers.get('location')
expect(authorize.status).toBe(302)
expect(consentLocation).toMatch(/^\/settings\/agent-access\?/)
const consent = await app.request(`${previewOrigin}/api/auth/oauth2/consent`, {
method: 'POST',
headers: {
Cookie: cookie,
Origin: previewOrigin,
'Content-Type': 'application/json',
},
body: JSON.stringify({
accept: true,
oauth_query: consentLocation?.slice(consentLocation.indexOf('?') + 1),
}),
})
const consentBody = await consent.text()
expect(consent.status, consentBody).toBe(200)
expect(JSON.parse(consentBody)).toMatchObject({
url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:8484\/callback\?code=/),
})
})
it('blocks partial Agent OAuth consent changes through the Better Auth endpoint', async () => {
const ctx = await createTestApp()
const res = await ctx.app.request('/api/auth/oauth2/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: 'zpan-agent', scope: 'objects:read' }),
})
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({
error: 'invalid_request',
error_description: 'Partial Agent OAuth consent is not supported',
})
})
})
describe('session hook — activeOrganizationId is set on sign-in after sign-up', () => {
it('sign-in after sign-up succeeds and returns a session cookie', async () => {
const ctx = await createTestApp()
+22
View File
@@ -1,4 +1,5 @@
import { apiKey } from '@better-auth/api-key'
import { oauthProvider } from '@better-auth/oauth-provider'
import { APIError, type BetterAuthOptions, type BetterAuthPlugin, betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { createAuthMiddleware, getSessionFromCtx } from 'better-auth/api'
@@ -38,6 +39,7 @@ import {
} from '../shared/oauth-providers'
import { generateUserOrgSlug, isPersonalOrgLike } from '../shared/org-slugs'
import { createEmailGateway } from './adapters/gateways/email'
import { createAgentOAuthGateway } from './adapters/repos/agent-oauth'
import { deleteApiKeysScopedToOrganization } from './adapters/repos/api-key-scopes'
import { createAuditRepo } from './adapters/repos/audit'
import { createDownloadTokenGateway } from './adapters/repos/download-tokens'
@@ -51,6 +53,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 * as authSchema from './db/auth-schema'
import { orgQuotaEntitlements, orgQuotas, systemOptions } from './db/schema'
import { executeWriteTransaction } from './db/transaction'
@@ -344,6 +347,8 @@ export async function createAuth(
const systemOptionsRepo = createSystemOptionsRepo(db)
const email = createEmailGateway(systemOptionsRepo)
const providerConfigs = await loadProviderConfigs(rawDb)
const agentOAuth = createAgentOAuthGateway()
await agentOAuth.ensureSystemClient(db)
const usesNativeWebDavRateLimit = Boolean(authPlatform.getBinding(WEBDAV_RATE_LIMITER_BINDING))
const authOptions = {
database: drizzleAdapter(db, { provider: 'sqlite', schema: authSchema }),
@@ -423,6 +428,22 @@ export async function createAuth(
}
return
}
if (ctx.path === '/oauth2/consent') {
const body = ctx.body as Record<string, unknown> | undefined
if (body?.scope !== undefined) {
throw new APIError('BAD_REQUEST', {
error: 'invalid_request',
error_description: 'Partial Agent OAuth consent is not supported',
})
}
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',
})
}
if (ctx.path !== '/api-key/create') return
const body = ctx.body as Record<string, unknown> | undefined
@@ -609,6 +630,7 @@ export async function createAuth(
verificationUri: '/device',
validateClient: async (clientId) => clientId === LEGACY_DOWNLOADER_CLIENT_ID,
}),
oauthProvider(createAgentOAuthProviderOptions({ db, agentOAuth })),
apiKey([
{
configId: ApiKeyTemplate.IHOST,
+143
View File
@@ -0,0 +1,143 @@
import { AGENT_OAUTH_ACCESS_TOKEN_SECONDS, AGENT_OAUTH_CLIENT_ID, AGENT_OAUTH_SCOPES } from '@shared/agent-oauth'
import { AuthorizationScope } from '@shared/authorization'
import { describe, expect, it, vi } from 'vitest'
import type { AgentOAuthGateway } from '../usecases/ports'
import { createAgentOAuthProviderOptions } from './agent-oauth-provider'
const db = {} as never
function createGateway(): AgentOAuthGateway {
return {
ensureSystemClient: vi.fn(),
assertLiveGrant: vi.fn(),
verifyAccessToken: vi.fn(),
listGrants: vi.fn(),
revokeGrant: vi.fn(),
}
}
function createOptions(input?: {
findPersonalOrg?: (userId: string) => Promise<string | null>
getMemberRole?: (orgId: string, userId: string) => Promise<string | null>
gateway?: AgentOAuthGateway
}) {
return createAgentOAuthProviderOptions({
db,
agentOAuth: input?.gateway ?? createGateway(),
orgs: {
findPersonalOrg: input?.findPersonalOrg ?? vi.fn(async () => 'personal-org'),
getMemberRole: input?.getMemberRole ?? vi.fn(async () => 'owner'),
},
})
}
describe('createAgentOAuthProviderOptions', () => {
it('configures the managed public native Agent OAuth provider contract', async () => {
const options = createOptions()
expect(options).toMatchObject({
disableJwtPlugin: true,
loginPage: '/sign-in',
consentPage: '/settings/agent-access',
accessTokenExpiresIn: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
grantTypes: ['authorization_code', 'refresh_token'],
postLogin: { page: '/settings/agent-access' },
})
expect(options.scopes).toEqual([...AGENT_OAUTH_SCOPES])
expect(options.cachedTrustedClients?.has(AGENT_OAUTH_CLIENT_ID)).toBe(true)
await expect(options.postLogin?.shouldRedirect?.({} as never)).resolves.toBe(false)
})
it('binds consent to the active workspace when the user still has access', async () => {
const options = createOptions()
await expect(
options.postLogin?.consentReferenceId?.({
user: { id: 'user-1' },
session: { activeOrganizationId: 'team-org' },
scopes: ['openid', AuthorizationScope.OBJECTS_READ],
} as never),
).resolves.toBe('team-org')
})
it('falls back to the personal workspace when no active workspace is set', async () => {
const options = createOptions({
findPersonalOrg: vi.fn(async () => 'personal-org'),
getMemberRole: vi.fn(async () => null),
})
await expect(
options.postLogin?.consentReferenceId?.({
user: { id: 'user-1' },
session: {},
scopes: [AuthorizationScope.OBJECTS_READ],
} as never),
).resolves.toBe('personal-org')
})
it('rejects ungrantable scopes, missing workspaces, and inaccessible active workspaces', async () => {
await expect(
createOptions().postLogin?.consentReferenceId?.({
user: { id: 'user-1' },
session: {},
scopes: ['objects:read', 'admin:root'],
} as never),
).rejects.toMatchObject({ body: expect.objectContaining({ error: 'invalid_scope' }) })
await expect(
createOptions({ findPersonalOrg: vi.fn(async () => null) }).postLogin?.consentReferenceId?.({
user: { id: 'user-1' },
session: {},
scopes: [AuthorizationScope.OBJECTS_READ],
} as never),
).rejects.toMatchObject({
body: expect.objectContaining({ error_description: 'A workspace is required for Agent OAuth' }),
})
await expect(
createOptions({
findPersonalOrg: vi.fn(async () => 'personal-org'),
getMemberRole: vi.fn(async () => null),
}).postLogin?.consentReferenceId?.({
user: { id: 'user-1' },
session: { activeOrganizationId: 'team-org' },
scopes: [AuthorizationScope.OBJECTS_READ],
} as never),
).rejects.toMatchObject({
body: expect.objectContaining({ error_description: 'Workspace access is required for Agent OAuth' }),
})
})
it('adds ZPan Agent claims only for valid live grants', async () => {
const gateway = createGateway()
const options = createOptions({ gateway })
await expect(
options.customAccessTokenClaims?.({
user: { id: 'user-1' },
referenceId: 'team-org',
scopes: [AuthorizationScope.OBJECTS_READ],
metadata: {},
} as never),
).resolves.toEqual({ zpan_org_id: 'team-org', zpan_actor: 'agent_oauth' })
expect(gateway.assertLiveGrant).toHaveBeenCalledWith(db, {
userId: 'user-1',
clientId: AGENT_OAUTH_CLIENT_ID,
orgId: 'team-org',
scopes: [AuthorizationScope.OBJECTS_READ],
})
})
it('skips non-agent clients and rejects missing user or workspace context', async () => {
const options = createOptions()
await expect(
options.customAccessTokenClaims?.({ metadata: { client_id: 'other-client' }, scopes: [] } as never),
).resolves.toEqual({})
await expect(
options.customAccessTokenClaims?.({ user: { id: 'user-1' }, scopes: [] } as never),
).rejects.toMatchObject({
body: expect.objectContaining({ error_description: 'Agent OAuth grant is missing workspace context' }),
})
})
})
+83
View File
@@ -0,0 +1,83 @@
import type { oauthProvider } from '@better-auth/oauth-provider'
import { APIError } from 'better-auth'
import {
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
AGENT_OAUTH_CLIENT_ID,
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
AGENT_OAUTH_SCOPES,
} from '../../shared/agent-oauth'
import { createOrgRepo } from '../adapters/repos/org'
import type { Database } from '../platform/interface'
import type { AgentOAuthGateway } from '../usecases/ports'
type AgentOAuthOrgLookup = Pick<ReturnType<typeof createOrgRepo>, 'findPersonalOrg' | 'getMemberRole'>
type AgentOAuthProviderOptions = Parameters<typeof oauthProvider>[0]
export function createAgentOAuthProviderOptions(input: {
db: Database
agentOAuth: AgentOAuthGateway
orgs?: AgentOAuthOrgLookup
}): AgentOAuthProviderOptions {
const orgs = input.orgs ?? createOrgRepo(input.db)
return {
disableJwtPlugin: true,
loginPage: '/sign-in',
consentPage: '/settings/agent-access',
accessTokenExpiresIn: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
refreshTokenExpiresIn: AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
grantTypes: ['authorization_code', 'refresh_token'],
scopes: [...AGENT_OAUTH_SCOPES],
advertisedMetadata: { scopes_supported: [...AGENT_OAUTH_SCOPES] },
cachedTrustedClients: new Set([AGENT_OAUTH_CLIENT_ID]),
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
postLogin: {
page: '/settings/agent-access',
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)
if (clientScopes.some((scope) => !grantableScopes.has(scope))) {
throw new APIError('BAD_REQUEST', { error: 'invalid_scope', error_description: 'Scope is not grantable' })
}
const orgId = typeof session.activeOrganizationId === 'string' ? session.activeOrganizationId : null
const selectedOrgId = orgId || (await orgs.findPersonalOrg(user.id))
if (!selectedOrgId) {
throw new APIError('BAD_REQUEST', {
error: 'invalid_request',
error_description: 'A workspace is required for Agent 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',
})
}
return selectedOrgId
},
},
customAccessTokenClaims: async ({ user, referenceId, scopes, metadata }) => {
if (metadata?.client_id && metadata.client_id !== AGENT_OAUTH_CLIENT_ID) return {}
if (!user?.id || !referenceId) {
throw new APIError('BAD_REQUEST', {
error: 'invalid_grant',
error_description: 'Agent OAuth grant is missing workspace context',
})
}
await input.agentOAuth.assertLiveGrant(input.db, {
userId: user.id,
clientId: AGENT_OAUTH_CLIENT_ID,
orgId: referenceId,
scopes,
})
return {
zpan_org_id: referenceId,
zpan_actor: 'agent_oauth',
}
},
}
}
+2
View File
@@ -15,6 +15,7 @@ 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'
@@ -80,6 +81,7 @@ export function createDeps(platform: Platform, options: CreateDepsOptions = {}):
return {
audit: createAuditRepo(db),
adminStats: createAdminStatsRepo(db),
agentOAuth: createAgentOAuthGateway(),
announcements: createAnnouncementRepo(db),
apiKeys: createApiKeyGateway(),
archiveJobs: createArchiveJobsGateway(platform),
+78 -1
View File
@@ -1,6 +1,13 @@
import { getTableConfig } from 'drizzle-orm/sqlite-core'
import { describe, expect, it } from 'vitest'
import { downloaderBootstrapCredential, user } from './auth-schema.js'
import {
downloaderBootstrapCredential,
oauthAccessToken,
oauthClient,
oauthConsent,
oauthRefreshToken,
user,
} from './auth-schema.js'
describe('auth-schema user table', () => {
it('has a username column', () => {
@@ -86,3 +93,73 @@ describe('downloaderBootstrapCredential table', () => {
expect(foreignKeys[0].reference().foreignColumns[0].name).toBe('id')
})
})
describe('Agent OAuth tables', () => {
it('declares the managed client columns and indexes', () => {
const { foreignKeys, indexes } = getTableConfig(oauthClient)
expect(oauthClient.clientId.name).toBe('client_id')
expect(oauthClient.redirectUris.notNull).toBe(true)
expect(oauthClient.requirePKCE.name).toBe('require_pkce')
expect(oauthClient.updatedAt.onUpdateFn?.()).toBeInstanceOf(Date)
expect(foreignKeys.map((foreignKey) => foreignKey.reference().foreignColumns[0].name)).toEqual(['id'])
expect(indexes.map((index) => index.config.name).sort()).toEqual([
'oauthClient_client_id_idx',
'oauthClient_user_id_idx',
])
})
it('declares refresh-token relationships and lookup indexes', () => {
const { foreignKeys, indexes } = getTableConfig(oauthRefreshToken)
expect(oauthRefreshToken.referenceId.name).toBe('reference_id')
expect(oauthRefreshToken.revoked.name).toBe('revoked')
expect(foreignKeys).toHaveLength(3)
expect(foreignKeys.map((foreignKey) => foreignKey.reference().foreignColumns[0].name)).toEqual([
'client_id',
'id',
'id',
])
expect(indexes.map((index) => index.config.name).sort()).toEqual([
'oauthRefreshToken_client_id_idx',
'oauthRefreshToken_session_id_idx',
'oauthRefreshToken_token_idx',
'oauthRefreshToken_user_id_idx',
])
})
it('declares access-token relationships and lookup indexes', () => {
const { foreignKeys, indexes } = getTableConfig(oauthAccessToken)
expect(oauthAccessToken.referenceId.name).toBe('reference_id')
expect(oauthAccessToken.expiresAt.notNull).toBe(true)
expect(foreignKeys).toHaveLength(4)
expect(foreignKeys.map((foreignKey) => foreignKey.reference().foreignColumns[0].name)).toEqual([
'client_id',
'id',
'id',
'id',
])
expect(indexes.map((index) => index.config.name).sort()).toEqual([
'oauthAccessToken_client_id_idx',
'oauthAccessToken_refresh_id_idx',
'oauthAccessToken_session_id_idx',
'oauthAccessToken_token_idx',
'oauthAccessToken_user_id_idx',
])
})
it('declares consent relationships and lookup indexes', () => {
const { foreignKeys, indexes } = getTableConfig(oauthConsent)
expect(oauthConsent.referenceId.name).toBe('reference_id')
expect(oauthConsent.scopes.notNull).toBe(true)
expect(foreignKeys).toHaveLength(2)
expect(oauthConsent.updatedAt.onUpdateFn?.()).toBeInstanceOf(Date)
expect(foreignKeys.map((foreignKey) => foreignKey.reference().foreignColumns[0].name)).toEqual(['client_id', 'id'])
expect(indexes.map((index) => index.config.name).sort()).toEqual([
'oauthConsent_client_id_idx',
'oauthConsent_user_id_idx',
])
})
})
+122
View File
@@ -216,6 +216,128 @@ export const deviceCode = sqliteTable(
],
)
export const oauthClient = sqliteTable(
'oauthClient',
{
id: text('id').primaryKey(),
clientId: text('client_id').notNull().unique(),
clientSecret: text('client_secret'),
disabled: integer('disabled', { mode: 'boolean' }).default(false),
skipConsent: integer('skip_consent', { mode: 'boolean' }),
enableEndSession: integer('enable_end_session', { mode: 'boolean' }),
subjectType: text('subject_type'),
scopes: text('scopes'), // JSON-serialized string[]
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
name: text('name'),
uri: text('uri'),
icon: text('icon'),
contacts: text('contacts'), // JSON-serialized string[]
tos: text('tos'),
policy: text('policy'),
softwareId: text('software_id'),
softwareVersion: text('software_version'),
softwareStatement: text('software_statement'),
redirectUris: text('redirect_uris').notNull(), // JSON-serialized string[]
postLogoutRedirectUris: text('post_logout_redirect_uris'), // JSON-serialized string[]
tokenEndpointAuthMethod: text('token_endpoint_auth_method'),
grantTypes: text('grant_types'), // JSON-serialized string[]
responseTypes: text('response_types'), // JSON-serialized string[]
public: integer('public', { mode: 'boolean' }),
type: text('type'),
requirePKCE: integer('require_pkce', { mode: 'boolean' }),
referenceId: text('reference_id'),
metadata: text('metadata'),
},
(table) => [index('oauthClient_client_id_idx').on(table.clientId), index('oauthClient_user_id_idx').on(table.userId)],
)
export const oauthRefreshToken = sqliteTable(
'oauthRefreshToken',
{
id: text('id').primaryKey(),
token: text('token').notNull().unique(),
clientId: text('client_id')
.notNull()
.references(() => oauthClient.clientId, { onDelete: 'cascade' }),
sessionId: text('session_id').references(() => session.id, { onDelete: 'set null' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
referenceId: text('reference_id'),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
revoked: integer('revoked', { mode: 'timestamp_ms' }),
authTime: integer('auth_time', { mode: 'timestamp_ms' }),
scopes: text('scopes').notNull(), // JSON-serialized string[]
},
(table) => [
index('oauthRefreshToken_client_id_idx').on(table.clientId),
index('oauthRefreshToken_session_id_idx').on(table.sessionId),
index('oauthRefreshToken_user_id_idx').on(table.userId),
index('oauthRefreshToken_token_idx').on(table.token),
],
)
export const oauthAccessToken = sqliteTable(
'oauthAccessToken',
{
id: text('id').primaryKey(),
token: text('token').notNull().unique(),
clientId: text('client_id')
.notNull()
.references(() => oauthClient.clientId, { onDelete: 'cascade' }),
sessionId: text('session_id').references(() => session.id, { onDelete: 'set null' }),
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
referenceId: text('reference_id'),
refreshId: text('refresh_id').references(() => oauthRefreshToken.id, { onDelete: 'cascade' }),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
scopes: text('scopes').notNull(), // JSON-serialized string[]
},
(table) => [
index('oauthAccessToken_client_id_idx').on(table.clientId),
index('oauthAccessToken_session_id_idx').on(table.sessionId),
index('oauthAccessToken_user_id_idx').on(table.userId),
index('oauthAccessToken_refresh_id_idx').on(table.refreshId),
index('oauthAccessToken_token_idx').on(table.token),
],
)
export const oauthConsent = sqliteTable(
'oauthConsent',
{
id: text('id').primaryKey(),
clientId: text('client_id')
.notNull()
.references(() => oauthClient.clientId, { onDelete: 'cascade' }),
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
referenceId: text('reference_id'),
scopes: text('scopes').notNull(), // JSON-serialized string[]
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [
index('oauthConsent_client_id_idx').on(table.clientId),
index('oauthConsent_user_id_idx').on(table.userId),
],
)
export const downloaderBootstrapCredential = sqliteTable(
'downloader_bootstrap_credentials',
{
@@ -0,0 +1,159 @@
import { createHash } from 'node:crypto'
import { AGENT_OAUTH_CLIENT_ID } from '@shared/agent-oauth'
import { AuthorizationScope } from '@shared/authorization'
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../db/auth-schema.js'
import { authedHeaders, createTestApp } from '../test/setup.js'
type TestContext = Awaited<ReturnType<typeof createTestApp>>
async function getUserAndPersonalOrg(db: TestContext['db'], email: string) {
const rows = await db.all<{ userId: string; orgId: string }>(sql`
SELECT u.id AS userId, o.id AS orgId
FROM user u
INNER JOIN member m ON m.user_id = u.id
INNER JOIN organization o ON o.id = m.organization_id
WHERE u.email = ${email} AND o.metadata LIKE '%"type":"personal"%'
LIMIT 1
`)
if (!rows[0]) throw new Error(`expected personal org for ${email}`)
return rows[0]
}
async function insertTeamOrg(db: TestContext['db'], orgId: string, userId: string) {
const now = Date.now()
await db.run(sql`
INSERT INTO organization (id, name, slug, metadata, created_at, updated_at)
VALUES (${orgId}, ${`Team ${orgId}`}, ${orgId}, '{"type":"team"}', ${now}, ${now})
`)
await db.run(sql`
INSERT INTO member (id, organization_id, user_id, role, created_at)
VALUES (${`${orgId}-member`}, ${orgId}, ${userId}, 'owner', ${now})
`)
}
async function insertGrant(
db: TestContext['db'],
input: { userId: string; orgId: string; scopes: AuthorizationScope[] },
) {
const now = new Date('2026-07-29T12:00:00.000Z')
await db.insert(authSchema.oauthConsent).values({
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
userId: input.userId,
referenceId: input.orgId,
scopes: JSON.stringify(input.scopes),
createdAt: now,
updatedAt: now,
})
await db.insert(authSchema.oauthRefreshToken).values({
id: 'refresh-1',
token: 'hashed-refresh',
clientId: AGENT_OAUTH_CLIENT_ID,
userId: input.userId,
referenceId: input.orgId,
expiresAt: new Date(Date.now() + 60_000),
createdAt: now,
scopes: JSON.stringify(input.scopes),
})
await db.insert(authSchema.oauthAccessToken).values({
id: 'access-1',
token: hashStoredToken('live-agent-token'),
clientId: AGENT_OAUTH_CLIENT_ID,
userId: input.userId,
referenceId: input.orgId,
refreshId: 'refresh-1',
expiresAt: new Date(Date.now() + 60_000),
createdAt: now,
scopes: JSON.stringify(input.scopes),
})
}
describe('Agent OAuth grants API integration', () => {
it('lists and revokes the current user grant family', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app, 'agent-grants@example.com')
const { userId, orgId } = await getUserAndPersonalOrg(db, 'agent-grants@example.com')
await insertGrant(db, { userId, orgId, scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ] })
const list = await app.request('/api/agent-oauth-grants', { headers })
expect(list.status).toBe(200)
await expect(list.json()).resolves.toEqual({
items: [
{
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
orgId,
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
createdAt: '2026-07-29T12:00:00.000Z',
updatedAt: '2026-07-29T12:00:00.000Z',
},
],
})
const revoke = await app.request('/api/agent-oauth-grants/grant-1', { method: 'DELETE', headers })
expect(revoke.status).toBe(204)
expect(await db.select().from(authSchema.oauthConsent)).toHaveLength(0)
expect(await db.select().from(authSchema.oauthAccessToken)).toHaveLength(0)
const [refresh] = await db.select().from(authSchema.oauthRefreshToken)
expect(refresh.revoked).not.toBeNull()
})
it('enforces live grant membership and fixed workspace for Agent OAuth bearer access', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app, 'agent-scope@example.com')
const { userId, orgId } = await getUserAndPersonalOrg(db, 'agent-scope@example.com')
await insertTeamOrg(db, 'other-workspace', userId)
await insertGrant(db, { userId, orgId, scopes: [AuthorizationScope.OBJECTS_READ] })
const bearer = { Authorization: 'Bearer live-agent-token' }
const allowed = await app.request('/api/objects', { headers: bearer })
expect(allowed.status).toBe(200)
const wrongWorkspace = await app.request('/api/objects?orgId=other-workspace', { headers: bearer })
expect(wrongWorkspace.status).toBe(403)
const revoke = await app.request('/api/agent-oauth-grants/grant-1', { method: 'DELETE', headers })
expect(revoke.status).toBe(204)
const revoked = await app.request('/api/objects', { headers: bearer })
expect(revoked.status).toBe(401)
})
it('blocks generic Better Auth OAuth consent mutation endpoints', async () => {
const { app } = await createTestApp()
for (const path of ['/api/auth/oauth2/update-consent', '/api/auth/oauth2/delete-consent']) {
const res = await app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: AGENT_OAUTH_CLIENT_ID }),
})
expect(res.status).toBe(403)
await expect(res.json()).resolves.toMatchObject({
error_description: 'Manage Agent OAuth grants from the Agent Access API',
})
}
})
it('returns 404 when revoking a missing Agent OAuth grant', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app, 'agent-missing-grant@example.com')
const revoke = await app.request('/api/agent-oauth-grants/missing-grant', { method: 'DELETE', headers })
expect(revoke.status).toBe(404)
await expect(revoke.json()).resolves.toMatchObject({
error: {
message: 'Agent OAuth grant not found',
},
})
})
})
function hashStoredToken(token: string): string {
return createHash('sha256').update(token).digest('base64url')
}
+65
View File
@@ -0,0 +1,65 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { listAgentOAuthGrants, revokeAgentOAuthGrant } from '../usecases/agent-oauth-grants'
import { authRoute, errorResponse, jsonContent } from './openapi'
const agentOAuthGrantSchema = z.object({
id: z.string(),
clientId: z.string(),
userId: z.string(),
orgId: z.string(),
scopes: z.array(z.enum(Object.values(AuthorizationScope) as [AuthorizationScope, ...AuthorizationScope[]])),
createdAt: z.string(),
updatedAt: z.string(),
})
const listSchema = z.object({ items: z.array(agentOAuthGrantSchema) })
const paramsSchema = z.object({ grantId: z.string().min(1) })
const listRoute = authRoute(
{ access: 'session' },
{
operationId: 'listAgentOAuthGrants',
summary: 'List Agent OAuth grants',
tags: ['Agent Access'],
method: 'get',
path: '/agent-oauth-grants',
middleware: [requireAuth] as const,
responses: {
200: jsonContent(listSchema, 'Agent OAuth grants'),
},
},
)
const revokeRoute = authRoute(
{ access: 'session' },
{
operationId: 'revokeAgentOAuthGrant',
summary: 'Revoke an Agent OAuth grant',
tags: ['Agent Access'],
method: 'delete',
path: '/agent-oauth-grants/{grantId}',
middleware: [requireAuth] as const,
request: { params: paramsSchema },
responses: {
204: { description: 'Revoked' },
404: errorResponse('Agent OAuth grant not found'),
},
},
)
export const agentOAuthGrants = new OpenAPIHono<Env>()
.openapi(listRoute, async (c) => {
const result = await listAgentOAuthGrants(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, {
userId: c.get('userId')!,
grantId,
})
return c.body(null, 204)
})
+47
View File
@@ -40,6 +40,53 @@ describe('[CF] Auth API', () => {
expect(res.headers.get('set-cookie')).toBeTruthy()
})
it('completes managed Agent 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`,
password: 'password123456',
}),
})
const cookie = signUp.headers
.getSetCookie()
.map((value) => value.split(';', 1)[0])
.join('; ')
const params = new URLSearchParams({
client_id: 'zpan-agent',
redirect_uri: 'http://127.0.0.1:8484/callback',
response_type: 'code',
scope: 'openid offline_access objects:read quota:read',
state: 'cf-agent-oauth',
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
code_challenge_method: 'S256',
})
const authorize = await app.request(`/api/auth/oauth2/authorize?${params}`, {
headers: { Cookie: cookie, Origin: 'http://localhost' },
})
const consentLocation = authorize.headers.get('location')
expect(authorize.status).toBe(302)
expect(consentLocation).toMatch(/^\/settings\/agent-access\?/)
const consent = await app.request('/api/auth/oauth2/consent', {
method: 'POST',
headers: { Cookie: cookie, Origin: 'http://localhost', 'Content-Type': 'application/json' },
body: JSON.stringify({
accept: true,
oauth_query: consentLocation?.slice(consentLocation.indexOf('?') + 1),
}),
})
const consentBody = await consent.text()
expect(consent.status, consentBody).toBe(200)
expect(JSON.parse(consentBody)).toMatchObject({
url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:8484\/callback\?code=/),
})
})
// Wrong password test is covered by Node tests (auth.test.ts).
// Better Auth throws an unhandled rejection internally on auth failure
// that leaks into the Miniflare isolate, causing a false test failure.
+18
View File
@@ -3,6 +3,24 @@ import { auditActor } from './audit-actor'
import type { AuthPrincipal } from './platform'
describe('auditActor', () => {
it('records Agent OAuth principals as delegated Agent actors', () => {
const principal: AuthPrincipal = {
kind: 'agent-oauth',
userId: 'user-1',
grantId: 'grant-1',
clientId: 'zpan-agent',
orgId: 'org-1',
scopes: [],
authMethod: 'bearer',
}
expect(auditActor(principal)).toEqual({
userId: 'user-1',
actorType: 'agent_oauth',
actorRef: 'grant-1',
})
})
it('records downloader bootstrap principals as user actors', () => {
const principal: AuthPrincipal = {
kind: 'downloader-bootstrap',
+3
View File
@@ -9,6 +9,9 @@ export function auditActor(principal: AuthPrincipal | null): AuditActor {
if (principal.kind === 'api-key') {
return { userId: principal.userId, actorType: 'api_key', actorRef: principal.keyId }
}
if (principal.kind === 'agent-oauth') {
return { userId: principal.userId, actorType: 'agent_oauth', actorRef: principal.grantId }
}
if (principal.kind === 'downloader') {
return { userId: null, actorType: 'downloader', actorRef: principal.downloaderId }
}
+27
View File
@@ -117,6 +117,33 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
await next()
return
}
const agentOAuth = await deps.agentOAuth.verifyAccessToken(platform.db, token)
if (agentOAuth) {
if (await deps.userAdmin.isBanned(agentOAuth.userId)) throw unauthorized('Unauthorized')
c.set('principal', {
kind: 'agent-oauth',
grantId: agentOAuth.grantId,
clientId: agentOAuth.clientId,
orgId: agentOAuth.orgId,
userId: agentOAuth.userId,
scopes: agentOAuth.scopes,
authMethod: 'bearer',
})
c.set('authzContext', {
credential: 'agent_oauth',
userId: agentOAuth.userId,
orgId: agentOAuth.orgId,
fixedOrgId: agentOAuth.orgId,
grantedScopes: new Set(agentOAuth.scopes),
actor: { type: 'agent_oauth', ref: agentOAuth.grantId },
state: { clientId: agentOAuth.clientId },
})
c.set('userId', agentOAuth.userId)
c.set('userRole', null)
c.set('orgId', agentOAuth.orgId)
await next()
return
}
const bootstrap = await deps.downloaderBootstrapCredentials.resolve(platform, token, new Date())
if (bootstrap) {
c.set('userId', bootstrap.userId)
+18
View File
@@ -52,6 +52,15 @@ export type AuthPrincipal =
permissions: Record<string, string[]> | null
authMethod: 'api-key'
}
| {
kind: 'agent-oauth'
grantId: string
clientId: string
orgId: string
userId: string
scopes: readonly AuthorizationScope[]
authMethod: 'bearer'
}
| {
kind: 'downloader'
downloaderId: string
@@ -95,6 +104,15 @@ export type AuthzContext =
actor: { type: 'api_key'; ref: string }
state: { configId: string; enabled: true }
}
| {
credential: 'agent_oauth'
userId: string
orgId: string
fixedOrgId: string
grantedScopes: ReadonlySet<AuthorizationScope>
actor: { type: 'agent_oauth'; ref: string }
state: { clientId: string }
}
| {
credential: 'downloader'
userId: null
+56
View File
@@ -53,6 +53,62 @@ describe('global OpenAPI document', () => {
expect(html).toContain('/api/openapi.json')
})
it('publishes Agent OAuth security schemes and Restish profiles', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const res = await app.request('/api/openapi.json')
const doc = (await res.json()) as {
components?: { securitySchemes?: Record<string, unknown> }
'x-cli-config'?: { auth?: Record<string, { params?: { client_id?: string; redirect_path?: string } }> }
}
expect(doc.components?.securitySchemes?.agentOAuth2).toMatchObject({
type: 'oauth2',
flows: { authorizationCode: { authorizationUrl: '/api/auth/oauth2/authorize' } },
})
expect(doc.components?.securitySchemes?.agentApiKey).toMatchObject({ type: 'http', scheme: 'bearer' })
expect(doc['x-cli-config']?.auth?.reader?.params).toMatchObject({
client_id: 'zpan-agent',
redirect_path: '/callback',
})
})
it('publishes OAuth discovery and protected-resource metadata at root locations', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const [authServer, protectedResource] = await Promise.all([
app.request('/.well-known/oauth-authorization-server/api/auth'),
app.request('/.well-known/oauth-protected-resource/api'),
])
expect(authServer.status).toBe(200)
expect(await authServer.json()).toMatchObject({
issuer: 'http://localhost:3000/api/auth',
authorization_endpoint: 'http://localhost:3000/api/auth/oauth2/authorize',
token_endpoint: 'http://localhost:3000/api/auth/oauth2/token',
code_challenge_methods_supported: ['S256'],
})
expect(protectedResource.status).toBe(200)
expect(await protectedResource.json()).toMatchObject({
resource: 'http://localhost/api',
authorization_servers: ['http://localhost:3000/api/auth'],
scopes_supported: expect.arrayContaining([AuthorizationScope.OBJECTS_READ]),
})
const protectedHead = await app.request('/.well-known/oauth-protected-resource/api', { method: 'HEAD' })
expect(protectedHead.status).toBe(200)
})
it('serves HEAD for OAuth discovery and OpenID metadata endpoints', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const [authServer, openidConfig] = await Promise.all([
app.request('/.well-known/oauth-authorization-server/api/auth', { method: 'HEAD' }),
app.request('/.well-known/openid-configuration/api/auth', { method: 'HEAD' }),
])
expect(authServer.status).toBe(200)
expect(openidConfig.status).toBe(404)
expect(await authServer.text()).toBe('')
})
it('documents the workspace-scoped API-key event-stream authorization contract', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const res = await app.request('/api/openapi.json')
+79
View File
@@ -114,6 +114,85 @@ const AUTH_SCHEMA_SQL = `
CREATE INDEX IF NOT EXISTS deviceCode_device_code_idx ON deviceCode(device_code);
CREATE INDEX IF NOT EXISTS deviceCode_user_code_idx ON deviceCode(user_code);
CREATE INDEX IF NOT EXISTS deviceCode_status_idx ON deviceCode(status);
CREATE TABLE IF NOT EXISTS oauthClient (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL UNIQUE,
client_secret TEXT,
disabled INTEGER DEFAULT 0,
skip_consent INTEGER,
enable_end_session INTEGER,
subject_type TEXT,
scopes TEXT,
user_id TEXT REFERENCES user(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
name TEXT,
uri TEXT,
icon TEXT,
contacts TEXT,
tos TEXT,
policy TEXT,
software_id TEXT,
software_version TEXT,
software_statement TEXT,
redirect_uris TEXT NOT NULL,
post_logout_redirect_uris TEXT,
token_endpoint_auth_method TEXT,
grant_types TEXT,
response_types TEXT,
public INTEGER,
type TEXT,
require_pkce INTEGER,
reference_id TEXT,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS oauthClient_client_id_idx ON oauthClient(client_id);
CREATE INDEX IF NOT EXISTS oauthClient_user_id_idx ON oauthClient(user_id);
CREATE TABLE IF NOT EXISTS oauthRefreshToken (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
client_id TEXT NOT NULL REFERENCES oauthClient(client_id) ON DELETE CASCADE,
session_id TEXT REFERENCES session(id) ON DELETE SET NULL,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
reference_id TEXT,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
revoked INTEGER,
auth_time INTEGER,
scopes TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS oauthRefreshToken_client_id_idx ON oauthRefreshToken(client_id);
CREATE INDEX IF NOT EXISTS oauthRefreshToken_session_id_idx ON oauthRefreshToken(session_id);
CREATE INDEX IF NOT EXISTS oauthRefreshToken_user_id_idx ON oauthRefreshToken(user_id);
CREATE INDEX IF NOT EXISTS oauthRefreshToken_token_idx ON oauthRefreshToken(token);
CREATE TABLE IF NOT EXISTS oauthAccessToken (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
client_id TEXT NOT NULL REFERENCES oauthClient(client_id) ON DELETE CASCADE,
session_id TEXT REFERENCES session(id) ON DELETE SET NULL,
user_id TEXT REFERENCES user(id) ON DELETE CASCADE,
reference_id TEXT,
refresh_id TEXT REFERENCES oauthRefreshToken(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
scopes TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS oauthAccessToken_client_id_idx ON oauthAccessToken(client_id);
CREATE INDEX IF NOT EXISTS oauthAccessToken_session_id_idx ON oauthAccessToken(session_id);
CREATE INDEX IF NOT EXISTS oauthAccessToken_user_id_idx ON oauthAccessToken(user_id);
CREATE INDEX IF NOT EXISTS oauthAccessToken_refresh_id_idx ON oauthAccessToken(refresh_id);
CREATE INDEX IF NOT EXISTS oauthAccessToken_token_idx ON oauthAccessToken(token);
CREATE TABLE IF NOT EXISTS oauthConsent (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES oauthClient(client_id) ON DELETE CASCADE,
user_id TEXT REFERENCES user(id) ON DELETE CASCADE,
reference_id TEXT,
scopes TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS oauthConsent_client_id_idx ON oauthConsent(client_id);
CREATE INDEX IF NOT EXISTS oauthConsent_user_id_idx ON oauthConsent(user_id);
CREATE TABLE IF NOT EXISTS downloader_bootstrap_credentials (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest'
import { listAgentOAuthGrants, revokeAgentOAuthGrant } from './agent-oauth-grants'
import type { AgentOAuthGateway } from './ports'
const db = {} as never
function gateway(overrides: Partial<AgentOAuthGateway> = {}): AgentOAuthGateway {
return {
ensureSystemClient: vi.fn(),
assertLiveGrant: vi.fn(),
verifyAccessToken: vi.fn(),
listGrants: vi.fn(async () => []),
revokeGrant: vi.fn(async () => true),
...overrides,
}
}
describe('Agent OAuth grant usecases', () => {
it('lists grants through the gateway', async () => {
const agentOAuth = gateway({
listGrants: vi.fn(async () => [
{
id: 'grant-1',
clientId: 'zpan-agent',
userId: 'user-1',
orgId: 'org-1',
scopes: [],
createdAt: '2026-07-29T12:00:00.000Z',
updatedAt: '2026-07-29T12:00:00.000Z',
},
]),
})
await expect(listAgentOAuthGrants({ agentOAuth }, db, { userId: 'user-1' })).resolves.toEqual({
items: [
{
id: 'grant-1',
clientId: 'zpan-agent',
userId: 'user-1',
orgId: 'org-1',
scopes: [],
createdAt: '2026-07-29T12:00:00.000Z',
updatedAt: '2026-07-29T12:00:00.000Z',
},
],
})
})
it('throws not found when revoke does not remove a grant', async () => {
const agentOAuth = gateway({ revokeGrant: vi.fn(async () => false) })
await expect(
revokeAgentOAuthGrant({ agentOAuth }, db, { userId: 'user-1', grantId: 'missing' }),
).rejects.toMatchObject({
httpStatus: 404,
message: 'Agent OAuth grant not found',
})
})
})
+25
View File
@@ -0,0 +1,25 @@
import type { Database } from '../platform/interface'
import type { Deps } from './deps'
import type { AgentOAuthGrant } from './ports'
import { notFound } from './ports'
export async function listAgentOAuthGrants(
deps: Pick<Deps, 'agentOAuth'>,
db: Database,
input: { userId: string },
): Promise<{ items: AgentOAuthGrant[] }> {
return { items: await deps.agentOAuth.listGrants(db, input.userId) }
}
export async function revokeAgentOAuthGrant(
deps: Pick<Deps, 'agentOAuth'>,
db: Database,
input: { userId: string; grantId: string; now?: Date },
): Promise<void> {
const revoked = await deps.agentOAuth.revokeGrant(db, {
userId: input.userId,
grantId: input.grantId,
now: input.now ?? new Date(),
})
if (!revoked) throw notFound('Agent OAuth grant not found')
}
+2
View File
@@ -4,6 +4,7 @@
import type {
AdminStatsRepo,
AgentOAuthGateway,
AnnouncementRepo,
ApiKeyGateway,
ArchiveJobsGateway,
@@ -56,6 +57,7 @@ import type {
export interface Deps {
audit: AuditRepo
adminStats: AdminStatsRepo
agentOAuth: AgentOAuthGateway
announcements: AnnouncementRepo
apiKeys: ApiKeyGateway
archiveJobs: ArchiveJobsGateway
+1
View File
@@ -4,6 +4,7 @@
// 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'
+31
View File
@@ -0,0 +1,31 @@
import type { AuthorizationScope } from '@shared/authorization'
import type { Database } from '../../platform/interface'
export interface VerifiedAgentOAuthToken {
grantId: string
userId: string
orgId: string
clientId: string
scopes: AuthorizationScope[]
}
export interface AgentOAuthGrant {
id: string
clientId: string
userId: string
orgId: string
scopes: AuthorizationScope[]
createdAt: string
updatedAt: string
}
export interface AgentOAuthGateway {
ensureSystemClient(db: Database): Promise<void>
assertLiveGrant(
db: Database,
input: { userId: string; clientId: string; orgId?: string; scopes: readonly string[] },
): Promise<void>
verifyAccessToken(db: Database, token: string): Promise<VerifiedAgentOAuthToken | null>
listGrants(db: Database, userId: string): Promise<AgentOAuthGrant[]>
revokeGrant(db: Database, input: { userId: string; grantId: string; now: Date }): Promise<boolean>
}
+10
View File
@@ -0,0 +1,10 @@
import { AGENT_GRANTABLE_API_KEY_SCOPES } from './api-key-templates'
export const AGENT_OAUTH_CLIENT_ID = 'zpan-agent'
export const AGENT_OAUTH_CLIENT_NAME = 'ZPan Agent'
export const AGENT_OAUTH_ACCESS_TOKEN_SECONDS = 15 * 60
export const AGENT_OAUTH_REFRESH_TOKEN_SECONDS = 30 * 24 * 60 * 60
export const RESTISH_OAUTH_REDIRECT_URIS = ['http://localhost:8484/callback', 'http://127.0.0.1:8484/callback'] as const
export const AGENT_OAUTH_STANDARD_SCOPES = ['openid', 'profile', 'email', 'offline_access'] as const
export const AGENT_OAUTH_SCOPES = [...AGENT_OAUTH_STANDARD_SCOPES, ...AGENT_GRANTABLE_API_KEY_SCOPES] as const
+1 -1
View File
@@ -6,7 +6,7 @@ compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"]
[assets]
binding = "ASSETS"
not_found_handling = "single-page-application"
run_worker_first = ["/api/*", "/dav", "/dav/*", "/ih/*", "/r/*", "/s/*"]
run_worker_first = ["/api/*", "/.well-known/*", "/dav", "/dav/*", "/ih/*", "/r/*", "/s/*"]
[[d1_databases]]
binding = "DB"