mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
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:
committed by
GitHub
parent
f2aea1bedb
commit
d22227ed2f
+5826
-18
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+22
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user