feat(auth): add contextual OAuth workspace grants (#550)

* feat(auth): add contextual OAuth workspace grants

* fix(auth): register RFC 9396 detail types

* fix(openapi): restore delegated CLI auth profile

* fix(store): recover paid capacity fulfillment

* fix(auth): close OAuth contract gaps

* fix(store): resume verified x402 settlements

* chore(deps): update zpan cloud sdk to 2.5.2

* test(store): cover verified settlement recovery

* fix(auth): localize standard consent scopes

* fix(docker): include dependency patches before install

* fix(store): harden x402 purchase responses

* test(auth): cover OAuth authorization boundaries

* docs: add PR 550 verification screenshots

* chore: remove temporary verification screenshots

* docs(openapi): document exhausted capacity response
This commit is contained in:
Jasper Van
2026-08-02 13:33:15 -04:00
committed by GitHub
parent 1caa4e580c
commit 2657f82ef1
66 changed files with 9759 additions and 1041 deletions
+1
View File
@@ -7,6 +7,7 @@ RUN apt-get update \
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
corepack enable \
&& pnpm install --frozen-lockfile
File diff suppressed because it is too large Load Diff
+118 -180
View File
@@ -1,218 +1,156 @@
# External OAuth Apps — Design
# External OAuth Apps
> Status: Implemented
> Scope: dynamic OAuth clients, external resource authorization, DPoP, resource
> discovery, consent, revocation, and direct uploads
> Standards: OAuth 2.1, RFC 7591, RFC 8693, RFC 8707, RFC 9126, RFC 9396, RFC 9449
## Decision
## Model
ZPan is an OAuth protected resource and authorization server. An Agent platform
such as FlareAuth discovers ZPan from its public API URL, dynamically registers
itself, asks the user for delegated access, and exchanges the resulting subject
grant for a DPoP-bound ZPan resource token.
ZPan is both an OAuth authorization server and the protected resource for its
HTTP API. External Agent platforms dynamically register their own client, ask a
user for access, and exchange that user-approved subject grant for a short-lived
DPoP-bound token used by one Agent.
ZPan does not ship or require:
The authorization model has three independent dimensions:
- a fixed first-party OAuth client;
- Restish credential profiles in OpenAPI;
- a Restish upload plugin;
- a ZPan-specific Agent skill.
- `scope` says what an operation may do;
- RFC 9396 `authorization_details` says which workspace it may affect;
- current site and workspace roles apply resource-local constraints.
The integration contract is the public protocol surface: OAuth metadata,
OpenAPI, route authorization metadata, and structured API responses.
Authentication only establishes the credential and actor. It does not select a
permission model. Session cookies, API keys, OAuth tokens, and specialized
service credentials all enter the same route policy evaluator.
## Discovery
Given the exact resource URL `https://zpan.example/api`, a client can discover:
Starting from `https://zpan.example/api`, clients discover:
| Contract | Path |
|---|---|
| API, OpenAPI, and workflow discovery links | `/api` |
| API discovery | `/api` |
| OpenAPI | `/api/openapi.json` |
| Arazzo workflows | `/api/workflows.arazzo.json` |
| Upload workflows | `/api/workflows.arazzo.json` |
| Protected resource metadata | `/.well-known/oauth-protected-resource/api` |
| Authorization server metadata | `/.well-known/oauth-authorization-server/api/auth` |
| Dynamic client registration | `/api/auth/oauth2/register` |
| Pushed authorization requests | `/api/auth/oauth2/par` |
Protected-resource metadata identifies the exact `/api` audience and the
authorization server. Authorization-server metadata advertises authorization
code, refresh token, JWT bearer, token exchange, dynamic registration, and
DPoP capabilities.
Authorization-server metadata advertises `scopes_supported`,
`authorization_details_types_supported`, and
`pushed_authorization_request_endpoint`. ZPan does not maintain a second scope
catalog endpoint. RFC 7591 clients can register `authorization_details_types`;
ZPan persists and echoes supported values and rejects unknown types as invalid
client metadata.
OpenAPI remains tool-neutral. It contains no `x-cli-config`, built-in client ID,
credential environment variable, or executable helper. Agent-callable
operations publish their exact runtime requirements through `x-zpan-auth`.
`GET /api/oauth-resource-scopes` is a public scope catalog whose OpenAPI
operation carries the standard OAuth scope declaration used by external
resource registries. Keeping the business operations themselves unbound avoids
selecting a built-in Restish OAuth profile before a delegated-credential hook
can provide the resource token. Browser and administration operations retain
their normal cookie/bearer declarations.
OpenAPI uses standard `security` declarations. Every protected ZPan operation
declares its OAuth scopes, plus cookie and bearer alternatives. Role constraints
that OpenAPI cannot express use the narrow
`x-zpan-authorization-constraints` extension. Better Auth operations and their
generated OpenAPI definitions remain owned by Better Auth and are not rewritten
by ZPan.
The API resource response publishes OpenAPI through an RFC 8631 `service-desc`
link and its Arazzo 1.1 description through a typed `describedby` link. The
OpenAPI document also links the Arazzo document through `externalDocs`. A
controller can therefore discover both contracts from the exact resource URL
without assuming a ZPan-specific path.
## Workspace Authorization Details
The Arazzo document defines separate prepare, re-presign, complete, and abort
workflows backed by stable OpenAPI operation IDs. Preparing an upload returns
the runtime descriptor for the direct storage transfer. This split is
intentional: an Arazzo operation target comes from its source OpenAPI server,
while a presigned storage URL is an arbitrary absolute URL generated at
runtime. The controller executes those PUT requests from the returned
descriptor, then supplies their ETags to the completion workflow.
ZPan defines this RFC 9396 authorization detail type:
## Dynamic Registration and Administration
The OAuth provider accepts RFC 7591-style dynamic client registration with PKCE.
Each controller registers its own:
- client name and URI;
- callback URI;
- grant and response types;
- token endpoint authentication method;
- JWKS or JWKS URI when JWT bearer exchange is used;
- requested scopes.
The server assigns the client ID. No client identity or callback is hard-coded
in ZPan.
Administrators can inspect dynamically registered applications in the existing
authentication settings. This first version does not add application approval:
registration is immediately usable, but user consent is still mandatory before
workspace access is granted. System/reference clients are not presented as
external registered applications.
## Consent and Workspace Binding
Authorization code + PKCE creates a user-controlled subject grant. The consent
page resolves the registered client record and displays its real name, callback,
requested scopes, ZPan instance, selected workspace, and grant lifetime.
Each consent is bound to:
- the signed-in user;
- the dynamically registered client;
- exactly one workspace;
- the approved ZPan resource scopes.
The request cannot replace that workspace with a query or body field. Team
membership and role checks still apply. Revoking a consent removes its access
tokens, revokes its refresh tokens, and deletes the consent. The OAuth Apps
page lists the real client name and workspace for every current-user grant.
## External Resource Token Flow
FlareAuth-style controllers use three credentials with separate purposes:
1. A user-approved ZPan subject token represents the connected account.
2. A JWT bearer assertion identifies the Agent/controller actor and mints a
short-lived actor token.
3. OAuth token exchange combines subject and actor tokens for the exact ZPan
`/api` audience and requested scopes.
The exchanged access token is a JWT containing the user, workspace,
`zpan_actor`, delegated actor (`act`), audience, scopes, client ID, expiry, and
JTI. API requests use `Authorization: DPoP` plus a proof bound to the method,
URL, access token, and DPoP key. ZPan verifies issuer, audience, signature,
expiry, scopes, DPoP proof, and JTI revocation.
Revoking an exchanged JWT stores its JTI until token expiry. The resource API
rejects revoked tokens. Opaque-token compatibility and fixed-client grant
assertions are intentionally not part of this path.
## Scope Model
Resource scopes use stable `<resource>:<action>` names. The external OAuth scope
catalog includes:
| Scope | Authority |
|---|---|
| `objects:read` | List, inspect, and download objects |
| `objects:create` | Create folders and direct-upload sessions |
| `objects:update` | Rename, move, and copy objects |
| `objects:delete` | Soft-delete objects |
| `shares:read` | Inspect shares |
| `shares:create` | Create public shares |
| `shares:delete` | Revoke shares |
| `quota:read` | Inspect workspace quota |
| `quota:purchase` | Purchase storage capacity for the bound workspace through x402 |
| `storage-usage:read` | Inspect workspace storage usage |
| `tasks:read` | Inspect task state |
Administrative, general billing management, credential-management, WebDAV,
downloader bootstrap, and purge authority are not grantable through this
catalog. `quota:purchase` is the narrow exception for autonomous x402 capacity
purchases and cannot manage subscriptions or other billing resources.
OAuth is a credential adapter, not a business-logic fork. Middleware resolves a
protocol-neutral principal, bound workspace, scope set, and audit actor before
calling the same file use cases used by other authenticated clients.
Upgrades that add OAuth resource scopes do not mutate OAuth tables during authentication
startup. Before deploying such an upgrade, operators run the idempotent scope
backfill in dry-run mode and then apply it:
```sh
pnpm oauth-scopes:backfill -- --d1 zpan-db --remote
pnpm oauth-scopes:backfill -- --d1 zpan-db --remote --apply
```json
{
"type": "https://zpan.space/authorization-details/workspace",
"identifier": "workspace-id"
}
```
For Node/SQLite deployments, replace the D1 arguments with
`--sqlite <database-path>`.
An authorization request may omit `identifier` to ask the user to choose. The
standalone `/oauth/consent` page lists only workspaces visible to the signed-in
user and can approve one or more of them. A request containing an identifier is
restricted to that workspace and cannot be widened by the consent submission.
The consent page does not change the browser session's active workspace.
## Self-Describing Direct Upload
The granted array is persisted on the consent, authorization code, access
token, and refresh-token family. It is returned in token responses and appears
as the top-level `authorization_details` claim in JWT access tokens. Refresh
rotation preserves it.
File bytes continue to bypass ZPan and go directly to S3-compatible storage.
The create-object response is the upload workflow contract; an Agent does not
need a plugin or skill to infer hidden follow-up steps.
One connected-account subject token may contain multiple approved workspaces.
Each RFC 8693 token-exchange request must select exactly one approved workspace;
the resulting Agent token therefore always has exactly one workspace detail.
The resource middleware rejects a token with zero or multiple workspace
details.
The response includes:
## Pushed Authorization Requests
- upload ID and object draft;
- ordered part descriptors with part number, byte offset, byte length, method,
presigned URL, and required headers;
- a `workflow` object describing the upload request;
- the exact complete, re-presign, and abort operation IDs, methods, and paths;
- instructions to preserve each upload response ETag and submit
`{ partNumber, etag }` to completion.
Clients should send authorization parameters to `/api/auth/oauth2/par` and then
open `/api/auth/oauth2/authorize` with only `client_id` and the returned
`request_uri`. ZPan authenticates the client according to its registration,
stores the request for 90 seconds, strips client credentials from the stored
parameters, and consumes the request URI on first use.
An Agent follows this generic sequence:
PAR keeps workspace requests, scopes, redirect URI, PKCE challenge, and state
bound to one server-side request while avoiding disclosure in front-channel
URLs. Direct authorization parameters remain supported for compatible clients.
1. Call `createObject` with file name, size, type, and workspace context.
2. Split the local file according to each returned `offset` and `length`.
3. `PUT` each byte range to its returned presigned URL and retain the response
ETag.
4. If a URL expires, call the returned re-presign operation for only the
unfinished part numbers.
5. Call the returned complete operation with all part numbers and ETags.
6. On an intentional cancellation, call the returned abort operation.
## Consent and Revocation
Presigned URLs are bearer capabilities with short lifetimes. They must not be
logged, cached in checkpoints, or sent through the controller. Completion and
re-presigning re-enter ZPan authorization and workspace checks.
Consent is keyed by signed-in user and registered client. It contains the
approved scopes and workspace details. Re-consent updates that single grant.
The OAuth Apps settings page lists all approved workspaces and scopes.
## Compatibility Boundary
Revoking a grant deletes its access tokens, revokes its live refresh-token
families, and deletes the consent. Revoking an exchanged JWT records its JTI
until expiry so the resource server rejects it immediately.
The legacy `zpan-cli` device flow remains limited to downloader registration.
API keys remain available for image hosting, WebDAV, and remote-download access.
They are separate credentials and are not used by OAuth applications.
## Agent Token Exchange
Future client-registration approval can be added around dynamically registered
client records without changing resource discovery, consent, token exchange,
OpenAPI, upload responses, or file use cases.
The external-resource flow uses three credentials:
1. The user-approved subject token represents the connected account and may
contain multiple workspaces.
2. A signed JWT bearer assertion mints a short-lived actor token identifying the
Agent platform actor.
3. RFC 8693 token exchange combines both credentials, the requested scopes, the
exact `/api` resource, and exactly one workspace authorization detail.
The exchanged JWT contains standard issuer, subject, audience, client, scope,
expiry, JTI, actor (`act`), confirmation (`cnf`), and
`authorization_details` claims. API requests use the DPoP authorization scheme
and a proof bound to the request method, URL, and access token.
## Scope and Role Enforcement
All ZPan resource routes use canonical `<resource>:<action>` scopes. The OAuth
server advertises every grantable canonical scope. Permanent object purge stays
outside user-delegated OAuth grants; it remains a separately constrained
operation.
Possessing a scope does not bypass role checks. For example, a token with a
write scope still needs editor access to the selected workspace, and a token
calling a site-admin operation must belong to a current site administrator.
Roles are read at request time so membership or administrator changes take
effect without waiting for token expiry.
## Direct Upload
File bytes continue to travel directly to S3-compatible storage through
presigned URLs. An Agent creates an object draft, follows the returned upload
descriptor, retains part ETags, and completes the upload. If quota is
insufficient, the normal create-object response is an x402 payment challenge;
after capacity purchase the Agent repeats the same request and continues the
upload.
The upload workflow is described by stable OpenAPI operation IDs and the Arazzo
document. Presigned storage URLs are short-lived bearer capabilities and must
not be logged or persisted in connection state.
## Acceptance
The integration is complete when a generic FlareAuth/Restish controller can:
The integration is complete when an external Agent controller can:
1. discover ZPan from `/api`;
2. dynamically register and appear in administrator settings;
3. create a user-visible authorization request;
4. obtain a DPoP resource token after consent;
5. discover file operations from OpenAPI and upload workflows from Arazzo;
6. upload bytes and complete the upload using the Arazzo and returned runtime
workflow data;
7. list, read, and rename the resulting object;
8. lose access after grant or JWT revocation.
1. discover the protected resource, authorization server, scopes, and PAR;
2. dynamically register a client;
3. push a rich authorization request and display standalone consent;
4. receive and refresh a multi-workspace subject grant;
5. exchange it for a one-workspace DPoP Agent token;
6. discover and call any permitted API operation through standard OpenAPI
security declarations;
7. handle quota exhaustion, x402 payment, upload completion, and download URL
retrieval;
8. lose access after consent, token, membership, or role revocation.
+30 -19
View File
@@ -1,8 +1,13 @@
import { expect, test } from '@playwright/test'
import { signUpAndGoToFiles } from './helpers'
const oauthQuery =
'client_id=dynamic-client&redirect_uri=https%3A%2F%2Fbroker.example.com%2Fcallback&response_type=code&scope=openid%20offline_access%20objects%3Aread%20shares%3Acreate%20quota%3Aread'
const oauthQuery = new URLSearchParams({
client_id: 'dynamic-client',
redirect_uri: 'https://broker.example.com/callback',
response_type: 'code',
scope: 'openid offline_access objects:read shares:create quota:read',
authorization_details: JSON.stringify([{ type: 'https://zpan.space/authorization-details/workspace' }]),
}).toString()
test.describe('OAuth Apps UI', () => {
test('renders consent details and submits full approval @desktop', async ({ page }) => {
@@ -14,8 +19,9 @@ test.describe('OAuth Apps UI', () => {
body: JSON.stringify({
clientId: 'dynamic-client',
clientName: 'FlareAuth',
instanceOrigin: 'http://localhost:5185',
workspace: { id: 'org-e2e', name: 'Personal' },
clientOrigin: 'https://broker.example.com',
workspaces: [{ id: 'org-e2e', name: 'Personal' }],
requestedWorkspaceIds: [],
scopes: ['objects:read', 'shares:create', 'quota:read'],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'https://broker.example.com/callback',
@@ -26,8 +32,8 @@ test.describe('OAuth Apps UI', () => {
await page.route('**/api/oauth-consent', async (route) => {
if (route.request().method() !== 'POST') return route.fallback()
expect(route.request().method()).toBe('POST')
const body = route.request().postDataJSON() as { accept: boolean; oauthQuery?: string; scope?: string }
expect(body).toEqual({ accept: true, oauthQuery })
const body = route.request().postDataJSON() as { accept: boolean; oauthQuery?: string; workspaceIds?: string[] }
expect(body).toEqual({ accept: true, oauthQuery, workspaceIds: ['org-e2e'] })
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ url: 'https://broker.example.com/callback?code=e2e-code' }),
@@ -37,16 +43,21 @@ test.describe('OAuth Apps UI', () => {
await route.fulfill({ contentType: 'text/html', body: '<main>Returned to FlareAuth</main>' })
})
await page.goto(`/settings/oauth-apps?${oauthQuery}`)
await page.goto(`/oauth/consent?${oauthQuery}`)
await expect(page.getByRole('heading', { name: 'Authorize Application' })).toBeVisible()
await expect(page.getByText('http://localhost:5185')).toBeVisible()
await expect(page.getByText('https://broker.example.com/callback')).toBeVisible()
await expect(page.getByRole('heading', { name: 'FlareAuth wants to access your workspaces' })).toBeVisible()
await expect(page.getByText('https://broker.example.com', { exact: true })).toBeVisible()
await expect(page.getByText('Personal')).toBeVisible()
await expect(page.getByText('Files: read objects')).toBeVisible()
await expect(page.getByText('Shares: create shares')).toBeVisible()
await expect(page.getByText('Quota: read workspace quota')).toBeVisible()
await expect(page.getByRole('navigation')).toHaveCount(0)
await page.getByRole('button', { name: 'Approve Access' }).click()
const approve = page.getByRole('button', { name: 'Approve Access' })
await expect(approve).toBeDisabled()
await page.getByRole('checkbox', { name: 'Personal' }).check()
await expect(approve).toBeEnabled()
await approve.click()
await expect(page).toHaveURL(/broker\.example\.com\/callback\?code=e2e-code/, { timeout: 10000 })
await expect(page.getByText('Returned to FlareAuth')).toBeVisible()
})
@@ -68,8 +79,7 @@ test.describe('OAuth Apps UI', () => {
clientId: 'dynamic-client',
clientName: 'FlareAuth',
userId: 'user-e2e',
orgId: 'org-e2e',
workspaceName: 'Personal',
workspaces: [{ id: 'org-e2e', name: 'Personal' }],
scopes: ['objects:read', 'shares:create'],
createdAt: '2026-07-29T12:00:00.000Z',
lastUsedAt: null,
@@ -107,8 +117,9 @@ test.describe('OAuth Apps UI', () => {
body: JSON.stringify({
clientId: 'dynamic-client',
clientName: 'FlareAuth',
instanceOrigin: 'http://localhost:5185',
workspace: { id: 'org-e2e', name: 'Personal' },
clientOrigin: 'https://broker.example.com',
workspaces: [{ id: 'org-e2e', name: 'Personal' }],
requestedWorkspaceIds: [],
scopes: ['objects:read', 'shares:create', 'quota:read'],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'https://broker.example.com/callback',
@@ -127,8 +138,7 @@ test.describe('OAuth Apps UI', () => {
clientId: 'dynamic-client',
clientName: 'FlareAuth',
userId: 'user-e2e',
orgId: 'org-e2e',
workspaceName: 'Personal',
workspaces: [{ id: 'org-e2e', name: 'Personal' }],
scopes: ['objects:read', 'shares:create', 'quota:read'],
createdAt: '2026-07-29T12:00:00.000Z',
lastUsedAt: '2026-07-29T12:30:00.000Z',
@@ -139,11 +149,12 @@ test.describe('OAuth Apps UI', () => {
})
})
await page.goto(`/settings/oauth-apps?${oauthQuery}`)
await expect(page.getByRole('heading', { name: 'Authorize Application' })).toBeVisible()
await page.goto(`/oauth/consent?${oauthQuery}`)
await expect(page.getByRole('heading', { name: 'FlareAuth wants to access your workspaces' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Approve Access' })).toBeVisible()
await expect(page.getByText('Files: read objects')).toBeVisible()
await expect(page.getByText('Shares: create shares')).toBeVisible()
await page.getByRole('checkbox', { name: 'Personal' }).check()
await expect
.poll(async () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1))
.toBe(true)
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE `oauthPushedAuthorizationRequest` (
`id` text PRIMARY KEY NOT NULL,
`request_uri` text NOT NULL,
`client_id` text NOT NULL,
`parameters` text NOT NULL,
`expires_at` integer NOT NULL,
`created_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
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauthPushedAuthorizationRequest_request_uri_unique` ON `oauthPushedAuthorizationRequest` (`request_uri`);--> statement-breakpoint
CREATE INDEX `oauthPushedAuthorizationRequest_client_id_idx` ON `oauthPushedAuthorizationRequest` (`client_id`);--> statement-breakpoint
CREATE INDEX `oauthPushedAuthorizationRequest_expires_at_idx` ON `oauthPushedAuthorizationRequest` (`expires_at`);--> statement-breakpoint
ALTER TABLE `oauthAccessToken` ADD `authorization_details` text;--> statement-breakpoint
ALTER TABLE `oauthConsent` ADD `authorization_details` text;--> statement-breakpoint
ALTER TABLE `oauthRefreshToken` ADD `authorization_details` text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -624,6 +624,13 @@
"when": 1785506906336,
"tag": "0089_better-auth-account-issuer-backfill",
"breakpoints": true
},
{
"idx": 90,
"version": "6",
"when": 1785649535282,
"tag": "0090_oauth-rar-par",
"breakpoints": true
}
]
}
+5 -2
View File
@@ -109,7 +109,7 @@
"tailwind-merge": "^3.5.0",
"yet-another-react-lightbox": "^3.30.1",
"zod": "^4.4.3",
"zpan-cloud-sdk": "^2.5.1"
"zpan-cloud-sdk": "^2.5.2"
},
"devDependencies": {
"@biomejs/biome": "^2.4.10",
@@ -192,7 +192,10 @@
"esbuild",
"sharp",
"workerd"
]
],
"patchedDependencies": {
"@better-auth/oauth-provider@1.7.0-rc.2": "patches/@better-auth__oauth-provider@1.7.0-rc.2.patch"
}
},
"volta": {
"node": "24.14.1",
@@ -0,0 +1,508 @@
diff --git a/dist/index.d.mts b/dist/index.d.mts
index 74c48338addc218100218739af9f51bbf59db2f2..2d9c07f812efc04019f5512bb0feaf6d006fa495 100644
--- a/dist/index.d.mts
+++ b/dist/index.d.mts
@@ -1,4 +1,4 @@
-import { A as OAuthProviderExtension, B as StoreTokenType, C as OAuthConsent, D as OAuthOpaqueAccessToken, E as OAuthMetadataExtensionInput, F as OAuthTokenResponse, H as VerificationValue, I as OAuthUserInfoExtensionInput, L as Prompt, M as OAuthResource, N as OAuthResourceInput, O as OAuthOptions, P as OAuthTokenIssueParams, R as SchemaClient, S as OAuthClientResource, T as OAuthExtensionGrantHandlerInput, U as ClientRegistrationRequest, V as StoredAuthorizationQuery, W as ResourceUriSchema, _ as OAuthClaimExtensionInput, a as GrantType, b as OAuthClientAuthenticationResult, c as ResourceServerMetadata, d as ActiveAccessTokenPayload, f as AuthorizePrompt, g as OAuthAuthorizationQuery, h as OAuthAuthenticatedClient, i as Confirmation, j as OAuthRefreshToken, k as OAuthProviderApi, l as TokenEndpointAuthMethod, m as InitialAccessTokenAuthorization, n as AuthServerMetadata, o as OAuthClient, p as ClientDiscovery, r as BearerMethodsSupported, s as OIDCMetadata, t as AuthMethod, u as TokenType, v as OAuthClientAuthenticationInput, w as OAuthExtensionGrantHandler, x as OAuthClientAuthenticationStrategy, y as OAuthClientAuthenticationRequest, z as Scope } from "./oauth-CSzjH7fO.mjs";
+import { A as OAuthProviderExtension, B as StoreTokenType, C as OAuthConsent, D as OAuthOpaqueAccessToken, E as OAuthMetadataExtensionInput, F as OAuthTokenResponse, H as VerificationValue, I as OAuthUserInfoExtensionInput, L as Prompt, M as OAuthResource, N as OAuthResourceInput, O as OAuthOptions, P as OAuthTokenIssueParams, Q as AuthorizationDetail, R as SchemaClient, S as OAuthClientResource, T as OAuthExtensionGrantHandlerInput, U as ClientRegistrationRequest, V as StoredAuthorizationQuery, W as ResourceUriSchema, _ as OAuthClaimExtensionInput, a as GrantType, b as OAuthClientAuthenticationResult, c as ResourceServerMetadata, d as ActiveAccessTokenPayload, f as AuthorizePrompt, g as OAuthAuthorizationQuery, h as OAuthAuthenticatedClient, i as Confirmation, j as OAuthRefreshToken, k as OAuthProviderApi, l as TokenEndpointAuthMethod, m as InitialAccessTokenAuthorization, n as AuthServerMetadata, o as OAuthClient, p as ClientDiscovery, r as BearerMethodsSupported, s as OIDCMetadata, t as AuthMethod, u as TokenType, v as OAuthClientAuthenticationInput, w as OAuthExtensionGrantHandler, x as OAuthClientAuthenticationStrategy, y as OAuthClientAuthenticationRequest, z as Scope } from "./oauth-CSzjH7fO.mjs";
import { a as OAuthEndpointErrorResult, c as OAuthFieldErrorCode, i as getIssuer, l as OAuthFieldErrorCodeMap, n as getOAuthProviderState, o as OAuthEndpointRedirectContext, r as oauthProvider, s as OAuthErrorCode, t as DEFAULT_OAUTH_SCOPES, u as OAuthRedirectOnError } from "./oauth-DdSU0qF6.mjs";
import { getSessionFromCtx } from "better-auth/api";
import { JWSAlgorithms, JwtOptions } from "better-auth/plugins";
@@ -195,4 +195,4 @@ declare function consumeClientAssertion(ctx: GenericEndpointContext, opts: OAuth
expectedAudience: string;
}): Promise<void>;
//#endregion
-export { type ActiveAccessTokenPayload, type AuthMethod, type AuthServerMetadata, type AuthorizePrompt, type BearerMethodsSupported, type ClientDiscovery, type ClientRegistrationRequest, type Confirmation, DEFAULT_OAUTH_SCOPES, type GrantType, type InitialAccessTokenAuthorization, type OAuthAuthenticatedClient, type OAuthAuthorizationQuery, type OAuthClaimExtensionInput, type OAuthClient, type OAuthClientAuthenticationInput, type OAuthClientAuthenticationRequest, type OAuthClientAuthenticationResult, type OAuthClientAuthenticationStrategy, type OAuthClientResource, type OAuthConsent, type OAuthEndpointErrorResult, type OAuthEndpointRedirectContext, type OAuthErrorCode, type OAuthExtensionGrantHandler, type OAuthExtensionGrantHandlerInput, type OAuthFieldErrorCode, type OAuthFieldErrorCodeMap, type OAuthMetadataExtensionInput, type OAuthOpaqueAccessToken, type OAuthOptions, type OAuthProviderApi, type OAuthProviderExtension, type OAuthRedirectOnError, type OAuthRefreshToken, type OAuthResource, type OAuthResourceInput, type OAuthTokenIssueParams, type OAuthTokenResponse, type OAuthUserInfoExtensionInput, type OIDCMetadata, type Prompt, type ResourceServerMetadata, ResourceUriSchema, type SchemaClient, type Scope, type StoreTokenType, type StoredAuthorizationQuery, type TokenEndpointAuthMethod, type TokenType, type VerificationValue, authServerMetadata, checkOAuthClient, consumeClientAssertion, extendOAuthProvider, getIssuer, getOAuthProviderApi, getOAuthProviderState, metadataResponse, oauthAuthorizationServerMetadata, oauthProvider, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oauthToSchema, oidcServerMetadata, raiseResourceServerChallenge };
\ No newline at end of file
+export { type ActiveAccessTokenPayload, type AuthMethod, type AuthServerMetadata, type AuthorizationDetail, type AuthorizePrompt, type BearerMethodsSupported, type ClientDiscovery, type ClientRegistrationRequest, type Confirmation, DEFAULT_OAUTH_SCOPES, type GrantType, type InitialAccessTokenAuthorization, type OAuthAuthenticatedClient, type OAuthAuthorizationQuery, type OAuthClaimExtensionInput, type OAuthClient, type OAuthClientAuthenticationInput, type OAuthClientAuthenticationRequest, type OAuthClientAuthenticationResult, type OAuthClientAuthenticationStrategy, type OAuthClientResource, type OAuthConsent, type OAuthEndpointErrorResult, type OAuthEndpointRedirectContext, type OAuthErrorCode, type OAuthExtensionGrantHandler, type OAuthExtensionGrantHandlerInput, type OAuthFieldErrorCode, type OAuthFieldErrorCodeMap, type OAuthMetadataExtensionInput, type OAuthOpaqueAccessToken, type OAuthOptions, type OAuthProviderApi, type OAuthProviderExtension, type OAuthRedirectOnError, type OAuthRefreshToken, type OAuthResource, type OAuthResourceInput, type OAuthTokenIssueParams, type OAuthTokenResponse, type OAuthUserInfoExtensionInput, type OIDCMetadata, type Prompt, type ResourceServerMetadata, ResourceUriSchema, type SchemaClient, type Scope, type StoreTokenType, type StoredAuthorizationQuery, type TokenEndpointAuthMethod, type TokenType, type VerificationValue, authServerMetadata, checkOAuthClient, consumeClientAssertion, extendOAuthProvider, getIssuer, getOAuthProviderApi, getOAuthProviderState, metadataResponse, oauthAuthorizationServerMetadata, oauthProvider, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oauthToSchema, oidcServerMetadata, raiseResourceServerChallenge };
diff --git a/dist/index.mjs b/dist/index.mjs
index 3896e50f99997176d2f99c934d52701cf076d11b..e26efc84ae50f385d3bb7de89d5b5b059d9f21e8 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -166,6 +166,44 @@ const STANDARD_CLAIM_NAMES = Object.keys(STANDARD_CLAIMS);
function getSupportedClaims(opts) {
return opts.advertisedMetadata?.claims_supported ?? opts.claims ?? [];
}
+function parseAuthorizationDetails(value) {
+ if (value == null || value === "") return [];
+ let parsed = value;
+ if (typeof value === "string") try {
+ parsed = JSON.parse(value);
+ } catch {
+ throw new APIError("BAD_REQUEST", {
+ error_description: "authorization_details must be valid JSON",
+ error: "invalid_authorization_details"
+ });
+ }
+ if (!Array.isArray(parsed) || parsed.some((detail) => !detail || typeof detail !== "object" || Array.isArray(detail) || typeof detail.type !== "string" || !detail.type)) throw new APIError("BAD_REQUEST", {
+ error_description: "authorization_details must be an array of objects with a type",
+ error: "invalid_authorization_details"
+ });
+ return parsed;
+}
+async function validateAuthorizationDetails(ctx, opts, details, phase, extra = {}) {
+ const config = opts.authorizationDetails;
+ if (!details.length && !config) return details;
+ if (!config) throw new APIError("BAD_REQUEST", {
+ error_description: "authorization_details is not supported",
+ error: "invalid_authorization_details"
+ });
+ const supported = new Set(config.typesSupported);
+ if (details.some((detail) => !supported.has(detail.type))) throw new APIError("BAD_REQUEST", {
+ error_description: "authorization_details contains an unsupported type",
+ error: "invalid_authorization_details"
+ });
+ const validated = config.validate ? await config.validate({ ctx, details, phase, ...extra }) : details;
+ return parseAuthorizationDetails(validated);
+}
+function authorizationDetailsCovered(opts, requested, granted) {
+ if (!requested.length) return true;
+ if (opts.authorizationDetails?.isSubset) return opts.authorizationDetails.isSubset({ requested, granted });
+ const grantedJson = new Set(granted.map((detail) => JSON.stringify(detail)));
+ return requested.every((detail) => grantedJson.has(JSON.stringify(detail)));
+}
//#endregion
//#region src/consent.ts
async function consentEndpoint(ctx, opts, authorize) {
@@ -184,6 +222,7 @@ async function consentEndpoint(ctx, opts, authorize) {
error_description: "client_id is required",
error: "invalid_client"
});
+ const requestedAuthorizationDetails = await validateAuthorizationDetails(ctx, opts, parseAuthorizationDetails(query.get("authorization_details")), "request", { clientId });
const requestedScopes = ctx.body.scope?.split(" ");
if (requestedScopes) {
if (!requestedScopes.every((sc) => originalRequestedScopes?.includes(sc))) throw new APIError("BAD_REQUEST", {
@@ -209,6 +248,13 @@ async function consentEndpoint(ctx, opts, authorize) {
ctx.query = searchParamsToQuery(query);
return await authorize(ctx);
}
+ const acceptedAuthorizationDetails = ctx.body.authorization_details === void 0 ? requestedAuthorizationDetails : parseAuthorizationDetails(ctx.body.authorization_details);
+ const authorizationDetails = await validateAuthorizationDetails(ctx, opts, acceptedAuthorizationDetails, "consent", {
+ clientId,
+ requested: requestedAuthorizationDetails,
+ user: session?.user,
+ session: session?.session
+ });
const referenceId = await opts.postLogin?.consentReferenceId?.({
user: session?.user,
session: session?.session,
@@ -241,6 +287,7 @@ async function consentEndpoint(ctx, opts, authorize) {
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
updatedAt: /* @__PURE__ */ new Date(iat * 1e3),
resources: resource.length ? resource : void 0,
+ authorizationDetails: authorizationDetails.length ? authorizationDetails : void 0,
referenceId
};
foundConsent?.id ? await ctx.context.adapter.update({
@@ -251,6 +298,7 @@ async function consentEndpoint(ctx, opts, authorize) {
}],
update: {
resources: consent.resources,
+ authorizationDetails: consent.authorizationDetails,
scopes: consent.scopes,
requestedUserInfoClaims: consent.requestedUserInfoClaims,
updatedAt: /* @__PURE__ */ new Date(iat * 1e3)
@@ -262,6 +310,8 @@ async function consentEndpoint(ctx, opts, authorize) {
scopes: consent.scopes
}
});
+ if (authorizationDetails.length) query.set("authorization_details", JSON.stringify(authorizationDetails));
+ else query.delete("authorization_details");
if (requestedScopes) query.set("scope", consent.scopes.join(" "));
if (acceptedClaims !== void 0) {
const claimsRequest = filterClaimsRequestUserInfoClaims(query.get("claims"), acceptedUserInfoClaims);
@@ -344,6 +394,7 @@ const RESERVED_ACCESS_TOKEN_CLAIMS = /* @__PURE__ */ new Set([
"jti",
"client_id",
"scope",
+ "authorization_details",
"auth_time",
"acr",
"amr",
@@ -1183,6 +1234,7 @@ const authorizationQuerySchema = z.object({
code_challenge_method: z.string().pipe(z.enum(["S256"])).optional(),
nonce: z.string().optional(),
claims: claimsRequestParameterSchema.optional(),
+ authorization_details: z.string().optional(),
dpop_jkt: dpopJktSchema.optional(),
resource: z.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]).optional()
}).passthrough();
@@ -1241,6 +1293,7 @@ const clientRegistrationRequestSchema = z.object({
]).optional(),
subject_type: z.enum(["public", "pairwise"]).optional(),
dpop_bound_access_tokens: z.boolean().optional(),
+ authorization_details_types: z.array(z.string().trim().min(1)).min(1).optional(),
resources: z.array(ResourceUriSchema).optional(),
skip_consent: z.never({ error: "skip_consent cannot be set during dynamic client registration" }).optional()
});
@@ -1478,6 +1531,7 @@ async function createJwtAccessToken(ctx, opts, user, client, audienceClaim, scop
signingAlgorithm: overrides?.signingAlgorithm ?? void 0,
payload: {
...overrides?.accessTokenClaims ?? {},
+ ...overrides?.authorizationDetails?.length ? { authorization_details: overrides.authorizationDetails } : {},
sub: subject,
aud: toAudienceClaim(audienceClaim),
client_id: client.clientId,
@@ -1574,7 +1628,7 @@ async function decodeRefreshToken(opts, token) {
});
return opts.formatRefreshToken?.decrypt ? opts.formatRefreshToken?.decrypt(token) : { token };
}
-async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload, resources, referenceId, authorizationCodeId, refreshId, confirmation, requestedUserInfoClaims) {
+async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload, resources, referenceId, authorizationCodeId, refreshId, confirmation, requestedUserInfoClaims, authorizationDetails) {
const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
const exp = payload?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
const token = opts.generateOpaqueAccessToken ? await opts.generateOpaqueAccessToken() : generateRandomString(32, "A-Z", "a-z");
@@ -1591,6 +1645,7 @@ async function createOpaqueAccessToken(ctx, opts, user, client, scopes, payload,
refreshId,
confirmation,
requestedUserInfoClaims: requestedUserInfoClaims?.length ? requestedUserInfoClaims : void 0,
+ authorizationDetails: authorizationDetails?.length ? authorizationDetails : void 0,
scopes,
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
expiresAt: /* @__PURE__ */ new Date(exp * 1e3)
@@ -1662,7 +1717,7 @@ async function revokeTokensIssuedForAuthorizationCode(ctx, authorizationCodeId)
await deleteIssuedTokens("oauthAccessToken");
await deleteIssuedTokens("oauthRefreshToken");
}
-async function createRefreshToken(ctx, opts, user, referenceId, authorizationCodeId, client, scopes, payload, originalRefresh, authTime, resources, confirmation, requestedUserInfoClaims) {
+async function createRefreshToken(ctx, opts, user, referenceId, authorizationCodeId, client, scopes, payload, originalRefresh, authTime, resources, confirmation, requestedUserInfoClaims, authorizationDetails) {
const iat = payload.iat ?? Math.floor(Date.now() / 1e3);
const exp = payload?.exp ?? iat + (opts.refreshTokenExpiresIn ?? 2592e3);
const token = opts.generateRefreshToken ? await opts.generateRefreshToken() : generateRandomString(32, "A-Z", "a-z");
@@ -1677,6 +1732,7 @@ async function createRefreshToken(ctx, opts, user, referenceId, authorizationCod
authTime,
confirmation,
requestedUserInfoClaims: requestedUserInfoClaims?.length ? requestedUserInfoClaims : void 0,
+ authorizationDetails: authorizationDetails?.length ? authorizationDetails : void 0,
scopes,
resources,
createdAt: /* @__PURE__ */ new Date(iat * 1e3),
@@ -1950,11 +2006,12 @@ async function createUserTokens(ctx, opts, params) {
refreshToken: existingRefreshToken
});
const requestedUserInfoClaims = params.requestedUserInfoClaims ?? existingRefreshToken?.requestedUserInfoClaims ?? [];
+ const authorizationDetails = await validateAuthorizationDetails(ctx, opts, parseAuthorizationDetails(params.authorizationDetails ?? existingRefreshToken?.authorizationDetails), "token", { client, user, grantType });
const earlyRefreshToken = isRefreshToken && user && !isJwtAccessToken ? await createRefreshToken(ctx, opts, user, referenceId, params.authorizationCodeId, client, effectiveScopes, {
iat,
exp: refreshTokenExp,
sid: sessionId
- }, existingRefreshToken, authTime, refreshResources, confirmation, requestedUserInfoClaims) : void 0;
+ }, existingRefreshToken, authTime, refreshResources, confirmation, requestedUserInfoClaims, authorizationDetails) : void 0;
const accessTokenClaims = isJwtAccessToken ? await resolveAccessTokenClaims({
ctx,
opts,
@@ -1976,16 +2033,17 @@ async function createUserTokens(ctx, opts, params) {
signingAlgorithm: grantIssuance.signingAlgorithm,
signingKeyId: grantIssuance.signingKeyId,
accessTokenClaims,
+ authorizationDetails,
confirmation
}) : createOpaqueAccessToken(ctx, opts, user, client, effectiveScopes, {
iat,
exp,
sid: sessionId
- }, params?.resources, referenceId, params.authorizationCodeId, earlyRefreshToken?.id, confirmation, requestedUserInfoClaims), earlyRefreshToken ? earlyRefreshToken : isRefreshToken && user ? createRefreshToken(ctx, opts, user, referenceId, params.authorizationCodeId, client, effectiveScopes, {
+ }, params?.resources, referenceId, params.authorizationCodeId, earlyRefreshToken?.id, confirmation, requestedUserInfoClaims, authorizationDetails), earlyRefreshToken ? earlyRefreshToken : isRefreshToken && user ? createRefreshToken(ctx, opts, user, referenceId, params.authorizationCodeId, client, effectiveScopes, {
iat,
exp: refreshTokenExp,
sid: sessionId
- }, existingRefreshToken, authTime, refreshResources, confirmation, requestedUserInfoClaims) : void 0]);
+ }, existingRefreshToken, authTime, refreshResources, confirmation, requestedUserInfoClaims, authorizationDetails) : void 0]);
const idToken = isIdToken ? await createIdToken(ctx, opts, user, client, effectiveScopes, nonce, sessionId, authTime, accessToken, additionalIdTokenClaims) : void 0;
const responseBody = {
...customFields,
@@ -1996,6 +2054,7 @@ async function createUserTokens(ctx, opts, params) {
token_type: confirmationTokenType(confirmation),
refresh_token: refreshToken?.token,
scope: effectiveScopes.join(" "),
+ ...authorizationDetails.length ? { authorization_details: authorizationDetails } : {},
id_token: idToken
};
if (existingRefreshToken?.id && refreshToken?.id) try {
@@ -2163,6 +2222,7 @@ async function handleAuthorizationCodeGrant(ctx, opts) {
authTime,
verificationValue,
authorizationCodeId,
+ authorizationDetails: parseAuthorizationDetails(verificationValue.query.authorization_details),
requestedUserInfoClaims,
resources: effectiveResources,
originalResources: authorizedResources
@@ -2490,6 +2550,7 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) {
exp: Math.floor(new Date(accessToken.expiresAt).getTime() / 1e3),
iat: Math.floor(new Date(accessToken.createdAt).getTime() / 1e3),
scope: accessToken.scopes?.join(" "),
+ ...accessToken.authorizationDetails?.length ? { authorization_details: accessToken.authorizationDetails } : {},
token_type: confirmationTokenType(accessToken.confirmation),
...accessToken.confirmation ? { cnf: accessToken.confirmation } : {}
},
@@ -2538,6 +2599,7 @@ async function validateRefreshToken(ctx, opts, token, clientId) {
exp: Math.floor(new Date(refreshToken.expiresAt).getTime() / 1e3),
iat: Math.floor(new Date(refreshToken.createdAt).getTime() / 1e3),
scope: refreshToken.scopes?.join(" "),
+ ...refreshToken.authorizationDetails?.length ? { authorization_details: refreshToken.authorizationDetails } : {},
token_type: confirmationTokenType(refreshToken.confirmation),
...refreshToken.confirmation ? { cnf: refreshToken.confirmation } : {}
};
@@ -3029,7 +3091,9 @@ function oauthAuthorizationServerMetadata(ctx, opts) {
const { clientDiscoveries, authMetadata } = buildAuthServerMetadata(ctx, opts);
return applyOAuthProviderMetadataExtensions(ctx, opts, "oauth-authorization-server", {
...mergeDiscoveryMetadata(clientDiscoveries),
- ...authMetadata
+ ...authMetadata,
+ ...opts.authorizationDetails ? { authorization_details_types_supported: opts.authorizationDetails.typesSupported } : {},
+ request_uri_parameter_supported: Boolean(opts.requestUriResolver)
});
}
function oidcServerMetadata(ctx, opts) {
@@ -3037,6 +3101,7 @@ function oidcServerMetadata(ctx, opts) {
const { jwtPluginOptions, clientDiscoveries, authMetadata } = buildAuthServerMetadata(ctx, opts);
const metadata = {
...authMetadata,
+ ...opts.authorizationDetails ? { authorization_details_types_supported: opts.authorizationDetails.typesSupported } : {},
claims_supported: getSupportedClaims(opts),
claims_parameter_supported: true,
userinfo_endpoint: `${baseURL}/oauth2/userinfo`,
@@ -3050,7 +3115,7 @@ function oidcServerMetadata(ctx, opts) {
})(),
end_session_endpoint: `${baseURL}/oauth2/end-session`,
request_parameter_supported: false,
- request_uri_parameter_supported: false,
+ request_uri_parameter_supported: Boolean(opts.requestUriResolver),
prompt_values_supported: [
"login",
"consent",
@@ -3408,6 +3473,13 @@ async function checkOAuthClient(client, opts, settings) {
error: "invalid_client_metadata",
error_description: "dpop_bound_access_tokens must be a boolean"
});
+ if (clientWithDefaults.authorization_details_types !== void 0) {
+ const supportedTypes = opts.authorizationDetails?.typesSupported ?? [];
+ if (!opts.authorizationDetails || clientWithDefaults.authorization_details_types.some((type) => !supportedTypes.includes(type))) throw new APIError("BAD_REQUEST", {
+ error: "invalid_client_metadata",
+ error_description: "authorization_details_types contains an unsupported type"
+ });
+ }
if (clientWithDefaults.type) {
if (isPublic && !(clientWithDefaults.type === "native" || clientWithDefaults.type === "user-agent-based")) throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
@@ -5379,6 +5451,10 @@ const schema = {
type: "string[]",
required: false
},
+ authorizationDetails: {
+ type: "json",
+ required: false
+ },
expiresAt: { type: "date" },
createdAt: { type: "date" },
revoked: {
@@ -5474,6 +5550,10 @@ const schema = {
type: "string[]",
required: false
},
+ authorizationDetails: {
+ type: "json",
+ required: false
+ },
refreshId: {
type: "string",
required: false,
@@ -5532,6 +5612,10 @@ const schema = {
type: "string[]",
required: false
},
+ authorizationDetails: {
+ type: "json",
+ required: false
+ },
scopes: {
type: "string[]",
required: true
@@ -5949,6 +6033,7 @@ const oauthProvider = (options) => {
accept: z.boolean().meta({ description: "Accept or deny user consent for a set of scopes" }),
scope: z.string().optional().meta({ description: "List of accept of accepted space-separated scopes. If none is provided, then all originally requested scopes are accepted." }),
claims: claimsRequestParameterSchema.optional().meta({ description: "Accepted OIDC claims request object. If none is provided, then all originally requested claims are accepted." }),
+ authorization_details: z.array(z.record(z.string(), z.unknown())).optional().meta({ description: "Accepted RFC 9396 authorization details" }),
oauth_query: z.string().optional().meta({ description: "The redirected page's query parameters" })
}),
use: [sessionMiddleware],
@@ -6916,6 +7001,14 @@ async function authorizeEndpoint(ctx, opts, settings) {
if (client.disabled) return handleRedirect(ctx, getErrorURL(ctx, "client_disabled", "client is disabled"));
if (!clientAllowsGrant(client, "authorization_code")) return handleRedirect(ctx, getErrorURL(ctx, "unauthorized_client", "client is not authorized to use the authorization_code grant"));
if (!findRegisteredRedirectUri(client.redirectUris, query.redirect_uri) || !query.redirect_uri) return handleRedirect(ctx, getErrorURL(ctx, "invalid_redirect", "invalid redirect uri"));
+ let requestedAuthorizationDetails;
+ try {
+ requestedAuthorizationDetails = await validateAuthorizationDetails(ctx, opts, parseAuthorizationDetails(query.authorization_details), "request", { client });
+ } catch (error) {
+ if (error instanceof APIError$1 || error instanceof APIError) return handleRedirect(ctx, formatErrorURL(query.redirect_uri, error.body?.error ?? "invalid_authorization_details", error.body?.error_description ?? "invalid authorization details", query.state, getIssuer(ctx, opts)));
+ throw error;
+ }
+ if (requestedAuthorizationDetails.length) query.authorization_details = JSON.stringify(requestedAuthorizationDetails);
let requestedScopes = query.scope?.split(" ").filter((s) => s);
if (requestedScopes) {
const validScopes = new Set(client.scopes ?? opts.scopes);
@@ -7042,6 +7135,18 @@ async function authorizeEndpoint(ctx, opts, settings) {
if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
}
+ const consentedAuthorizationDetails = parseAuthorizationDetails(consent.authorizationDetails);
+ if (!authorizationDetailsCovered(opts, requestedAuthorizationDetails, consentedAuthorizationDetails)) {
+ if (promptNone) return redirectWithPromptNoneError(ctx, opts, query, "consent_required", "End-User consent is required");
+ return redirectWithPromptCode(ctx, opts, "consent", { sessionId: session.session.id });
+ }
+ const effectiveAuthorizationDetails = opts.authorizationDetails?.resolve ? await opts.authorizationDetails.resolve({ requested: requestedAuthorizationDetails, granted: consentedAuthorizationDetails }) : requestedAuthorizationDetails;
+ if (!authorizationDetailsCovered(opts, effectiveAuthorizationDetails, consentedAuthorizationDetails)) throw new APIError("BAD_REQUEST", {
+ error_description: "resolved authorization_details exceed the consented grant",
+ error: "invalid_authorization_details"
+ });
+ if (effectiveAuthorizationDetails.length) query.authorization_details = JSON.stringify(effectiveAuthorizationDetails);
+ else delete query.authorization_details;
return redirectWithAuthorizationCode(ctx, opts, {
query,
clientId: client.clientId,
diff --git a/dist/oauth-CSzjH7fO.d.mts b/dist/oauth-CSzjH7fO.d.mts
index ce35cc05b9c72a8070a244d18e86227917c0d3a9..8bdd12f1cc643c042818895756efc10b24258b96 100644
--- a/dist/oauth-CSzjH7fO.d.mts
+++ b/dist/oauth-CSzjH7fO.d.mts
@@ -348,6 +348,10 @@ declare const schema: {
type: "string[]";
required: false;
};
+ authorizationDetails: {
+ type: "json";
+ required: false;
+ };
expiresAt: {
type: "date";
};
@@ -448,6 +452,10 @@ declare const schema: {
type: "string[]";
required: false;
};
+ authorizationDetails: {
+ type: "json";
+ required: false;
+ };
refreshId: {
type: "string";
required: false;
@@ -510,6 +518,10 @@ declare const schema: {
type: "string[]";
required: false;
};
+ authorizationDetails: {
+ type: "json";
+ required: false;
+ };
scopes: {
type: "string[]";
required: true;
@@ -604,6 +616,7 @@ declare const clientRegistrationRequestSchema: z.ZodObject<{
pairwise: "pairwise";
}>>;
dpop_bound_access_tokens: z.ZodOptional<z.ZodBoolean>;
+ authorization_details_types: z.ZodOptional<z.ZodArray<z.ZodString>>;
resources: z.ZodOptional<z.ZodArray<z.ZodString>>;
skip_consent: z.ZodOptional<z.ZodNever>;
}, z.core.$strip>;
@@ -704,6 +717,8 @@ interface OAuthTokenIssueParams {
resources?: string[];
/** Full original authorized resources for the grant, used to seed refresh tokens. */
originalResources?: string[];
+ /** RFC 9396 Rich Authorization Requests granted for this token. */
+ authorizationDetails?: AuthorizationDetail[];
/**
* OIDC UserInfo claim names requested by the authorization request's
* `claims.userinfo` object. The authorization server persists these names so
@@ -749,6 +764,7 @@ interface OAuthTokenResponse {
token_type: TokenType;
refresh_token: string | undefined;
scope: string;
+ authorization_details?: AuthorizationDetail[];
id_token: string | undefined;
[key: string]: unknown;
}
@@ -1000,6 +1016,23 @@ interface OAuthOptions<Scopes extends readonly Scope[] = InternallySupportedScop
* Custom schema definitions
*/
schema?: InferOptionSchema<typeof schema>;
+ /** RFC 9396 Rich Authorization Requests support. */
+ authorizationDetails?: {
+ typesSupported: string[];
+ validate?: (input: {
+ ctx: GenericEndpointContext;
+ details: AuthorizationDetail[];
+ phase: "request" | "consent" | "token";
+ requested?: AuthorizationDetail[];
+ client?: SchemaClient<Scope[]>;
+ clientId?: string;
+ user?: User & Record<string, unknown>;
+ session?: Session & Record<string, unknown>;
+ grantType?: GrantType;
+ }) => Awaitable<AuthorizationDetail[]>;
+ isSubset?: (input: { requested: AuthorizationDetail[]; granted: AuthorizationDetail[] }) => boolean;
+ resolve?: (input: { requested: AuthorizationDetail[]; granted: AuthorizationDetail[] }) => Awaitable<AuthorizationDetail[]>;
+ };
/**
* The scopes that the client is allowed to request.
* Must contain "openid" to be considered an OIDC server,
@@ -1919,6 +1952,8 @@ interface OAuthAuthorizationQuery {
* - "offline_access" is required for requests that require a refresh token.
*/
scope?: string;
+ /** JSON-encoded RFC 9396 Rich Authorization Request details. */
+ authorization_details?: string;
/**
* Opaque value used to maintain state between the request and the callback. Typically,
* Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by cryptographically binding the
@@ -2138,6 +2173,11 @@ interface VerificationValue {
referenceId?: string;
authTime?: number;
}
+/** One RFC 9396 authorization detail. */
+type AuthorizationDetail = {
+ type: string;
+ [key: string]: unknown;
+};
/**
* Client registered values as used within the plugin
*/
@@ -2341,6 +2381,7 @@ interface OAuthOpaqueAccessToken<Scopes extends readonly Scope[] = InternallySup
* `claims.userinfo` object.
*/
requestedUserInfoClaims?: string[];
+ authorizationDetails?: AuthorizationDetail[];
/**
* RFC 7800 `cnf` confirmation that sender-constrains this access token (for
* example DPoP `{ jkt }`). Surfaced as the `cnf` claim at introspection.
@@ -2399,6 +2440,7 @@ interface OAuthRefreshToken<Scopes extends readonly Scope[] = InternallySupporte
* `claims.userinfo` object. Carried forward on rotation.
*/
requestedUserInfoClaims?: string[];
+ authorizationDetails?: AuthorizationDetail[];
/**
* RFC 7800 `cnf` confirmation that sender-constrains this refresh-token
* family (for example DPoP `{ jkt }`). Carried forward on rotation.
@@ -2419,6 +2461,7 @@ type OAuthConsent<Scopes extends readonly Scope[] = InternallySupportedScopes[]>
* `claims.userinfo` object.
*/
requestedUserInfoClaims?: string[];
+ authorizationDetails?: AuthorizationDetail[];
scopes: Scopes;
createdAt: Date;
updatedAt: Date;
@@ -2500,6 +2543,7 @@ interface AuthServerMetadata {
* Supported scopes.
*/
scopes_supported?: string[];
+ authorization_details_types_supported?: string[];
/**
* Supported response types. (for /authorize endpoint)
*/
@@ -2859,4 +2903,4 @@ interface ResourceServerMetadata {
dpop_bound_access_tokens_required?: boolean;
}
//#endregion
-export { OAuthProviderExtension as A, StoreTokenType as B, OAuthConsent as C, OAuthOpaqueAccessToken as D, OAuthMetadataExtensionInput as E, OAuthTokenResponse as F, VerificationValue as H, OAuthUserInfoExtensionInput as I, Prompt as L, OAuthResource as M, OAuthResourceInput as N, OAuthOptions as O, OAuthTokenIssueParams as P, SchemaClient as R, OAuthClientResource as S, OAuthExtensionGrantHandlerInput as T, ClientRegistrationRequest as U, StoredAuthorizationQuery as V, ResourceUriSchema as W, OAuthClaimExtensionInput as _, GrantType as a, OAuthClientAuthenticationResult as b, ResourceServerMetadata as c, ActiveAccessTokenPayload as d, AuthorizePrompt as f, OAuthAuthorizationQuery as g, OAuthAuthenticatedClient as h, Confirmation as i, OAuthRefreshToken as j, OAuthProviderApi as k, TokenEndpointAuthMethod as l, InitialAccessTokenAuthorization as m, AuthServerMetadata as n, OAuthClient as o, ClientDiscovery as p, BearerMethodsSupported as r, OIDCMetadata as s, AuthMethod as t, TokenType as u, OAuthClientAuthenticationInput as v, OAuthExtensionGrantHandler as w, OAuthClientAuthenticationStrategy as x, OAuthClientAuthenticationRequest as y, Scope as z };
\ No newline at end of file
+export { OAuthProviderExtension as A, StoreTokenType as B, OAuthConsent as C, OAuthOpaqueAccessToken as D, OAuthMetadataExtensionInput as E, OAuthTokenResponse as F, VerificationValue as H, OAuthUserInfoExtensionInput as I, Prompt as L, OAuthResource as M, OAuthResourceInput as N, OAuthOptions as O, OAuthTokenIssueParams as P, AuthorizationDetail as Q, SchemaClient as R, OAuthClientResource as S, OAuthExtensionGrantHandlerInput as T, ClientRegistrationRequest as U, StoredAuthorizationQuery as V, ResourceUriSchema as W, OAuthClaimExtensionInput as _, GrantType as a, OAuthClientAuthenticationResult as b, ResourceServerMetadata as c, ActiveAccessTokenPayload as d, AuthorizePrompt as f, OAuthAuthorizationQuery as g, OAuthAuthenticatedClient as h, Confirmation as i, OAuthRefreshToken as j, OAuthProviderApi as k, TokenEndpointAuthMethod as l, InitialAccessTokenAuthorization as m, AuthServerMetadata as n, OAuthClient as o, ClientDiscovery as p, BearerMethodsSupported as r, OIDCMetadata as s, AuthMethod as t, TokenType as u, OAuthClientAuthenticationInput as v, OAuthExtensionGrantHandler as w, OAuthClientAuthenticationStrategy as x, OAuthClientAuthenticationRequest as y, Scope as z };
+12 -7
View File
@@ -22,6 +22,11 @@ overrides:
undici: 7.28.0
'@esbuild-kit/core-utils>esbuild': 0.25.12
patchedDependencies:
'@better-auth/oauth-provider@1.7.0-rc.2':
hash: 7090dafc18d45b6b4e1cca723d9786306d6ffed78adc9ad3a614065062ebfb6d
path: patches/@better-auth__oauth-provider@1.7.0-rc.2.patch
importers:
.:
@@ -40,7 +45,7 @@ importers:
version: 1.7.0-rc.2(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.7.0-rc.2(@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.7(zod@4.4.3))
'@better-auth/oauth-provider':
specifier: 1.7.0-rc.2
version: 1.7.0-rc.2(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@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.7(zod@4.4.3))
version: 1.7.0-rc.2(patch_hash=7090dafc18d45b6b4e1cca723d9786306d6ffed78adc9ad3a614065062ebfb6d)(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@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.7(zod@4.4.3))
'@better-captcha/react':
specifier: ^0.7.0
version: 0.7.0(react@19.2.5)(typescript@5.9.3)
@@ -192,8 +197,8 @@ importers:
specifier: ^4.4.3
version: 4.4.3
zpan-cloud-sdk:
specifier: ^2.5.1
version: 2.5.1(hono@4.12.27)(zod@4.4.3)
specifier: ^2.5.2
version: 2.5.2(hono@4.12.27)(zod@4.4.3)
devDependencies:
'@biomejs/biome':
specifier: ^2.4.10
@@ -5902,8 +5907,8 @@ packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
zpan-cloud-sdk@2.5.1:
resolution: {integrity: sha512-8db7VzBYl0yuzmU8f1RCoqGRFHSaVn6cDe9v7IS10WuLs+QPwbeBZZDQxfmmpAWfoOdl8M9gxJGjlgzfBHZy6A==}
zpan-cloud-sdk@2.5.2:
resolution: {integrity: sha512-ed7grH13go/tcO17o1V2g0FAEN6a+7jQbN3WkDu5l2AabvivOymWqWGNJUZJR5DGkE4g/kGhh290V0dQMUl/hw==}
peerDependencies:
hono: ^4.7.11
zod: ^4.3.6
@@ -6467,7 +6472,7 @@ snapshots:
'@better-auth/core': 1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
'@better-auth/oauth-provider@1.7.0-rc.2(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@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.7(zod@4.4.3))':
'@better-auth/oauth-provider@1.7.0-rc.2(patch_hash=7090dafc18d45b6b4e1cca723d9786306d6ffed78adc9ad3a614065062ebfb6d)(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@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.7(zod@4.4.3))':
dependencies:
'@better-auth/core': 1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
@@ -11637,7 +11642,7 @@ snapshots:
zod@4.4.3: {}
zpan-cloud-sdk@2.5.1(hono@4.12.27)(zod@4.4.3):
zpan-cloud-sdk@2.5.2(hono@4.12.27)(zod@4.4.3):
dependencies:
hono: 4.12.27
zod: 4.4.3
+7 -6
View File
@@ -1,4 +1,5 @@
import { AuthorizationScope } from '@shared/authorization'
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '@shared/oauth'
import { isNull } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../../db/auth-schema'
@@ -37,7 +38,7 @@ describe('OAuth gateway', () => {
id: 'grant-1',
clientId: CLIENT_ID,
userId,
referenceId: orgId,
authorizationDetails: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: orgId }],
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date('2026-07-29T12:00:00.000Z'),
lastUsedAt: new Date('2026-07-29T12:20:00.000Z'),
@@ -47,7 +48,7 @@ describe('OAuth gateway', () => {
id: 'grant-without-workspace',
clientId: CLIENT_ID,
userId,
referenceId: null,
authorizationDetails: null,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date(),
updatedAt: new Date(),
@@ -60,7 +61,7 @@ describe('OAuth gateway', () => {
clientId: CLIENT_ID,
clientName: 'FlareAuth',
userId,
orgId,
workspaceIds: [orgId],
scopes: [AuthorizationScope.OBJECTS_READ],
createdAt: '2026-07-29T12:00:00.000Z',
lastUsedAt: '2026-07-29T12:20:00.000Z',
@@ -78,7 +79,7 @@ describe('OAuth gateway', () => {
id: 'grant-1',
clientId: CLIENT_ID,
userId,
referenceId: orgId,
authorizationDetails: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: orgId }],
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date(),
updatedAt: new Date(),
@@ -88,7 +89,7 @@ describe('OAuth gateway', () => {
token: 'hashed-refresh',
clientId: CLIENT_ID,
userId,
referenceId: orgId,
authorizationDetails: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: orgId }],
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
@@ -98,7 +99,7 @@ describe('OAuth gateway', () => {
token: 'hashed-access',
clientId: CLIENT_ID,
userId,
referenceId: orgId,
authorizationDetails: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: orgId }],
refreshId: 'refresh-1',
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
+13 -13
View File
@@ -1,4 +1,5 @@
import { type AuthorizationScope, isAuthorizationScope } from '@shared/authorization'
import { parseWorkspaceAuthorizationDetails } from '@shared/schemas'
import { and, eq, gt, inArray, isNull } from 'drizzle-orm'
import { decodeJwt } from 'jose'
import {
@@ -98,7 +99,7 @@ export function createOAuthGateway(): OAuthGateway {
clientId: oauthConsent.clientId,
clientName: oauthClient.name,
userId: oauthConsent.userId,
orgId: oauthConsent.referenceId,
authorizationDetails: oauthConsent.authorizationDetails,
scopes: oauthConsent.scopes,
createdAt: oauthConsent.createdAt,
lastUsedAt: oauthConsent.lastUsedAt,
@@ -107,14 +108,16 @@ export function createOAuthGateway(): OAuthGateway {
.innerJoin(oauthClient, eq(oauthClient.clientId, oauthConsent.clientId))
.where(eq(oauthConsent.userId, userId))
return rows.flatMap((row) => {
if (!row.userId || !row.orgId) return []
if (!row.userId) return []
const workspaceIds = workspaceIdsFromAuthorizationDetails(row.authorizationDetails)
if (workspaceIds.length === 0) return []
return [
{
id: row.id,
clientId: row.clientId,
clientName: row.clientName || row.clientId,
userId: row.userId,
orgId: row.orgId,
workspaceIds,
scopes: parseScopes(row.scopes),
createdAt: toIso(row.createdAt),
lastUsedAt: row.lastUsedAt ? toIso(row.lastUsedAt) : null,
@@ -129,12 +132,11 @@ export function createOAuthGateway(): OAuthGateway {
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)))
.limit(1)
if (!grant?.userId || !grant.referenceId) return false
if (!grant?.userId) return false
const refreshRows = await db
.select({ id: oauthRefreshToken.id })
.from(oauthRefreshToken)
@@ -142,7 +144,6 @@ export function createOAuthGateway(): OAuthGateway {
and(
eq(oauthRefreshToken.clientId, grant.clientId),
eq(oauthRefreshToken.userId, grant.userId),
eq(oauthRefreshToken.referenceId, grant.referenceId),
isNull(oauthRefreshToken.revoked),
),
)
@@ -150,13 +151,7 @@ export function createOAuthGateway(): OAuthGateway {
await executeWriteTransaction(db, [
db
.delete(oauthAccessToken)
.where(
and(
eq(oauthAccessToken.clientId, grant.clientId),
eq(oauthAccessToken.userId, grant.userId),
eq(oauthAccessToken.referenceId, grant.referenceId),
),
),
.where(and(eq(oauthAccessToken.clientId, grant.clientId), eq(oauthAccessToken.userId, grant.userId))),
...(refreshIds.length > 0
? [db.update(oauthRefreshToken).set({ revoked: input.now }).where(inArray(oauthRefreshToken.id, refreshIds))]
: []),
@@ -167,6 +162,11 @@ export function createOAuthGateway(): OAuthGateway {
}
}
function workspaceIdsFromAuthorizationDetails(value: unknown): string[] {
if (value == null) return []
return parseWorkspaceAuthorizationDetails(value).flatMap((detail) => (detail.identifier ? [detail.identifier] : []))
}
function parseScopes(value: string | string[] | null): AuthorizationScope[] {
if (Array.isArray(value)) return value.filter(isAuthorizationScope)
if (!value) return []
+9 -1
View File
@@ -7,6 +7,14 @@ import type { OrgRepo } from '../../usecases/ports'
const ROLE_LEVELS: Record<string, number> = { owner: 3, admin: 3, editor: 2, viewer: 1, member: 1 }
export function createOrgRepo(db: Database): OrgRepo {
async function listUserOrgs(userId: string): Promise<Array<{ id: string; name: string }>> {
return db
.select({ id: organization.id, name: organization.name })
.from(member)
.innerJoin(organization, eq(organization.id, member.organizationId))
.where(eq(member.userId, userId))
}
// Find the user's personal org, if they still belong to it. New personal orgs
// are identified by metadata.type; legacy rows keep the `personal-*` slug.
// The member row remains load-bearing because admins can revoke access without
@@ -65,5 +73,5 @@ export function createOrgRepo(db: Database): OrgRepo {
return orgId === (await findPersonalOrg(userId))
}
return { findPersonalOrg, getMemberRole, getOrgNames, canReadOrg, canWriteToOrg, isPersonalOrg }
return { listUserOrgs, findPersonalOrg, getMemberRole, getOrgNames, canReadOrg, canWriteToOrg, isPersonalOrg }
}
+6
View File
@@ -17,6 +17,11 @@ async function isBanned(db: Database, userId: string): Promise<boolean> {
return Boolean(rows[0]?.banned)
}
async function getSiteRole(db: Database, userId: string): Promise<string | null> {
const rows = await db.select({ role: user.role }).from(user).where(eq(user.id, userId)).limit(1)
return rows[0]?.role ?? null
}
async function findActiveUserIdByUsername(db: Database, username: string): Promise<string | null> {
const normalizedEmail = username.toLowerCase()
const rows = await db
@@ -224,6 +229,7 @@ function mergeGrantMetadata(existing: string | null, patch: Record<string, unkno
export function createUserAdminRepo(db: Database): UserAdminRepo {
return {
isBanned: (userId) => isBanned(db, userId),
getSiteRole: (userId) => getSiteRole(db, userId),
findActiveUserIdByUsername: (username) => findActiveUserIdByUsername(db, username),
listUserPersonalEntitlements: (userId) => listUserPersonalEntitlements(db, userId),
grantUserPersonalEntitlement: (input) => grantUserPersonalEntitlement(db, input),
+42 -39
View File
@@ -1,7 +1,7 @@
import { release as osRelease } from 'node:os'
import { OpenAPIHono } from '@hono/zod-openapi'
import { Scalar } from '@scalar/hono-api-reference'
import { OAUTH_SCOPE_DESCRIPTIONS, OAUTH_SCOPES } from '@shared/oauth'
import { OAUTH_RESOURCE_SCOPES, OAUTH_SCOPE_DESCRIPTIONS, OAUTH_SCOPES } from '@shared/oauth'
import type { Context } from 'hono'
import { cors } from 'hono/cors'
import type { Auth } from './auth'
@@ -22,7 +22,6 @@ import ihost from './http/image-hosting/images'
import internal from './http/internal'
import { notifications } from './http/notifications'
import { oauthGrants } from './http/oauth-grants'
import { oauthResourceScopes } from './http/oauth-resource-scopes'
import objects from './http/objects'
import { adminQuotas, userQuotas } from './http/quotas'
import redirect from './http/redirect'
@@ -232,6 +231,26 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
for (const [path, item] of Object.entries(authDoc.paths ?? {})) {
doc.paths[`/api/auth${path}`] = item as (typeof doc.paths)[string]
}
// better-auth 1.7.0-rc.2 documents POST /device/token with its session
// response even though the handler returns an OAuth device token. Keep the
// generated contract aligned with the wire response until upstream fixes it.
const deviceTokenJson = (
doc.paths['/api/auth/device/token'] as
| { post?: { responses?: Record<string, { content?: Record<string, { schema?: unknown }> }> } }
| undefined
)?.post?.responses?.['200']?.content?.['application/json']
if (deviceTokenJson) {
deviceTokenJson.schema = {
type: 'object',
properties: {
access_token: { type: 'string' },
token_type: { type: 'string' },
expires_in: { type: 'integer' },
scope: { type: 'string' },
},
required: ['access_token', 'token_type', 'expires_in'],
}
}
doc.components ??= {}
doc.components.securitySchemes = {
...(doc.components.securitySchemes ?? {}),
@@ -260,42 +279,28 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
oauthAuthorizationServer: '/.well-known/oauth-authorization-server/api/auth',
oauthProtectedResource: '/.well-known/oauth-protected-resource/api',
},
})
// better-auth's device-authorization plugin advertises POST /device/token as
// returning { session, user }, but its handler actually returns the OAuth
// device token { access_token, token_type, expires_in } (see better-auth's
// device-authorization/routes.mjs). Correct that one wrong response so the
// document — and the generated downloader client — match the real wire shape.
const deviceTokenJson = (
doc.paths['/api/auth/device/token'] as
| { post?: { responses?: Record<string, { content?: Record<string, { schema?: unknown }> }> } }
| undefined
)?.post?.responses?.['200']?.content?.['application/json']
if (deviceTokenJson) {
deviceTokenJson.schema = {
type: 'object',
properties: {
access_token: { type: 'string' },
token_type: { type: 'string' },
expires_in: { type: 'integer' },
scope: { type: 'string' },
'x-cli-config': {
profiles: {
default: {
credentials: {
oauth2: {
auth: {
type: 'api-key',
params: {
in: 'header',
name: 'Authorization',
value: 'DPoP',
provider: 'realmroot-target',
scopes: OAUTH_RESOURCE_SCOPES.join(' '),
},
},
params: { provider: 'realmroot-target' },
},
},
},
},
required: ['access_token', 'token_type', 'expires_in'],
}
}
for (const [path, pathItem] of Object.entries(doc.paths)) {
if (!path.startsWith('/api/auth/') || !pathItem || typeof pathItem !== 'object') continue
for (const operation of Object.values(pathItem)) {
if (!operation || typeof operation !== 'object') continue
Object.assign(operation, {
'x-zpan-auth': { public: true, scopes: [] },
'x-mcp-ignore': true,
})
if (path.includes('/callback')) Object.assign(operation, { 'x-cli-ignore': true })
}
}
},
})
return c.json(doc)
})
@@ -337,7 +342,6 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
// /s/:token is intentionally left for the SPA landing page.
app.route('/api/shares', publicShares)
app.route('/api/configz', configz)
app.route('/api/oauth-resource-scopes', oauthResourceScopes)
// Self-hosted avatar blobs (CF + AVATARS R2 binding, no AVATARS_PUBLIC_URL). Public.
app.get('/api/avatar-blobs/:scope/:id', serveAvatarBlob)
app.route('/r', redirect)
@@ -509,4 +513,3 @@ export type AdminOverviewRoute = typeof adminOverview
export type AdminStatsRoute = typeof adminStats
export type StorageUsageRoute = typeof storageUsage
export type OAuthGrantsRoute = typeof oauthGrants
export type OAuthResourceScopesRoute = typeof oauthResourceScopes
+190 -4
View File
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '@shared/oauth'
import { isPersonalOrgLike } from '@shared/org-slugs'
import { deriveDpopAth } from 'better-auth/oauth2'
import { eq, sql } from 'drizzle-orm'
@@ -843,6 +844,7 @@ describe('OAuth consent guards', () => {
token_endpoint_auth_method: 'client_secret_basic',
scope: 'openid offline_access',
jwks_uri: 'https://broker.example.com/api/auth/jwks',
authorization_details_types: [WORKSPACE_AUTHORIZATION_DETAIL_TYPE],
}),
})
const body = (await res.json()) as Record<string, unknown>
@@ -852,6 +854,7 @@ describe('OAuth consent guards', () => {
client_id: expect.any(String),
client_secret: expect.any(String),
token_endpoint_auth_method: 'client_secret_basic',
authorization_details_types: [WORKSPACE_AUTHORIZATION_DETAIL_TYPE],
})
expect(String(body.scope).split(' ')).toEqual(expect.arrayContaining(['openid', 'offline_access', 'objects:read']))
@@ -872,6 +875,28 @@ describe('OAuth consent guards', () => {
)
})
it('rejects unsupported authorization detail types during dynamic client registration', async () => {
const ctx = await createTestApp()
const response = await ctx.app.request('/api/auth/oauth2/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'Unsupported RAR Client',
redirect_uris: ['https://broker.example.com/oauth/callback'],
grant_types: ['authorization_code'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
authorization_details_types: ['https://broker.example.com/authorization-details/unknown'],
}),
})
expect(response.status).toBe(400)
await expect(response.json()).resolves.toMatchObject({
error: 'invalid_client_metadata',
error_description: 'authorization_details_types contains an unsupported type',
})
})
it('returns a DPoP challenge for a foreign access token instead of an internal error', async () => {
const ctx = await createTestApp()
const apiUrl = 'http://localhost:3000/api/objects'
@@ -976,6 +1001,8 @@ describe('OAuth consent guards', () => {
expect(registration.status).toBe(201)
const signUpResponse = await signUp(ctx, 'external-resource@example.com')
const signUpBody = (await signUpResponse.clone().json()) as { user: { id: string } }
const workspaceId = await personalOrgForUser(ctx, signUpBody.user.id)
const cookie = signUpResponse.headers
.getSetCookie()
.map((value) => value.split(';', 1)[0])
@@ -993,6 +1020,7 @@ describe('OAuth consent guards', () => {
state: 'external-resource',
code_challenge: challenge,
code_challenge_method: 'S256',
authorization_details: JSON.stringify([{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE }]),
})
const authorize = await ctx.app.request(
`http://localhost:3000/api/auth/oauth2/authorize?${authorizeParams.toString()}`,
@@ -1000,12 +1028,13 @@ describe('OAuth consent guards', () => {
)
const consentLocation = authorize.headers.get('location')
expect(authorize.status).toBe(302)
expect(consentLocation).toMatch(/^\/settings\/oauth-apps\?/)
expect(consentLocation).toMatch(/^\/oauth\/consent\?/)
const consent = await ctx.app.request('http://localhost:3000/api/auth/oauth2/consent', {
method: 'POST',
headers: { Cookie: cookie, Origin: 'http://localhost:3000', 'Content-Type': 'application/json' },
body: JSON.stringify({
accept: true,
authorization_details: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: workspaceId }],
oauth_query: consentLocation?.slice(consentLocation.indexOf('?') + 1),
}),
})
@@ -1027,8 +1056,58 @@ describe('OAuth consent guards', () => {
resource: 'http://localhost:3000/api',
}).toString(),
})
const subject = (await subjectResponse.json()) as { access_token: string }
const subject = (await subjectResponse.json()) as {
access_token: string
refresh_token: string
authorization_details: Array<{ type: string; identifier: string }>
}
expect(subjectResponse.status).toBe(200)
expect(subject.authorization_details).toEqual([
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: workspaceId },
])
const reusedVerifier = 'reused-consent-verifier-with-sufficient-entropy-1234567890'
const reusedParams = new URLSearchParams(authorizeParams)
reusedParams.set('state', 'reused-consent')
reusedParams.set('code_challenge', createHash('sha256').update(reusedVerifier).digest('base64url'))
const reusedAuthorize = await ctx.app.request(
`http://localhost:3000/api/auth/oauth2/authorize?${reusedParams.toString()}`,
{ headers: { Cookie: cookie, Origin: 'http://localhost:3000' } },
)
const reusedLocation = reusedAuthorize.headers.get('location')
expect(reusedAuthorize.status).toBe(302)
expect(reusedLocation).toMatch(/^https:\/\/broker\.example\.com\/api\/account-connections\/oauth\/callback\?/)
const reusedSubjectResponse = await ctx.app.request(tokenEndpoint, {
method: 'POST',
headers: { Authorization: basic, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: new URL(reusedLocation!).searchParams.get('code')!,
redirect_uri: redirectUri,
code_verifier: reusedVerifier,
resource: 'http://localhost:3000/api',
}).toString(),
})
const reusedSubject = (await reusedSubjectResponse.json()) as {
authorization_details: Array<{ type: string; identifier: string }>
}
expect(reusedSubjectResponse.status).toBe(200)
expect(reusedSubject.authorization_details).toEqual(subject.authorization_details)
const refreshedResponse = await ctx.app.request(tokenEndpoint, {
method: 'POST',
headers: { Authorization: basic, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: subject.refresh_token,
resource: 'http://localhost:3000/api',
}).toString(),
})
const refreshed = (await refreshedResponse.json()) as {
authorization_details: Array<{ type: string; identifier: string }>
}
expect(refreshedResponse.status).toBe(200)
expect(refreshed.authorization_details).toEqual(subject.authorization_details)
const now = Math.floor(Date.now() / 1000)
const assertion = await new SignJWT({})
@@ -1077,11 +1156,18 @@ describe('OAuth consent guards', () => {
requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
resource: 'http://localhost:3000/api',
scope: 'objects:read quota:read',
authorization_details: JSON.stringify([{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: workspaceId }]),
}).toString(),
})
const exchanged = (await exchangeResponse.json()) as { access_token: string; token_type: string; scope: string }
const exchanged = (await exchangeResponse.json()) as {
access_token: string
token_type: string
scope: string
authorization_details: Array<{ type: string; identifier: string }>
}
expect(exchangeResponse.status).toBe(200)
expect(exchanged).toMatchObject({ token_type: 'DPoP', scope: 'objects:read quota:read' })
expect(exchanged.authorization_details).toEqual(subject.authorization_details)
const apiUrl = 'http://localhost:3000/api/test-agent-audit'
const apiProof = await new SignJWT({
@@ -1139,6 +1225,8 @@ describe('OAuth consent guards', () => {
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 }, 'oauth-consent@example.com')
const signUpBody = (await signUpResponse.clone().json()) as { user: { id: string } }
const workspaceId = await personalOrgForUser(ctx, signUpBody.user.id)
const cookie = signUpResponse.headers
.getSetCookie()
.map((value) => value.split(';', 1)[0])
@@ -1165,13 +1253,14 @@ describe('OAuth consent guards', () => {
state: 'oauth-consent-test',
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
code_challenge_method: 'S256',
authorization_details: JSON.stringify([{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE }]),
})
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\/oauth-apps\?/)
expect(consentLocation).toMatch(/^\/oauth\/consent\?/)
const consent = await app.request(`${previewOrigin}/api/auth/oauth2/consent`, {
method: 'POST',
@@ -1182,6 +1271,7 @@ describe('OAuth consent guards', () => {
},
body: JSON.stringify({
accept: true,
authorization_details: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: workspaceId }],
oauth_query: consentLocation?.slice(consentLocation.indexOf('?') + 1),
}),
})
@@ -1193,6 +1283,102 @@ describe('OAuth consent guards', () => {
})
})
it('accepts a pushed authorization request and consumes its request URI once', async () => {
const ctx = await createTestApp()
const metadata = await ctx.app.request('/.well-known/oauth-authorization-server/api/auth')
await expect(metadata.json()).resolves.toMatchObject({
pushed_authorization_request_endpoint: 'http://localhost:3000/api/auth/oauth2/par',
request_uri_parameter_supported: true,
authorization_details_types_supported: [WORKSPACE_AUTHORIZATION_DETAIL_TYPE],
})
const signUpResponse = await signUp(ctx, 'oauth-par@example.com')
const cookie = signUpResponse.headers
.getSetCookie()
.map((value) => value.split(';', 1)[0])
.join('; ')
const registration = await ctx.app.request('/api/auth/oauth2/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'PAR Test Client',
redirect_uris: ['https://broker.example.com/par-callback'],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
scope: 'openid offline_access objects:read',
}),
})
const registered = (await registration.json()) as { client_id: string }
const pushedParams = new URLSearchParams({
client_id: registered.client_id,
redirect_uri: 'https://broker.example.com/par-callback',
response_type: 'code',
scope: 'openid offline_access objects:read',
state: 'par-test',
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
code_challenge_method: 'S256',
authorization_details: JSON.stringify([{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE }]),
})
const invalidRedirectParams = new URLSearchParams(pushedParams)
invalidRedirectParams.set('redirect_uri', 'https://attacker.example.com/callback')
const invalidRedirect = await ctx.app.request('/api/auth/oauth2/par', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: invalidRedirectParams.toString(),
})
expect(invalidRedirect.status).toBe(400)
await expect(invalidRedirect.json()).resolves.toMatchObject({ error: 'invalid_request' })
const otherRegistration = await ctx.app.request('/api/auth/oauth2/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'Other PAR Client',
redirect_uris: ['https://other.example.com/callback'],
grant_types: ['authorization_code'],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_basic',
scope: 'openid objects:read',
}),
})
const other = (await otherRegistration.json()) as { client_id: string; client_secret: string }
const mismatchedCredentials = await ctx.app.request('/api/auth/oauth2/par', {
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(`${other.client_id}:${other.client_secret}`).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: pushedParams.toString(),
})
expect(mismatchedCredentials.status).toBe(400)
await expect(mismatchedCredentials.json()).resolves.toMatchObject({ error: 'invalid_client' })
const pushed = await ctx.app.request('/api/auth/oauth2/par', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: pushedParams.toString(),
})
const pushedBody = (await pushed.json()) as { request_uri: string; expires_in: number }
expect(pushed.status, JSON.stringify(pushedBody)).toBe(201)
expect(pushedBody).toMatchObject({
request_uri: expect.stringMatching(/^urn:ietf:params:oauth:request_uri:/),
expires_in: 90,
})
const authorizeUrl = new URL('/api/auth/oauth2/authorize', 'http://localhost')
authorizeUrl.searchParams.set('client_id', registered.client_id)
authorizeUrl.searchParams.set('request_uri', pushedBody.request_uri)
const authorize = await ctx.app.request(authorizeUrl, { headers: { Cookie: cookie } })
expect(authorize.status).toBe(302)
expect(authorize.headers.get('location')).toMatch(/^\/oauth\/consent\?/)
const replay = await ctx.app.request(authorizeUrl, { headers: { Cookie: cookie } })
expect(replay.status).toBe(302)
expect(new URL(replay.headers.get('location')!, 'http://localhost').searchParams.get('error')).toBe(
'invalid_request_uri',
)
})
it('blocks partial OAuth consent changes through the Better Auth endpoint', async () => {
const ctx = await createTestApp()
+4 -1
View File
@@ -54,6 +54,7 @@ import { createSiteInvitationRepo } from './adapters/repos/site-invitations'
import { initialStorageUsageProjectionQueries } from './adapters/repos/storage-usage-breakdown'
import { createSystemOptionsRepo } from './adapters/repos/system-options'
import { recordUserActivity } from './adapters/repos/user-activity'
import { oauthPushedAuthorizationRequests } from './auth/oauth-par'
import { createOAuthProviderOptions } from './auth/oauth-provider'
import * as authSchema from './db/auth-schema'
import { orgQuotaEntitlements, orgQuotas, systemOptions } from './db/schema'
@@ -428,6 +429,7 @@ export async function createAuth(
const email = createEmailGateway(systemOptionsRepo)
const providerConfigs = await loadProviderConfigs(rawDb)
const resourceAudience = baseURL ? `${new URL(baseURL).origin}/api` : undefined
const oauthProviderOptions = createOAuthProviderOptions({ db, resourceAudience })
const usesNativeWebDavRateLimit = Boolean(authPlatform.getBinding(WEBDAV_RATE_LIMITER_BINDING))
const authOptions = {
database: drizzleAdapter(db, { provider: 'sqlite', schema: authSchema }),
@@ -721,7 +723,8 @@ export async function createAuth(
validateClient: async (clientId) => clientId === LEGACY_DOWNLOADER_CLIENT_ID,
}),
jwt(),
oauthProvider(createOAuthProviderOptions({ db, resourceAudience })),
oauthPushedAuthorizationRequests(oauthProviderOptions),
oauthProvider(oauthProviderOptions),
apiKey([
{
configId: ApiKeyTemplate.IHOST,
+109
View File
@@ -0,0 +1,109 @@
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '@shared/oauth'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { oauthPushedAuthorizationRequests, resolvePushedAuthorizationRequest } from './oauth-par'
const getOAuthProviderApi = vi.hoisted(() => vi.fn())
vi.mock('@better-auth/oauth-provider', async (importOriginal) => ({
...(await importOriginal<typeof import('@better-auth/oauth-provider')>()),
getOAuthProviderApi,
}))
const requestUri = 'urn:ietf:params:oauth:request_uri:test'
function pushedRequest(overrides: Record<string, string> = {}) {
return {
client_id: 'client-1',
redirect_uri: 'https://agent.example/callback',
response_type: 'code',
scope: 'openid',
code_challenge_method: 'S256',
code_challenge: 'a'.repeat(43),
authorization_details: JSON.stringify([{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE }]),
...overrides,
}
}
function endpointContext(body: Record<string, string>) {
return {
body,
context: { adapter: { create: vi.fn() } },
setHeader: vi.fn(),
} as never
}
async function submit(body: Record<string, string>) {
const endpoint = oauthPushedAuthorizationRequests({ scopes: ['openid'] } as never).endpoints
?.oauth2PushedAuthorizationRequest
if (!endpoint) throw new Error('PAR endpoint is not configured')
return endpoint(endpointContext(body))
}
describe('OAuth pushed authorization requests', () => {
beforeEach(() => {
getOAuthProviderApi.mockReturnValue({
getClient: vi.fn(async () => ({
clientId: 'client-1',
redirectUris: ['https://agent.example/callback'],
responseTypes: ['code'],
grantTypes: ['authorization_code'],
scopes: ['openid'],
public: true,
})),
authenticateClient: vi.fn(async () => ({ clientId: 'client-1' })),
})
})
it.each([
[{ response_type: 'token' }, 'Only the authorization code response type is supported'],
[{ code_challenge: 'short' }, 'A valid S256 PKCE challenge is required'],
[{ authorization_details: 'not-json' }, 'Invalid workspace authorization details'],
[{ authorization_details: '[]' }, 'Exactly one workspace authorization request is required'],
])('rejects invalid pushed request parameters %#', async (overrides, message) => {
await expect(submit(pushedRequest(overrides))).rejects.toMatchObject({
body: expect.objectContaining({ error_description: message }),
})
})
it('rejects clients without the authorization code grant', async () => {
getOAuthProviderApi.mockReturnValue({
getClient: vi.fn(async () => ({
clientId: 'client-1',
redirectUris: ['https://agent.example/callback'],
responseTypes: ['code'],
grantTypes: ['refresh_token'],
scopes: ['openid'],
public: true,
})),
authenticateClient: vi.fn(async () => ({ clientId: 'client-1' })),
})
await expect(submit(pushedRequest())).rejects.toMatchObject({
body: expect.objectContaining({ error_description: 'Client cannot use the authorization code grant' }),
})
})
it('deletes an expired request before rejecting it', async () => {
const adapter = {
findOne: vi.fn(async () => ({
id: 'par-1',
clientId: 'client-1',
parameters: { scope: 'openid' },
expiresAt: new Date(0),
})),
delete: vi.fn(async () => undefined),
}
await expect(
resolvePushedAuthorizationRequest({
requestUri,
clientId: 'client-1',
ctx: { context: { adapter } } as never,
}),
).resolves.toBeNull()
expect(adapter.delete).toHaveBeenCalledWith({
model: 'oauthPushedAuthorizationRequest',
where: [{ field: 'id', value: 'par-1' }],
})
})
})
+162
View File
@@ -0,0 +1,162 @@
import { getOAuthProviderApi, type oauthProvider } from '@better-auth/oauth-provider'
import { parseWorkspaceAuthorizationDetails } from '@shared/schemas'
import { APIError, type BetterAuthPlugin } from 'better-auth'
import { createAuthEndpoint } from 'better-auth/api'
import { nanoid } from 'nanoid'
import { z } from 'zod'
type OAuthProviderOptions = Parameters<typeof oauthProvider>[0]
const PAR_LIFETIME_SECONDS = 90
const PAR_REQUEST_URI_PREFIX = 'urn:ietf:params:oauth:request_uri:'
export function oauthPushedAuthorizationRequests(options: OAuthProviderOptions): BetterAuthPlugin {
return {
id: 'zpan-oauth-par',
schema: {
oauthPushedAuthorizationRequest: {
fields: {
requestUri: { type: 'string', required: true, unique: true },
clientId: { type: 'string', required: true, index: true },
parameters: { type: 'json', required: true },
expiresAt: { type: 'date', required: true },
createdAt: { type: 'date', required: true },
},
},
},
endpoints: {
oauth2PushedAuthorizationRequest: createAuthEndpoint(
'/oauth2/par',
{
method: 'POST',
body: z.record(z.string(), z.string()),
metadata: { noStore: true, allowedMediaTypes: ['application/x-www-form-urlencoded'] },
},
async (ctx) => {
const clientId = ctx.body.client_id
if (!clientId) throw oauthError('invalid_request', 'client_id is required')
const provider = getOAuthProviderApi(ctx, options)
const client = await provider.getClient(clientId)
if (!client || client.disabled) throw oauthError('invalid_client', 'Unknown OAuth client')
const authenticated = await provider.authenticateClient({ requireCredentials: !isPublicClient(client) })
if (authenticated.clientId !== clientId)
throw oauthError('invalid_client', 'Client credentials do not match client_id')
await validatePushedAuthorizationRequest(ctx.body, client, options)
const parameters = stripClientCredentials(ctx.body)
const requestUri = `${PAR_REQUEST_URI_PREFIX}${nanoid(32)}`
const now = new Date()
await ctx.context.adapter.create({
model: 'oauthPushedAuthorizationRequest',
data: {
requestUri,
clientId,
parameters,
createdAt: now,
expiresAt: new Date(now.getTime() + PAR_LIFETIME_SECONDS * 1000),
},
})
return new Response(JSON.stringify({ request_uri: requestUri, expires_in: PAR_LIFETIME_SECONDS }), {
status: 201,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', Pragma: 'no-cache' },
})
},
),
},
}
}
export async function resolvePushedAuthorizationRequest(input: {
requestUri: string
clientId: string
ctx: Parameters<NonNullable<OAuthProviderOptions['requestUriResolver']>>[0]['ctx']
}): Promise<Record<string, string> | null> {
if (!input.requestUri.startsWith(PAR_REQUEST_URI_PREFIX)) return null
const row = await input.ctx.context.adapter.findOne<{
id: string
clientId: string
parameters: Record<string, string>
expiresAt: Date
}>({
model: 'oauthPushedAuthorizationRequest',
where: [{ field: 'requestUri', value: input.requestUri }],
})
if (!row || row.clientId !== input.clientId) return null
if (row.expiresAt <= new Date()) {
await input.ctx.context.adapter.delete({
model: 'oauthPushedAuthorizationRequest',
where: [{ field: 'id', value: row.id }],
})
return null
}
await input.ctx.context.adapter.delete({
model: 'oauthPushedAuthorizationRequest',
where: [{ field: 'id', value: row.id }],
})
return row.parameters
}
function isPublicClient(client: { public?: boolean; tokenEndpointAuthMethod?: string }): boolean {
return client.public === true || client.tokenEndpointAuthMethod === 'none'
}
async function validatePushedAuthorizationRequest(
body: Record<string, string>,
client: {
redirectUris?: string[]
responseTypes?: string[]
grantTypes?: string[]
scopes?: string[]
public?: boolean
tokenEndpointAuthMethod?: string
requirePKCE?: boolean
},
options: OAuthProviderOptions,
) {
if (body.request || body.request_uri) throw oauthError('invalid_request', 'Nested request objects are not supported')
if (!body.redirect_uri || !client.redirectUris?.includes(body.redirect_uri)) {
throw oauthError('invalid_request', 'redirect_uri is not registered')
}
if (body.response_type !== 'code' || (client.responseTypes && !client.responseTypes.includes('code'))) {
throw oauthError('unsupported_response_type', 'Only the authorization code response type is supported')
}
if (client.grantTypes && !client.grantTypes.includes('authorization_code')) {
throw oauthError('unauthorized_client', 'Client cannot use the authorization code grant')
}
const scopes = (body.scope ?? '').split(/\s+/).filter(Boolean)
const allowedScopes = new Set(client.scopes ?? options.scopes ?? [])
if (scopes.some((scope) => !allowedScopes.has(scope))) throw oauthError('invalid_scope', 'Scope is not registered')
const requiresPkce = isPublicClient(client) || client.requirePKCE !== false || scopes.includes('offline_access')
if (
requiresPkce &&
(body.code_challenge_method !== 'S256' ||
!body.code_challenge ||
!/^[A-Za-z0-9_-]{43,128}$/.test(body.code_challenge))
) {
throw oauthError('invalid_request', 'A valid S256 PKCE challenge is required')
}
let details: ReturnType<typeof parseWorkspaceAuthorizationDetails>
try {
details = parseWorkspaceAuthorizationDetails(body.authorization_details)
} catch {
throw oauthError('invalid_authorization_details', 'Invalid workspace authorization details')
}
if (details.length !== 1) {
throw oauthError('invalid_authorization_details', 'Exactly one workspace authorization request is required')
}
}
function stripClientCredentials(body: Record<string, string>): Record<string, string> {
const parameters = { ...body }
delete parameters.client_secret
delete parameters.client_assertion
delete parameters.client_assertion_type
return parameters
}
function oauthError(error: string, errorDescription: string) {
return new APIError('BAD_REQUEST', { error, error_description: errorDescription })
}
+172 -59
View File
@@ -1,19 +1,26 @@
import { AuthorizationScope } from '@shared/authorization'
import { OAUTH_ACCESS_TOKEN_SECONDS, OAUTH_SCOPES } from '@shared/oauth'
import {
JWT_BEARER_GRANT_TYPE,
OAUTH_ACCESS_TOKEN_SECONDS,
OAUTH_ACCESS_TOKEN_TYPE,
OAUTH_SCOPES,
TOKEN_EXCHANGE_GRANT_TYPE,
WORKSPACE_AUTHORIZATION_DETAIL_TYPE,
} from '@shared/oauth'
import { describe, expect, it, vi } from 'vitest'
import { createOAuthProviderOptions } from './oauth-provider'
const db = {} as never
function createOptions(input?: {
findPersonalOrg?: (userId: string) => Promise<string | null>
getMemberRole?: (orgId: string, userId: string) => Promise<string | null>
canReadOrg?: (userId: string, orgId: string) => Promise<boolean>
resourceAudience?: string
}) {
return createOAuthProviderOptions({
db,
resourceAudience: input?.resourceAudience,
orgs: {
findPersonalOrg: input?.findPersonalOrg ?? vi.fn(async () => 'personal-org'),
getMemberRole: input?.getMemberRole ?? vi.fn(async () => 'owner'),
canReadOrg: input?.canReadOrg ?? vi.fn(async () => true),
},
})
}
@@ -24,86 +31,135 @@ describe('createOAuthProviderOptions', () => {
expect(options).toMatchObject({
loginPage: '/sign-in',
consentPage: '/settings/oauth-apps',
consentPage: '/oauth/consent',
accessTokenExpiresIn: OAUTH_ACCESS_TOKEN_SECONDS,
grantTypes: ['authorization_code', 'refresh_token'],
postLogin: { page: '/settings/oauth-apps' },
authorizationDetails: { typesSupported: [WORKSPACE_AUTHORIZATION_DETAIL_TYPE] },
})
expect(options.scopes).toEqual([...OAUTH_SCOPES])
expect(options.cachedTrustedClients).toBeUndefined()
expect(options.allowDynamicClientRegistration).toBe(true)
expect(options.allowUnauthenticatedClientRegistration).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 () => {
it('accepts one or more explicit workspaces during consent', async () => {
const options = createOptions()
await expect(
options.postLogin?.consentReferenceId?.({
options.authorizationDetails?.validate?.({
ctx: {} as never,
phase: 'consent',
details: [
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-1' },
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-2' },
],
requested: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE }],
user: { id: 'user-1' },
session: { activeOrganizationId: 'team-org' },
scopes: ['openid', AuthorizationScope.OBJECTS_READ],
} as never),
).resolves.toBe('team-org')
).resolves.toHaveLength(2)
})
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),
})
it('rejects inaccessible workspaces during consent', async () => {
const options = createOptions({ canReadOrg: vi.fn(async () => false) })
await expect(
options.postLogin?.consentReferenceId?.({
options.authorizationDetails?.validate?.({
ctx: {} as never,
phase: 'consent',
details: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-1' }],
requested: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE }],
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 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 OAuth' }),
body: expect.objectContaining({ error_description: 'Workspace access is required' }),
})
})
it('adds ZPan resource claims to a consent-bound access token', async () => {
it('does not allow consent to widen an explicitly requested workspace', async () => {
const options = createOptions()
await expect(
options.authorizationDetails?.validate?.({
ctx: {} as never,
phase: 'consent',
details: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-2' }],
requested: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-1' }],
user: { id: 'user-1' },
} as never),
).rejects.toMatchObject({
body: expect.objectContaining({ error_description: 'Workspace selection exceeds the authorization request' }),
})
})
it('requires both requested and selected workspaces during consent', async () => {
const options = createOptions()
await expect(
options.authorizationDetails?.validate?.({
ctx: {} as never,
phase: 'consent',
details: [],
requested: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE }],
user: { id: 'user-1' },
} as never),
).rejects.toMatchObject({
body: expect.objectContaining({ error_description: 'At least one workspace is required' }),
})
})
it('rejects duplicate workspace selections during consent', async () => {
const options = createOptions()
const detail = { type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-1' }
await expect(
options.authorizationDetails?.validate?.({
ctx: {} as never,
phase: 'consent',
details: [detail, detail],
requested: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE }],
user: { id: 'user-1' },
} as never),
).rejects.toMatchObject({
body: expect.objectContaining({ error_description: 'Workspace authorization details must be unique' }),
})
})
it('requires exactly one workspace detail in the authorization request', async () => {
const options = createOptions()
await expect(
options.authorizationDetails?.validate?.({
ctx: {} as never,
phase: 'request',
details: [
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-1' },
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-2' },
],
} as never),
).rejects.toMatchObject({
body: expect.objectContaining({ error_description: 'Exactly one workspace request is required' }),
})
})
it('resolves a workspace chooser from the existing grant without widening explicit requests', async () => {
const authorizationDetails = createOptions().authorizationDetails
const granted = [
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-1' },
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-2' },
]
expect(
await authorizationDetails?.resolve?.({ requested: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE }], granted }),
).toEqual(granted)
expect(
await authorizationDetails?.resolve?.({
requested: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-1' }],
granted,
}),
).toEqual([{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-1' }])
})
it('does not encode workspace selection in custom claims', async () => {
const options = createOptions()
await expect(
options.customAccessTokenClaims?.({
user: { id: 'user-1' },
referenceId: 'team-org',
} as never),
).resolves.toEqual({ zpan_org_id: 'team-org', zpan_actor: 'oauth' })
).resolves.toEqual({ zpan_actor: 'oauth' })
})
it('omits ZPan resource claims without user or workspace context', async () => {
@@ -115,6 +171,63 @@ describe('createOAuthProviderOptions', () => {
user: { id: 'user-1' },
scopes: [],
} as never),
).resolves.toEqual({})
).resolves.toEqual({ zpan_actor: 'oauth' })
})
it.each([
['not-json', 'authorization_details must be valid JSON'],
['[]', 'Token exchange requires exactly one workspace'],
[
JSON.stringify([{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-2' }]),
'Requested workspace is not authorized',
],
])('rejects invalid token-exchange authorization details %#', async (authorizationDetails, message) => {
const resourceAudience = 'https://files.example/api'
const options = createOptions({ resourceAudience })
const extension = options.extensions?.find((candidate) => candidate.grants?.[TOKEN_EXCHANGE_GRANT_TYPE])
const grant = extension?.grants?.[TOKEN_EXCHANGE_GRANT_TYPE]
if (!grant) throw new Error('token exchange grant is not configured')
const client = { clientId: 'client-1' }
const provider = {
authenticateClient: vi.fn(async () => ({ client })),
requireActiveAccessToken: vi
.fn()
.mockResolvedValueOnce({
sub: 'user-1',
scope: AuthorizationScope.OBJECTS_READ,
authorization_details: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-1' }],
})
.mockResolvedValueOnce({ sub: 'agent-1', zpan_actor_token: true }),
issueTokens: vi.fn(),
}
const body = {
scope: AuthorizationScope.OBJECTS_READ,
subject_token: 'subject-token',
subject_token_type: OAUTH_ACCESS_TOKEN_TYPE,
actor_token: 'actor-token',
actor_token_type: OAUTH_ACCESS_TOKEN_TYPE,
requested_token_type: OAUTH_ACCESS_TOKEN_TYPE,
resource: resourceAudience,
authorization_details: authorizationDetails,
}
await expect(
grant({
ctx: {
body,
headers: new Headers({ DPoP: 'proof' }),
context: { internalAdapter: { findUserById: vi.fn(async () => ({ id: 'user-1' })) } },
},
opts: {},
provider,
} as never),
).rejects.toMatchObject({ body: expect.objectContaining({ error_description: message }) })
})
it('advertises the JWT bearer grant alongside token exchange', () => {
const options = createOptions({ resourceAudience: 'https://files.example/api' })
const grants = options.extensions?.flatMap((extension) => Object.keys(extension.grants ?? {})) ?? []
expect(grants).toEqual(expect.arrayContaining([JWT_BEARER_GRANT_TYPE, TOKEN_EXCHANGE_GRANT_TYPE]))
})
})
+106 -35
View File
@@ -1,4 +1,5 @@
import {
type AuthorizationDetail,
consumeClientAssertion,
type OAuthProviderExtension,
type oauthProvider,
@@ -18,11 +19,14 @@ import {
OAUTH_SCOPES,
OAUTH_STANDARD_SCOPES,
TOKEN_EXCHANGE_GRANT_TYPE,
WORKSPACE_AUTHORIZATION_DETAIL_TYPE,
} from '../../shared/oauth'
import { workspaceAuthorizationDetailSchema } from '../../shared/schemas'
import { createOrgRepo } from '../adapters/repos/org'
import type { Database } from '../platform/interface'
import { resolvePushedAuthorizationRequest } from './oauth-par'
type OAuthOrgLookup = Pick<ReturnType<typeof createOrgRepo>, 'findPersonalOrg' | 'getMemberRole'>
type OAuthOrgLookup = Pick<ReturnType<typeof createOrgRepo>, 'canReadOrg'>
type OAuthProviderOptions = Parameters<typeof oauthProvider>[0]
export function createOAuthProviderOptions(input: {
@@ -50,7 +54,7 @@ export function createOAuthProviderOptions(input: {
return {
loginPage: '/sign-in',
consentPage: '/settings/oauth-apps',
consentPage: '/oauth/consent',
accessTokenExpiresIn: OAUTH_ACCESS_TOKEN_SECONDS,
m2mAccessTokenExpiresIn: OAUTH_ACTOR_TOKEN_SECONDS,
refreshTokenExpiresIn: OAUTH_REFRESH_TOKEN_SECONDS,
@@ -63,41 +67,61 @@ export function createOAuthProviderOptions(input: {
clientRegistrationRequirePKCE: true,
clientRegistrationAllowedScopes: [...OAUTH_SCOPES],
clientRegistrationDefaultScopes: [...OAUTH_STANDARD_SCOPES],
extensions: input.resourceAudience ? [externalResourceGrantExtension(input.resourceAudience)] : [],
extensions: [
oauthStandardsMetadataExtension(),
...(input.resourceAudience ? [externalResourceGrantExtension(input.resourceAudience)] : []),
],
advertisedMetadata: { scopes_supported: [...OAUTH_SCOPES] },
authorizationDetails: {
typesSupported: [WORKSPACE_AUTHORIZATION_DETAIL_TYPE],
validate: async ({ details, phase, requested, user }) => {
const workspaces = details.map(parseWorkspaceAuthorizationDetail)
if (phase === 'request') {
if (workspaces.length !== 1)
throw oauthError('invalid_authorization_details', 'Exactly one workspace request is required')
assertUniqueWorkspaceIdentifiers(workspaces)
return workspaces
}
if (phase === 'consent') {
if (!user?.id) throw oauthError('access_denied', 'Authentication is required')
const original = (requested ?? []).map(parseWorkspaceAuthorizationDetail)
if (original.length === 0 || workspaces.length === 0) {
throw oauthError('invalid_authorization_details', 'At least one workspace is required')
}
assertUniqueWorkspaceIdentifiers(workspaces)
const fixedWorkspaceId = original[0].identifier
if (fixedWorkspaceId && workspaces.some((detail) => detail.identifier !== fixedWorkspaceId)) {
throw oauthError('access_denied', 'Workspace selection exceeds the authorization request')
}
for (const detail of workspaces) {
if (!detail.identifier || !(await orgs.canReadOrg(user.id, detail.identifier))) {
throw oauthError('access_denied', 'Workspace access is required')
}
}
return workspaces
}
assertUniqueWorkspaceIdentifiers(workspaces)
return workspaces
},
isSubset: ({ requested, granted }) => workspaceAuthorizationDetailsCovered(requested, granted),
resolve: ({ requested, granted }) =>
requested.every((detail) => parseWorkspaceAuthorizationDetail(detail).identifier) ? requested : granted,
},
requestUriResolver: resolvePushedAuthorizationRequest,
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
postLogin: {
page: '/settings/oauth-apps',
shouldRedirect: async () => false,
consentReferenceId: async ({ user, session, scopes }) => {
const clientScopes = scopes.filter((scope) => scope !== 'openid' && scope !== 'profile' && scope !== 'email')
const grantableScopes = new Set<string>(OAUTH_SCOPES)
if (clientScopes.some((scope) => !grantableScopes.has(scope))) {
throw oauthError('invalid_scope', 'Scope is not grantable')
}
const orgId = typeof session.activeOrganizationId === 'string' ? session.activeOrganizationId : null
const selectedOrgId = orgId || (await orgs.findPersonalOrg(user.id))
if (!selectedOrgId) throw oauthError('invalid_request', 'A workspace is required for 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 OAuth',
})
}
return selectedOrgId
},
},
customAccessTokenClaims: async ({ user, referenceId }) => {
if (!user?.id || !referenceId) return {}
return {
zpan_org_id: referenceId,
zpan_actor: 'oauth',
}
},
customAccessTokenClaims: async ({ user }) => (user?.id ? { zpan_actor: 'oauth' } : {}),
}
}
function oauthStandardsMetadataExtension(): OAuthProviderExtension {
return {
metadata: ({ ctx }) => ({
pushed_authorization_request_endpoint: `${ctx.context.baseURL}/oauth2/par`,
require_pushed_authorization_requests: false,
}),
}
}
@@ -145,8 +169,15 @@ function externalResourceGrantExtension(resourceAudience: string): OAuthProvider
throw oauthError('invalid_scope', 'Requested scope exceeds the connected account grant')
}
if (typeof subject.sub !== 'string') throw oauthError('invalid_grant', 'Subject token has no user')
const orgId = typeof subject.zpan_org_id === 'string' ? subject.zpan_org_id : undefined
if (!orgId) throw oauthError('invalid_grant', 'Subject token has no workspace')
const authorizationDetails = parseTokenExchangeAuthorizationDetails(ctx.body)
const subjectAuthorizationDetails = Array.isArray(subject.authorization_details)
? (subject.authorization_details as AuthorizationDetail[])
: []
if (!workspaceAuthorizationDetailsCovered(authorizationDetails, subjectAuthorizationDetails)) {
throw oauthError('invalid_authorization_details', 'Requested workspace is not authorized')
}
const orgId = parseWorkspaceAuthorizationDetail(authorizationDetails[0]).identifier
if (!orgId) throw oauthError('invalid_authorization_details', 'Token exchange requires one workspace')
const user = await ctx.context.internalAdapter.findUserById(subject.sub)
if (!user) throw oauthError('invalid_grant', 'Subject user no longer exists')
@@ -154,7 +185,7 @@ function externalResourceGrantExtension(resourceAudience: string): OAuthProvider
client,
scopes: requestedScopes,
user,
referenceId: orgId,
authorizationDetails,
resources: [resourceAudience],
accessTokenClaims: {
act: {
@@ -169,6 +200,46 @@ function externalResourceGrantExtension(resourceAudience: string): OAuthProvider
}
}
function parseWorkspaceAuthorizationDetail(detail: AuthorizationDetail) {
const parsed = workspaceAuthorizationDetailSchema.safeParse(detail)
if (!parsed.success) throw oauthError('invalid_authorization_details', 'Invalid workspace authorization detail')
return parsed.data
}
function assertUniqueWorkspaceIdentifiers(details: Array<{ identifier?: string }>) {
const identifiers = details.flatMap((detail) => (detail.identifier ? [detail.identifier] : []))
if (new Set(identifiers).size !== identifiers.length) {
throw oauthError('invalid_authorization_details', 'Workspace authorization details must be unique')
}
}
function workspaceAuthorizationDetailsCovered(
requested: AuthorizationDetail[],
granted: AuthorizationDetail[],
): boolean {
const grantedIds = new Set(
granted.map(parseWorkspaceAuthorizationDetail).flatMap((detail) => (detail.identifier ? [detail.identifier] : [])),
)
return requested
.map(parseWorkspaceAuthorizationDetail)
.every((detail) => (detail.identifier ? grantedIds.has(detail.identifier) : grantedIds.size > 0))
}
function parseTokenExchangeAuthorizationDetails(body: unknown): AuthorizationDetail[] {
const raw = body && typeof body === 'object' ? (body as Record<string, unknown>).authorization_details : undefined
if (typeof raw !== 'string') throw oauthError('invalid_authorization_details', 'authorization_details is required')
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw oauthError('invalid_authorization_details', 'authorization_details must be valid JSON')
}
if (!Array.isArray(parsed) || parsed.length !== 1) {
throw oauthError('invalid_authorization_details', 'Token exchange requires exactly one workspace')
}
return parsed.map((detail) => parseWorkspaceAuthorizationDetail(detail as AuthorizationDetail))
}
async function verifyAgentAssertion(
ctx: Parameters<NonNullable<OAuthProviderExtension['grants']>[string]>[0]['ctx'],
opts: Parameters<NonNullable<OAuthProviderExtension['grants']>[string]>[0]['opts'],
+13
View File
@@ -5,6 +5,7 @@ import {
oauthAccessToken,
oauthClient,
oauthConsent,
oauthPushedAuthorizationRequest,
oauthRefreshToken,
user,
} from './auth-schema.js'
@@ -164,4 +165,16 @@ describe('OAuth tables', () => {
'oauthConsent_user_id_idx',
])
})
it('declares pushed authorization request relationships and lookup indexes', () => {
const { foreignKeys, indexes } = getTableConfig(oauthPushedAuthorizationRequest)
expect(oauthPushedAuthorizationRequest.requestUri.isUnique).toBe(true)
expect(oauthPushedAuthorizationRequest.parameters.notNull).toBe(true)
expect(foreignKeys.map((foreignKey) => foreignKey.reference().foreignColumns[0].name)).toEqual(['client_id'])
expect(indexes.map((index) => index.config.name).sort()).toEqual([
'oauthPushedAuthorizationRequest_client_id_idx',
'oauthPushedAuthorizationRequest_expires_at_idx',
])
})
})
+24
View File
@@ -1,5 +1,6 @@
import { relations, sql } from 'drizzle-orm'
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import type { WorkspaceAuthorizationDetail } from '../../shared/schemas'
export const user = sqliteTable(
'user',
@@ -343,6 +344,7 @@ export const oauthRefreshToken = sqliteTable(
authorizationCodeId: text('authorization_code_id'),
resources: text('resources'),
requestedUserInfoClaims: text('requested_user_info_claims'),
authorizationDetails: text('authorization_details', { mode: 'json' }).$type<WorkspaceAuthorizationDetail[]>(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
@@ -377,6 +379,7 @@ export const oauthAccessToken = sqliteTable(
authorizationCodeId: text('authorization_code_id'),
resources: text('resources'),
requestedUserInfoClaims: text('requested_user_info_claims'),
authorizationDetails: text('authorization_details', { mode: 'json' }).$type<WorkspaceAuthorizationDetail[]>(),
refreshId: text('refresh_id').references(() => oauthRefreshToken.id, { onDelete: 'cascade' }),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
@@ -406,6 +409,7 @@ export const oauthConsent = sqliteTable(
referenceId: text('reference_id'),
resources: text('resources'),
requestedUserInfoClaims: text('requested_user_info_claims'),
authorizationDetails: text('authorization_details', { mode: 'json' }).$type<WorkspaceAuthorizationDetail[]>(),
scopes: text('scopes').notNull(), // JSON-serialized string[]
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
@@ -427,6 +431,26 @@ export const oauthClientAssertion = sqliteTable('oauthClientAssertion', {
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
})
export const oauthPushedAuthorizationRequest = sqliteTable(
'oauthPushedAuthorizationRequest',
{
id: text('id').primaryKey(),
requestUri: text('request_uri').notNull().unique(),
clientId: text('client_id')
.notNull()
.references(() => oauthClient.clientId, { onDelete: 'cascade' }),
parameters: text('parameters', { mode: 'json' }).$type<Record<string, string>>().notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
},
(table) => [
index('oauthPushedAuthorizationRequest_client_id_idx').on(table.clientId),
index('oauthPushedAuthorizationRequest_expires_at_idx').on(table.expiresAt),
],
)
export const oauthJwtRevocation = sqliteTable(
'oauthJwtRevocation',
{
+13 -2
View File
@@ -1,4 +1,5 @@
import { env } from 'cloudflare:workers'
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '@shared/oauth'
import { describe, expect, it } from 'vitest'
import { createApp } from '../../app'
import { createAuth } from '../../auth'
@@ -111,20 +112,30 @@ describe('[CF] Auth API', () => {
state: 'cf-oauth',
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
code_challenge_method: 'S256',
authorization_details: JSON.stringify([{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE }]),
})
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\/oauth-apps\?/)
expect(consentLocation).toMatch(/^\/oauth\/consent\?/)
const oauthQuery = consentLocation?.slice(consentLocation.indexOf('?') + 1) ?? ''
const contextResponse = await app.request(`/api/oauth-consent?oauthQuery=${encodeURIComponent(oauthQuery)}`, {
headers: { Cookie: cookie },
})
const context = (await contextResponse.json()) as { workspaces: Array<{ id: string }> }
expect(contextResponse.status).toBe(200)
expect(context.workspaces).toHaveLength(1)
const consent = await app.request('/api/oauth-consent', {
method: 'POST',
headers: { Cookie: cookie, Origin: 'http://localhost', 'Content-Type': 'application/json' },
body: JSON.stringify({
accept: true,
oauthQuery: consentLocation?.slice(consentLocation.indexOf('?') + 1),
oauthQuery,
workspaceIds: [context.workspaces[0].id],
}),
})
const consentBody = await consent.text()
+61 -10
View File
@@ -1,7 +1,11 @@
import { AuthorizationScope } from '@shared/authorization'
import { OAUTH_ACCESS_TOKEN_SECONDS, OAUTH_REFRESH_TOKEN_SECONDS } from '@shared/oauth'
import {
OAUTH_ACCESS_TOKEN_SECONDS,
OAUTH_REFRESH_TOKEN_SECONDS,
WORKSPACE_AUTHORIZATION_DETAIL_TYPE,
} from '@shared/oauth'
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import * as authSchema from '../db/auth-schema.js'
import { authedHeaders, createTestApp } from '../test/setup.js'
@@ -60,7 +64,7 @@ async function insertGrant(
id: 'grant-1',
clientId: CLIENT_ID,
userId: input.userId,
referenceId: input.orgId,
authorizationDetails: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: input.orgId }],
scopes: JSON.stringify(input.scopes),
createdAt: now,
updatedAt: now,
@@ -70,7 +74,7 @@ async function insertGrant(
token: 'hashed-refresh',
clientId: CLIENT_ID,
userId: input.userId,
referenceId: input.orgId,
authorizationDetails: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: input.orgId }],
expiresAt: new Date(Date.now() + 60_000),
createdAt: now,
scopes: JSON.stringify(input.scopes),
@@ -80,7 +84,7 @@ async function insertGrant(
token: 'hashed-access',
clientId: CLIENT_ID,
userId: input.userId,
referenceId: input.orgId,
authorizationDetails: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: input.orgId }],
refreshId: 'refresh-1',
expiresAt: new Date(Date.now() + 60_000),
createdAt: now,
@@ -94,6 +98,7 @@ function oauthQuery() {
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: `${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ} openid offline_access`,
authorization_details: JSON.stringify([{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE }]),
}).toString()
}
@@ -112,8 +117,9 @@ describe('OAuth grants API integration', () => {
await expect(res.json()).resolves.toEqual({
clientId: CLIENT_ID,
clientName: CLIENT_NAME,
instanceOrigin: 'http://localhost',
workspace: { id: orgId, name: expect.any(String) },
clientOrigin: new URL(REDIRECT_URI).origin,
workspaces: [{ id: orgId, name: expect.any(String) }],
requestedWorkspaceIds: [],
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
standardScopes: ['openid', 'offline_access'],
redirectUri: REDIRECT_URI,
@@ -132,13 +138,59 @@ describe('OAuth grants API integration', () => {
const res = await app.request('/api/oauth-consent', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ accept: true, oauthQuery: `client_id=${CLIENT_ID}&response_type=token` }),
body: JSON.stringify({
accept: true,
oauthQuery: `client_id=${CLIENT_ID}&response_type=token`,
workspaceIds: ['org-1'],
}),
})
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({ error: { message: 'Invalid OAuth request' } })
})
it('rejects workspace selections outside the server-owned consent context', async () => {
const { app, db } = await createTestApp()
await insertClient(db)
const headers = await authedHeaders(app, 'agent-invalid-workspace@example.com')
const res = await app.request('/api/oauth-consent', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
accept: true,
oauthQuery: oauthQuery(),
workspaceIds: ['org-not-owned'],
}),
})
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({ error: { message: 'Invalid workspace selection' } })
})
it('forwards validated workspace authorization details to Better Auth', async () => {
const { app, auth, db } = await createTestApp()
await insertClient(db)
const headers = await authedHeaders(app, 'agent-valid-workspace@example.com')
const { orgId } = await getUserAndPersonalOrg(db, 'agent-valid-workspace@example.com')
const handler = vi
.spyOn(auth, 'handler')
.mockResolvedValue(Response.json({ url: 'https://flareauth.example/callback?code=issued' }))
const res = await app.request('/api/oauth-consent', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ accept: true, oauthQuery: oauthQuery(), workspaceIds: [orgId] }),
})
expect(res.status).toBe(200)
const forwarded = handler.mock.calls[0]?.[0]
await expect(forwarded?.json()).resolves.toMatchObject({
accept: true,
authorization_details: [{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: orgId }],
})
})
it('lists and revokes the current user dynamic-client grant family', async () => {
const { app, db } = await createTestApp()
await insertClient(db)
@@ -155,8 +207,7 @@ describe('OAuth grants API integration', () => {
clientId: CLIENT_ID,
clientName: CLIENT_NAME,
userId,
orgId,
workspaceName: expect.any(String),
workspaces: [{ id: orgId, name: expect.any(String) }],
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
createdAt: '2026-07-29T12:00:00.000Z',
lastUsedAt: null,
+16 -7
View File
@@ -1,5 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '@shared/oauth'
import {
oauthConsentContextSchema,
oauthConsentResultSchema,
@@ -9,6 +10,7 @@ import {
import type { Env } from '../middleware/platform'
import { getOAuthConsentContext } from '../usecases/oauth-consent'
import { listOAuthGrants, revokeOAuthGrant } from '../usecases/oauth-grants'
import { badRequest } from '../usecases/ports'
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
const paramsSchema = z.object({ grantId: z.string().min(1) })
@@ -84,21 +86,21 @@ export const oauthGrants = new OpenAPIHono<Env>()
const context = await getOAuthConsentContext(c.get('deps'), {
db: c.get('platform').db,
userId: c.get('userId')!,
orgId: c.get('orgId'),
requestUrl: c.req.url,
oauthQuery,
})
return c.json(context, 200)
})
.openapi(consentSubmitRoute, async (c) => {
const { accept, oauthQuery } = c.req.valid('json')
await getOAuthConsentContext(c.get('deps'), {
const { accept, oauthQuery, workspaceIds } = c.req.valid('json')
const context = await getOAuthConsentContext(c.get('deps'), {
db: c.get('platform').db,
userId: c.get('userId')!,
orgId: c.get('orgId'),
requestUrl: c.req.url,
oauthQuery,
})
const availableWorkspaceIds = new Set(context.workspaces.map((workspace) => workspace.id))
if (accept && (workspaceIds.length === 0 || workspaceIds.some((id) => !availableWorkspaceIds.has(id)))) {
throw badRequest('Invalid workspace selection')
}
const headers = new Headers(c.req.raw.headers)
headers.set('content-type', 'application/json')
headers.delete('content-length')
@@ -106,7 +108,14 @@ export const oauthGrants = new OpenAPIHono<Env>()
new Request(new URL('/api/auth/oauth2/consent', c.req.url), {
method: 'POST',
headers,
body: JSON.stringify({ accept, oauth_query: oauthQuery }),
body: JSON.stringify({
accept,
oauth_query: oauthQuery,
authorization_details: workspaceIds.map((identifier) => ({
type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE,
identifier,
})),
}),
}),
)
const body = await response.json().catch(() => null)
-47
View File
@@ -1,47 +0,0 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { OAUTH_RESOURCE_SCOPES, OAUTH_SCOPE_DESCRIPTIONS } from '@shared/oauth'
import type { Env } from '../middleware/platform'
import { authRoute, jsonContent } from './openapi'
const scopeSchema = z.object({
value: z.string(),
description: z.string(),
})
const route = authRoute(
{ public: true },
{
operationId: 'listOAuthResourceScopes',
summary: 'List OAuth resource scopes',
description:
'Public scope catalog for external authorization controllers. Runtime API operations remain protected by their x-zpan-auth declarations.',
tags: ['OAuth'],
method: 'get',
path: '/',
responses: {
200: jsonContent(z.object({ scopes: z.array(scopeSchema) }), 'OAuth resource scope catalog'),
},
},
)
// FlareAuth derives requestable business scopes from standard OAuth operation
// security. The empty alternative truthfully documents that this catalog
// endpoint itself is public. Protected business operations remain unbound so a
// delegated credential hook can sign them before Restish's built-in auth runs.
const scopeCatalogSecurity: Record<string, string[]>[] = [{ oauth2: [...OAUTH_RESOURCE_SCOPES] }, {}]
const scopeCatalogRoute = Object.assign(route, {
security: scopeCatalogSecurity,
'x-mcp-ignore': true,
})
export const oauthResourceScopes = new OpenAPIHono<Env>().openapi(scopeCatalogRoute, (c) =>
c.json(
{
scopes: OAUTH_RESOURCE_SCOPES.map((value) => ({
value,
description: OAUTH_SCOPE_DESCRIPTIONS[value],
})),
},
200,
),
)
+2 -4
View File
@@ -2362,7 +2362,7 @@ describe('Objects API — quota enforcement', () => {
expect(quotaRows[0]).toEqual({ used: 140, quota: 100 })
})
it('rejects upload preparation when storage entitlements are exhausted', async () => {
it('rejects upload preparation when no capacity offer can satisfy the request', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app)
@@ -2390,15 +2390,13 @@ describe('Objects API — quota enforcement', () => {
expect(storageRows[0].used).toBe(500)
})
it('returns 422 before upload when the file would exceed quota', async () => {
it('returns 422 before upload when no capacity store is bound', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
// quota = 100, used = 90, file size = 50 → exceeds
await setOrgQuota(db, orgId, 100, 90)
stubCapacityStoreWithoutOffers()
const res = await createDraftResponse(app, headers, { name: 'toobig.txt', size: 50 })
expect(res.status).toBe(422)
const body = (await res.json()) as { error: { message: string; details: Array<{ reason: string }> } }
+1
View File
@@ -244,6 +244,7 @@ const createObjectRoute = authRoute(
400: errorResponse('No active organization or file too large'),
403: errorResponse('Forbidden'),
409: errorResponse('Name conflict'),
422: errorResponse('Quota exceeded without an eligible capacity offer'),
503: errorResponse('No storage configured'),
},
},
+21 -38
View File
@@ -1,5 +1,4 @@
import { createRoute, type RouteConfig, type z } from '@hono/zod-openapi'
import { OAUTH_SCOPES } from '@shared/oauth'
import { errorResponseSchema } from '@shared/schemas'
import { authorize, type RouteAuthorizationDeclaration, type ScopedAuthorizationPolicy } from '../middleware/authz'
@@ -24,8 +23,6 @@ export const jsonBody = <T extends z.ZodType>(schema: T) => ({
// `jsonError`; this just documents the response shape in the OpenAPI document.
export const errorResponse = (description: string) => jsonContent(errorResponseSchema, description)
const OAUTH_SCOPE_SET = new Set<string>(OAUTH_SCOPES)
export function authRoute<P extends string, T extends Omit<RouteConfig, 'path'> & { path: P }>(
auth: RouteAuthorizationDeclaration,
config: T,
@@ -35,8 +32,7 @@ export function authRoute<P extends string, T extends Omit<RouteConfig, 'path'>
...config,
middleware,
...openApiSecurity(auth),
'x-zpan-auth': openApiAuthMetadata(auth),
...openApiCliMetadata(auth),
...openApiAuthorizationConstraints(auth),
} as T) as T & { getRoutingPath(): string }
}
@@ -44,6 +40,7 @@ export function findOperationsMissingAuthContract(paths: Record<string, Record<s
const methods = new Set(['get', 'put', 'post', 'delete', 'patch', 'head', 'options'])
const missing: string[] = []
for (const [path, operations] of Object.entries(paths)) {
if (path.startsWith('/api/auth/')) continue
for (const [method, operation] of Object.entries(operations)) {
if (!methods.has(method)) continue
if (!operation || typeof operation !== 'object') continue
@@ -54,46 +51,32 @@ export function findOperationsMissingAuthContract(paths: Record<string, Record<s
return missing
}
function openApiAuthorizationConstraints(auth: RouteAuthorizationDeclaration): Record<string, unknown> {
if ('public' in auth) return {}
return {
'x-zpan-authorization-constraints': {
requiredScopes: [...auth.scopes],
...(auth.oauth === false ? { oauth: false } : {}),
...(auth.minTeamRole ? { minTeamRole: auth.minTeamRole } : {}),
...(auth.siteRole ? { siteRole: auth.siteRole } : {}),
},
}
}
function hasValidAuthContract(operation: object): boolean {
if (!('x-zpan-auth' in operation)) return false
const auth = operation['x-zpan-auth']
if (!auth || typeof auth !== 'object' || !('public' in auth) || !('scopes' in auth)) return false
if (typeof auth.public !== 'boolean' || !Array.isArray(auth.scopes)) return false
return auth.public ? auth.scopes.length === 0 : auth.scopes.length > 0
if (!('security' in operation) || !Array.isArray(operation.security)) return false
if (operation.security.length === 0) return true
const constraints =
'x-zpan-authorization-constraints' in operation ? operation['x-zpan-authorization-constraints'] : null
if (!constraints || typeof constraints !== 'object' || !('requiredScopes' in constraints)) return false
return Array.isArray(constraints.requiredScopes) && constraints.requiredScopes.length > 0
}
function openApiSecurity(auth: RouteAuthorizationDeclaration): { security?: Record<string, string[]>[] } {
if ('public' in auth) return { security: [] }
if (isAgentCallablePolicy(auth)) return {}
return { security: openApiPolicySecurity(auth) }
}
function openApiCliMetadata(auth: RouteAuthorizationDeclaration): Record<string, boolean> {
if ('public' in auth) return {}
return isAgentCallablePolicy(auth) ? {} : { 'x-mcp-ignore': true }
}
function openApiAuthMetadata(auth: RouteAuthorizationDeclaration): Record<string, unknown> {
if ('public' in auth) return { public: true, scopes: [] }
return {
public: false,
...openApiPolicyMetadata(auth),
}
}
function openApiPolicySecurity(policy: ScopedAuthorizationPolicy): Record<string, string[]>[] {
return [{ bearerAuth: [...policy.scopes] }, { cookieAuth: [] }]
}
function isAgentCallablePolicy(policy: ScopedAuthorizationPolicy): boolean {
return policy.scopes.every((scope) => OAUTH_SCOPE_SET.has(scope))
}
function openApiPolicyMetadata(policy: ScopedAuthorizationPolicy): Record<string, unknown> {
return {
scopes: [...policy.scopes],
minTeamRole: policy.minTeamRole ?? null,
siteRole: policy.siteRole ?? null,
auditDenied: policy.auditDenied !== false,
}
return [...(policy.oauth === false ? [] : [{ oauth2: [...policy.scopes] }]), { bearerAuth: [] }, { cookieAuth: [] }]
}
@@ -869,6 +869,12 @@ describe('Quota Store API', () => {
})
const response = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response
const verified = { ...capacityAttempt(), status: 'verified', verifiedAt: '2026-07-30T00:01:00.000Z' }
const paidPending = {
...capacityAttempt(),
status: 'paid_pending_fulfillment',
settlementResponseHeader: 'receipt-header',
settledAt: '2026-07-30T00:02:00.000Z',
}
const delivered = {
...capacityAttempt(),
status: 'delivered',
@@ -881,6 +887,8 @@ describe('Quota Store API', () => {
.mockResolvedValueOnce(response(cloudProduct()))
.mockResolvedValueOnce(response(capacityReceiver()))
.mockResolvedValueOnce(response(verified))
.mockResolvedValueOnce(response(paidPending))
.mockResolvedValueOnce(response(paidPending))
.mockResolvedValueOnce(response(capacityPublication()))
.mockResolvedValueOnce(response(cloudProduct()))
.mockResolvedValueOnce(response(capacityReceiver()))
@@ -894,9 +902,27 @@ describe('Quota Store API', () => {
})
const pendingResponse = await request()
expect(pendingResponse.status).toBe(202)
await expect(pendingResponse.json()).resolves.toEqual({
attemptId: 'attempt-1',
orderId: 'order-cloud-1',
resourceId: 'cloud-pkg-1:price-usd',
requestHash: 'hash-1',
status: 'pending',
})
const deliveredResponse = await request()
expect(deliveredResponse.status).toBe(200)
expect(deliveredResponse.headers.get('PAYMENT-RESPONSE')).toBe('receipt-header')
const deliveredBody = await deliveredResponse.json()
expect(deliveredBody).toEqual({
attemptId: 'attempt-1',
orderId: 'order-cloud-1',
resourceId: 'cloud-pkg-1:price-usd',
requestHash: 'hash-1',
status: 'delivered',
})
expect(deliveredBody).not.toHaveProperty('paymentRequiredHeader')
expect(deliveredBody).not.toHaveProperty('paymentSignatureHeader')
expect(deliveredBody).not.toHaveProperty('settlementResponseHeader')
})
it('allows team checkout for the team owner and targets the team org [spec: quota-store/team-checkout]', async () => {
+13 -5
View File
@@ -1,6 +1,13 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { checkoutInputSchema, discountQuoteInputSchema, redeemGiftCardInputSchema } from '@shared/schemas'
import {
capacityPurchaseDeliveredResultSchema,
capacityPurchasePendingResultSchema,
checkoutInputSchema,
discountQuoteInputSchema,
redeemGiftCardInputSchema,
x402PaymentRequiredSchema,
} from '@shared/schemas'
import type { Env } from '../../middleware/platform'
import { requireFeature } from '../../middleware/require-feature'
import { badGateway, badRequest, forbidden } from '../../usecases/ports'
@@ -182,10 +189,10 @@ const capacityPurchaseRoute = authRoute(
...jsonBody(capacityPurchaseInputSchema),
},
responses: {
200: cloudBody('Capacity delivered'),
202: cloudBody('Payment accepted; capacity fulfillment is pending'),
200: jsonContent(capacityPurchaseDeliveredResultSchema, 'Capacity delivered'),
202: jsonContent(capacityPurchasePendingResultSchema, 'Payment accepted; capacity fulfillment is pending'),
400: errorResponse('Invalid capacity offer'),
402: cloudBody('x402 payment required'),
402: jsonContent(x402PaymentRequiredSchema, 'x402 payment required'),
403: errorResponse('License not bound'),
409: errorResponse('Purchase request conflict'),
429: errorResponse('Too many pending capacity purchases'),
@@ -362,7 +369,8 @@ export const cloudStore = app
return c.json(result.paymentRequired, 402)
}
if (result.paymentResponseHeader) c.header('PAYMENT-RESPONSE', result.paymentResponseHeader)
return c.json(result.attempt, result.kind === 'delivered' ? 200 : 202)
if (result.purchase.status === 'delivered') return c.json(result.purchase, 200)
return c.json(result.purchase, 202)
})
.openapi(discountRoute, async (c) => {
const result = await getDiscountQuote(c.get('deps'), getCloudBaseUrl(c), c.req.valid('json'))
+1 -1
View File
@@ -104,7 +104,7 @@ const restoreObjectRoute = authRoute(
)
const purgeObjectRoute = authRoute(
{ scopes: [AuthorizationScope.OBJECTS_PURGE], minTeamRole: 'editor' },
{ scopes: [AuthorizationScope.OBJECTS_PURGE], oauth: false, minTeamRole: 'editor' },
{
operationId: 'purgeTrashObject',
summary: 'Permanently delete trashed object',
+51
View File
@@ -0,0 +1,51 @@
import { Hono } from 'hono'
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { describe, expect, it, vi } from 'vitest'
import { AppError } from '../usecases/ports'
import { authMiddleware } from './auth'
import type { Env } from './platform'
const verifyAccessTokenRequest = vi.hoisted(() => vi.fn())
vi.mock('@better-auth/oauth-provider/resource-client', () => ({
oauthProviderResourceClient: () => ({
getActions: () => ({ verifyAccessTokenRequest }),
}),
}))
function createApp() {
const app = new Hono<Env>()
app.onError((error, c) => {
const status = (error instanceof AppError ? error.httpStatus : 500) as ContentfulStatusCode
return c.json({ message: error.message }, status)
})
app.use('*', async (c, next) => {
c.set('auth', {
$context: Promise.resolve({ baseURL: 'https://files.example', internalAdapter: {} }),
} as never)
c.set('platform', { db: {} } as never)
c.set('deps', {} as never)
await next()
})
app.use('*', authMiddleware)
app.get('/api/test', (c) => c.json({ ok: true }))
return app
}
describe('OAuth authentication middleware', () => {
it('rejects malformed workspace authorization details in an otherwise shaped token', async () => {
verifyAccessTokenRequest.mockResolvedValue({
sub: 'user-1',
client_id: 'client-1',
act: { sub: 'agent-1', iss: 'https://agent.example' },
authorization_details: 'not-json',
})
const response = await createApp().request('https://files.example/api/test', {
headers: { Authorization: 'DPoP access-token', DPoP: 'proof' },
})
expect(response.status).toBe(401)
await expect(response.json()).resolves.toEqual({ message: 'Unauthorized' })
})
})
+25 -6
View File
@@ -1,5 +1,11 @@
import { oauthProviderResourceClient } from '@better-auth/oauth-provider/resource-client'
import { AuthorizationScope, isAuthorizationScope, permissionScopes } from '@shared/authorization'
import {
AuthorizationScope,
CANONICAL_AUTHORIZATION_SCOPES,
isAuthorizationScope,
permissionScopes,
} from '@shared/authorization'
import { parseWorkspaceAuthorizationDetails } from '@shared/schemas'
import { createDpopReplayStore } from 'better-auth/oauth2'
import { createMiddleware } from 'hono/factory'
import {
@@ -37,7 +43,7 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
throw error
}
const userId = typeof payload.sub === 'string' ? payload.sub : null
const orgId = typeof payload.zpan_org_id === 'string' ? payload.zpan_org_id : null
const orgId = workspaceOrgIdFromClaim(payload.authorization_details)
const clientId = typeof payload.client_id === 'string' ? payload.client_id : null
const actorClaims = payload.act && typeof payload.act === 'object' ? (payload.act as Record<string, unknown>) : null
const actorSubject = actorClaims?.sub
@@ -52,6 +58,7 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
throw dpopUnauthorized(audience)
}
if (await c.get('deps').userAdmin.isBanned(userId)) throw unauthorized('Unauthorized')
const role = (await c.get('deps').userAdmin.getSiteRole(userId)) ?? undefined
const scopes = typeof payload.scope === 'string' ? payload.scope.split(/\s+/).filter(isAuthorizationScope) : []
c.set('principal', {
kind: 'oauth',
@@ -69,7 +76,7 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
workspace: { mode: 'bound', orgId },
grantedScopes: new Set(scopes),
actor: { type: 'oauth', ref: actorSubject, issuer: actorIssuer },
state: { clientId },
state: { clientId, role },
})
c.set('userId', userId)
c.set('userRole', null)
@@ -133,6 +140,7 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
}
if (apiKey) {
if (await deps.userAdmin.isBanned(apiKey.referenceId)) throw unauthorized('Unauthorized')
const role = (await deps.userAdmin.getSiteRole(apiKey.referenceId)) ?? undefined
const orgId = apiKey.scope.mode === 'workspace' ? apiKey.scope.orgId : null
const userId = apiKey.referenceId
c.set('principal', {
@@ -151,7 +159,7 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
workspace: orgId ? { mode: 'bound', orgId } : { mode: 'none', orgId: null },
grantedScopes: new Set(permissionScopes(apiKey.permissions)),
actor: { type: 'api_key', ref: apiKey.id },
state: { configId: apiKey.configId, enabled: true },
state: { configId: apiKey.configId, enabled: true, role },
})
c.set('userId', userId)
c.set('userRole', null)
@@ -161,6 +169,8 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
}
const bootstrap = await deps.downloaderBootstrapCredentials.resolve(platform, token, new Date())
if (bootstrap) {
if (await deps.userAdmin.isBanned(bootstrap.userId)) throw unauthorized('Unauthorized')
const role = (await deps.userAdmin.getSiteRole(bootstrap.userId)) ?? undefined
c.set('userId', bootstrap.userId)
c.set('userRole', null)
c.set('orgId', null)
@@ -177,7 +187,7 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
workspace: { mode: 'none', orgId: null },
grantedScopes: new Set([AuthorizationScope.DOWNLOADERS_CREATE]),
actor: { type: 'user', ref: bootstrap.userId },
state: { clientId: LEGACY_DOWNLOADER_CLIENT_ID, scope: LEGACY_DOWNLOADER_REGISTER_SCOPE },
state: { clientId: LEGACY_DOWNLOADER_CLIENT_ID, scope: LEGACY_DOWNLOADER_REGISTER_SCOPE, role },
})
if (!bootstrap.active || !isDownloaderBootstrapRegistrationRequest(c.req.method, c.req.path)) {
throw unauthorized('Unauthorized')
@@ -210,7 +220,7 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
credential: 'session',
userId: result.user.id,
workspace: { mode: 'selected', orgId },
grantedScopes: null,
grantedScopes: new Set(CANONICAL_AUTHORIZATION_SCOPES),
actor: { type: 'user', ref: result.user.id },
state: { firstParty: true, role: result.user.role },
})
@@ -236,3 +246,12 @@ function isUnauthorizedApiError(error: unknown): boolean {
const candidate = error as Error & { status?: unknown; statusCode?: unknown }
return candidate.status === 'UNAUTHORIZED' || candidate.status === 401 || candidate.statusCode === 401
}
function workspaceOrgIdFromClaim(value: unknown): string | null {
try {
const details = parseWorkspaceAuthorizationDetails(value)
return details.length === 1 ? (details[0].identifier ?? null) : null
} catch {
return null
}
}
+30 -4
View File
@@ -1,4 +1,4 @@
import { AuthorizationScope } from '@shared/authorization'
import { AuthorizationScope, CANONICAL_AUTHORIZATION_SCOPES } from '@shared/authorization'
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js'
@@ -419,7 +419,7 @@ describe('evaluateAuthorization', () => {
credential: 'session' as const,
userId: 'user-1',
workspace: { mode: 'selected' as const, orgId: 'org-1' },
grantedScopes: null,
grantedScopes: new Set(CANONICAL_AUTHORIZATION_SCOPES),
actor: { type: 'user' as const, ref: 'user-1' },
state: { firstParty: true as const, role: 'admin' },
}
@@ -469,7 +469,7 @@ describe('evaluateAuthorization', () => {
workspace: { mode: 'none' as const, orgId: null },
grantedScopes: new Set([AuthorizationScope.DOWNLOADERS_CREATE]),
actor: { type: 'user' as const, ref: 'user-1' },
state: { clientId: 'zpan-cli' as const, scope: 'downloader:register' as const },
state: { clientId: 'zpan-cli' as const, scope: 'downloader:register' as const, role: 'admin' },
}
await expect(
@@ -495,6 +495,32 @@ describe('evaluateAuthorization', () => {
).resolves.toMatchObject({ allowed: false, status: 403, reason: 'missing_scope' })
})
it('enforces OAuth credential exclusions independently from scopes', async () => {
const oauthContext = {
credential: 'oauth' as const,
userId: 'user-1',
workspace: { mode: 'bound' as const, orgId: 'org-1' },
grantedScopes: new Set([AuthorizationScope.OBJECTS_PURGE]),
actor: { type: 'oauth' as const, ref: 'grant-1', issuer: 'https://realmroot.example' },
state: { clientId: 'agent-client' },
}
await expect(
evaluateAuthorization({
context: oauthContext,
declaration: { scopes: [AuthorizationScope.OBJECTS_PURGE], oauth: false },
deps,
}),
).resolves.toMatchObject({ allowed: false, status: 403, reason: 'actor_not_allowed' })
await expect(
evaluateAuthorization({
context: sessionContext,
declaration: { scopes: [AuthorizationScope.OBJECTS_PURGE], oauth: false },
deps,
}),
).resolves.toMatchObject({ allowed: true })
})
it('uses task-upload token scopes', async () => {
const taskUploadContext = {
credential: 'download-task-upload' as const,
@@ -533,7 +559,7 @@ describe('evaluateAuthorization', () => {
credential: 'session',
userId: 'user-1',
workspace: { mode: 'selected', orgId: 'org-1' },
grantedScopes: null,
grantedScopes: new Set(CANONICAL_AUTHORIZATION_SCOPES),
actor: { type: 'user', ref: 'user-1' },
state: { firstParty: true },
},
+9 -5
View File
@@ -18,6 +18,7 @@ export type RequiredAuthorizationScopes = readonly [AuthorizationScope, ...Autho
export type ScopedAuthorizationPolicy = {
scopes: RequiredAuthorizationScopes
oauth?: boolean
minTeamRole?: TeamRole
siteRole?: 'admin'
auditDenied?: boolean
@@ -62,20 +63,23 @@ async function evaluateScopedPolicy(
deps: AuthzDeps,
): Promise<AuthzDecision> {
if (context.credential === 'anonymous') return deny(context, 401, 'missing_credential', policy)
if (policy.oauth === false && context.credential === 'oauth') return deny(context, 403, 'actor_not_allowed', policy)
if (context.credential !== 'session') {
for (const scope of policy.scopes) {
if (!context.grantedScopes.has(scope)) return deny(context, 403, 'missing_scope', policy)
}
for (const scope of policy.scopes) {
if (!context.grantedScopes.has(scope)) return deny(context, 403, 'missing_scope', policy)
}
if (context.credential === 'session' && policy.siteRole === 'admin' && context.state.role !== 'admin') {
if (policy.siteRole === 'admin' && contextSiteRole(context) !== 'admin') {
return deny(context, 403, 'insufficient_site_role', policy)
}
return evaluateRole(context, policy.minTeamRole, policy, deps)
}
function contextSiteRole(context: Exclude<AuthzContext, { credential: 'anonymous' }>): string | undefined {
return 'role' in context.state && typeof context.state.role === 'string' ? context.state.role : undefined
}
async function evaluateRole(
context: AuthzContext,
minTeamRole: TeamRole | undefined,
+4 -4
View File
@@ -102,7 +102,7 @@ export type AuthzContext =
credential: 'session'
userId: string
workspace: { mode: 'selected'; orgId: string | null }
grantedScopes: null
grantedScopes: ReadonlySet<AuthorizationScope>
actor: { type: 'user'; ref: string }
state: { firstParty: true; role?: string }
}
@@ -112,7 +112,7 @@ export type AuthzContext =
workspace: { mode: 'none'; orgId: null } | { mode: 'bound'; orgId: string }
grantedScopes: ReadonlySet<AuthorizationScope>
actor: { type: 'api_key'; ref: string }
state: { configId: string; enabled: true }
state: { configId: string; enabled: true; role?: string }
}
| {
credential: 'oauth'
@@ -120,7 +120,7 @@ export type AuthzContext =
workspace: { mode: 'bound'; orgId: string }
grantedScopes: ReadonlySet<AuthorizationScope>
actor: { type: 'oauth'; ref: string; issuer: string }
state: { clientId: string }
state: { clientId: string; role?: string }
}
| {
credential: 'downloader'
@@ -136,7 +136,7 @@ export type AuthzContext =
workspace: { mode: 'none'; orgId: null }
grantedScopes: ReadonlySet<AuthorizationScope>
actor: { type: 'user'; ref: string }
state: { clientId: 'zpan-cli'; scope: 'downloader:register' }
state: { clientId: 'zpan-cli'; scope: 'downloader:register'; role?: string }
}
| {
credential: 'download-task-upload'
+142 -109
View File
@@ -131,7 +131,7 @@ describe('global OpenAPI document', () => {
expect(await headResponse.text()).toBe('')
})
it('publishes the external OAuth scope catalog without Restish profiles', async () => {
it('publishes the external OAuth scope catalog with delegated CLI authentication', 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 {
@@ -141,7 +141,16 @@ describe('global OpenAPI document', () => {
{ type?: string; scheme?: string; flows?: { authorizationCode?: { scopes?: Record<string, string> } } }
>
}
'x-cli-config'?: unknown
'x-cli-config'?: {
profiles?: {
default?: {
credentials?: Record<
string,
{ auth?: { params?: Record<string, unknown> }; params?: Record<string, unknown> }
>
}
}
}
}
expect(doc.components?.securitySchemes?.oauth2).toMatchObject({
@@ -161,51 +170,35 @@ describe('global OpenAPI document', () => {
},
})
expect(doc.components?.securitySchemes?.agentApiKey).toBeUndefined()
expect(doc['x-cli-config']).toBeUndefined()
expect(doc['x-cli-config']?.profiles?.default?.credentials?.oauth2).toEqual({
auth: {
type: 'api-key',
params: {
in: 'header',
name: 'Authorization',
value: 'DPoP',
provider: 'realmroot-target',
scopes: expect.stringContaining(AuthorizationScope.OBJECTS_CREATE),
},
},
params: { provider: 'realmroot-target' },
})
expect(doc['x-cli-config']?.profiles?.default?.credentials?.oauth2.auth?.params?.scopes).not.toContain(
AuthorizationScope.OBJECTS_PURGE,
)
})
it('publishes a public resource-scope catalog for external controller discovery', async () => {
it('publishes scopes through authorization-server metadata without a duplicate catalog endpoint', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const [catalogResponse, documentResponse] = await Promise.all([
app.request('/api/oauth-resource-scopes'),
app.request('/api/openapi.json'),
])
const catalog = (await catalogResponse.json()) as {
scopes: { value: string; description: string }[]
}
const document = (await documentResponse.json()) as {
paths: Record<string, { get?: { security?: Record<string, string[]>[]; 'x-zpan-auth'?: unknown } }>
}
expect(catalogResponse.status).toBe(200)
expect(catalog.scopes).toEqual(
expect.arrayContaining([
{
value: AuthorizationScope.OBJECTS_CREATE,
description: 'Create folders and upload objects',
},
{
value: AuthorizationScope.OBJECTS_UPDATE,
description: 'Rename, move, and copy objects',
},
{
value: AuthorizationScope.QUOTA_PURCHASE,
description: 'Purchase workspace storage capacity',
},
expect((await app.request('/api/oauth-resource-scopes')).status).toBe(404)
const response = await app.request('/.well-known/oauth-authorization-server/api/auth')
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
scopes_supported: expect.arrayContaining([
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.OBJECTS_CREATE,
AuthorizationScope.OBJECTS_UPDATE,
]),
)
expect(document.paths['/api/oauth-resource-scopes']?.get).toMatchObject({
security: [
{
oauth2: expect.arrayContaining([
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.OBJECTS_CREATE,
AuthorizationScope.OBJECTS_UPDATE,
]),
},
{},
],
'x-zpan-auth': { public: true, scopes: [] },
})
})
@@ -271,6 +264,7 @@ describe('global OpenAPI document', () => {
get?: {
description?: string
responses?: Record<string, { description?: string }>
security?: Record<string, string[]>[]
'x-zpan-auth'?: unknown
}
}
@@ -282,10 +276,11 @@ describe('global OpenAPI document', () => {
expect(events?.description).toContain('Workspace-scoped API keys')
expect(events?.description).toContain('download-tasks:read')
expect(events?.description).toContain('resource-change')
expect(events?.['x-zpan-auth']).toMatchObject({
public: false,
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
})
expect(events?.security).toEqual([
{ oauth2: [AuthorizationScope.DOWNLOAD_TASKS_READ] },
{ bearerAuth: [] },
{ cookieAuth: [] },
])
})
it('emits explicit authorization metadata for routes migrated to authRoute', async () => {
@@ -323,14 +318,12 @@ describe('global OpenAPI document', () => {
middleware?: unknown[]
}
expect(route.security).toEqual([{ bearerAuth: [AuthorizationScope.DOWNLOAD_TASKS_READ] }, { cookieAuth: [] }])
expect(route['x-zpan-auth']).toEqual({
public: false,
scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ],
minTeamRole: 'viewer',
siteRole: null,
auditDenied: true,
})
expect(route.security).toEqual([
{ oauth2: [AuthorizationScope.DOWNLOAD_TASKS_READ] },
{ bearerAuth: [] },
{ cookieAuth: [] },
])
expect(route['x-zpan-auth']).toBeUndefined()
expect(route.middleware).toHaveLength(1)
})
@@ -362,7 +355,7 @@ describe('global OpenAPI document', () => {
}
})
it('leaves externally authorized operations unbound so delegated hooks can authenticate them', () => {
it('declares standard OAuth, bearer, and cookie alternatives for protected operations', () => {
const route = authRoute(
{
scopes: [AuthorizationScope.OBJECTS_CREATE],
@@ -376,10 +369,14 @@ describe('global OpenAPI document', () => {
},
) as { security?: unknown }
expect(route.security).toBeUndefined()
expect(route.security).toEqual([
{ oauth2: [AuthorizationScope.OBJECTS_CREATE] },
{ bearerAuth: [] },
{ cookieAuth: [] },
])
})
it('hides non-agent scoped policies from MCP without hiding them from Restish', () => {
it('keeps role constraints separate from authentication and scopes', () => {
const adminRoute = authRoute(
{
scopes: [AuthorizationScope.SITE_ANALYTICS_READ],
@@ -391,25 +388,29 @@ describe('global OpenAPI document', () => {
path: '/probe',
responses: { 200: { description: 'OK' } },
},
) as { security?: unknown; 'x-zpan-auth'?: unknown; 'x-cli-ignore'?: boolean; 'x-mcp-ignore'?: boolean }
) as { security?: unknown; 'x-zpan-authorization-constraints'?: unknown }
expect(adminRoute.security).toEqual([{ bearerAuth: [AuthorizationScope.SITE_ANALYTICS_READ] }, { cookieAuth: [] }])
expect(adminRoute['x-zpan-auth']).toEqual({
public: false,
scopes: [AuthorizationScope.SITE_ANALYTICS_READ],
minTeamRole: null,
expect(adminRoute.security).toEqual([
{ oauth2: [AuthorizationScope.SITE_ANALYTICS_READ] },
{ bearerAuth: [] },
{ cookieAuth: [] },
])
expect(adminRoute['x-zpan-authorization-constraints']).toEqual({
requiredScopes: [AuthorizationScope.SITE_ANALYTICS_READ],
siteRole: 'admin',
auditDenied: true,
})
expect(adminRoute['x-cli-ignore']).toBeUndefined()
expect(adminRoute['x-mcp-ignore']).toBe(true)
})
it('detects OpenAPI operations missing explicit authorization declarations without an allowlist', () => {
expect(
findOperationsMissingAuthContract({
'/public': { get: { 'x-zpan-auth': { public: true, scopes: [] } } },
'/protected': { post: { 'x-zpan-auth': { public: false, scopes: ['objects:read'] } } },
'/public': { get: { security: [] } },
'/protected': {
post: {
security: [{ oauth2: ['objects:read'] }],
'x-zpan-authorization-constraints': { requiredScopes: ['objects:read'] },
},
},
'/missing': { delete: { responses: { 204: { description: 'Deleted' } } } },
}),
).toEqual(['DELETE /missing'])
@@ -427,45 +428,35 @@ describe('global OpenAPI document', () => {
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 {
paths: Record<string, Record<string, { security?: unknown; 'x-zpan-auth'?: unknown; 'x-mcp-ignore'?: boolean }>>
paths: Record<string, Record<string, { security?: unknown; 'x-zpan-authorization-constraints'?: unknown }>>
}
const operation = doc.paths['/api/downloads/downloaders']?.post
expect(operation?.security).toEqual([{ bearerAuth: [AuthorizationScope.DOWNLOADERS_CREATE] }, { cookieAuth: [] }])
expect(operation?.['x-zpan-auth']).toEqual({
public: false,
scopes: [AuthorizationScope.DOWNLOADERS_CREATE],
minTeamRole: null,
expect(operation?.security).toEqual([
{ oauth2: [AuthorizationScope.DOWNLOADERS_CREATE] },
{ bearerAuth: [] },
{ cookieAuth: [] },
])
expect(operation?.['x-zpan-authorization-constraints']).toEqual({
requiredScopes: [AuthorizationScope.DOWNLOADERS_CREATE],
siteRole: 'admin',
auditDenied: true,
})
expect(operation?.['x-mcp-ignore']).toBe(true)
})
it('marks session, admin, and credential-management operations as ignored by MCP without hiding them from Restish', async () => {
it('keeps purge scope separate from its non-OAuth credential policy', 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 {
paths: Record<string, Record<string, { 'x-cli-ignore'?: boolean; 'x-mcp-ignore'?: boolean }>>
paths: Record<string, Record<string, { security?: unknown; 'x-zpan-authorization-constraints'?: unknown }>>
}
const ignoredOperations = [
doc.paths['/api/oauth-grants']?.get,
doc.paths['/api/oauth-grants/{grantId}']?.delete,
doc.paths['/api/site/storages']?.post,
doc.paths['/api/auth/sign-in/email']?.post,
doc.paths['/api/auth/sign-out']?.post,
]
for (const operation of ignoredOperations) {
expect(operation?.['x-mcp-ignore']).toBe(true)
expect(operation?.['x-cli-ignore']).toBeUndefined()
}
expect(doc.paths['/api/auth/callback/{id}']?.get?.['x-mcp-ignore']).toBe(true)
expect(doc.paths['/api/auth/callback/{id}']?.get?.['x-cli-ignore']).toBe(true)
expect(doc.paths['/api/objects']?.get?.['x-mcp-ignore']).toBeUndefined()
expect(Object.keys(doc.paths)).not.toContain('/api/openapi.agent.json')
expect(await app.request('/api/openapi.agent.json')).toMatchObject({ status: 404 })
const operation = doc.paths['/api/trash/objects/{id}']?.delete
expect(operation?.security).toEqual([{ bearerAuth: [] }, { cookieAuth: [] }])
expect(operation?.['x-zpan-authorization-constraints']).toEqual({
requiredScopes: [AuthorizationScope.OBJECTS_PURGE],
oauth: false,
minTeamRole: 'editor',
})
})
it('publishes stable upload operations for Restish plugin discovery', async () => {
@@ -500,22 +491,43 @@ describe('global OpenAPI document', () => {
expect(doc.paths['/api/objects']?.post).toMatchObject({
operationId: 'createObject',
'x-zpan-auth': {
public: false,
scopes: [AuthorizationScope.OBJECTS_CREATE],
},
})
expect(doc.paths['/api/store/capacity-purchases/{resourceId}']?.post).toMatchObject({
operationId: 'purchaseStorageCapacity',
description: expect.stringContaining('same requestHash with a fresh idempotencyKey'),
'x-zpan-auth': {
public: false,
scopes: [AuthorizationScope.QUOTA_PURCHASE],
},
responses: { 429: expect.any(Object) },
})
expect(doc.paths['/api/objects']?.post?.security).toBeUndefined()
for (const [status, resultStatus] of [
['200', 'delivered'],
['202', 'pending'],
]) {
expect(
doc.paths['/api/store/capacity-purchases/{resourceId}']?.post?.responses?.[status]?.content?.[
'application/json'
]?.schema,
).toMatchObject({
type: 'object',
required: ['attemptId', 'orderId', 'resourceId', 'requestHash', 'status'],
properties: { status: { type: 'string', enum: [resultStatus] } },
})
}
expect(
doc.paths['/api/store/capacity-purchases/{resourceId}']?.post?.responses?.['402']?.content?.['application/json']
?.schema,
).toMatchObject({
type: 'object',
required: ['x402Version', 'resource', 'accepts'],
})
expect(doc.paths['/api/objects']?.post?.security).toEqual([
{ oauth2: [AuthorizationScope.OBJECTS_CREATE] },
{ bearerAuth: [] },
{ cookieAuth: [] },
])
expect(doc.paths['/api/objects']?.post?.responses?.['201']).toBeDefined()
expect(doc.paths['/api/objects']?.post?.responses?.['402']?.content?.['application/json']?.schema).toEqual({
$ref: '#/components/schemas/CapacityRequired',
})
expect(doc.paths['/api/objects']?.post?.responses?.['422']).toBeDefined()
expect(doc.paths['/api/objects']?.post?.requestBody).toBeDefined()
expect(doc.paths['/api/objects']?.post?.requestBody?.content?.['application/json']?.schema).toMatchObject({
required: ['name'],
@@ -626,7 +638,9 @@ describe('global OpenAPI document', () => {
it('documents owner role requirements for store operations that enforce owner team role', 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 { paths: Record<string, Record<string, { 'x-zpan-auth'?: unknown }>> }
const doc = (await res.json()) as {
paths: Record<string, Record<string, { 'x-zpan-authorization-constraints'?: unknown }>>
}
const ownerOperations = [
doc.paths['/api/store/credits']?.get,
@@ -640,8 +654,7 @@ describe('global OpenAPI document', () => {
]
for (const operation of ownerOperations) {
expect(operation?.['x-zpan-auth']).toMatchObject({
public: false,
expect(operation?.['x-zpan-authorization-constraints']).toMatchObject({
minTeamRole: 'owner',
})
}
@@ -715,11 +728,31 @@ describe('global OpenAPI document', () => {
it("merges better-auth's auto-generated schema (incl. the device flow) into the same doc", 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 { paths: Record<string, unknown> }
const doc = (await res.json()) as {
paths: Record<
string,
{
post?: {
responses?: Record<
string,
{ content?: { 'application/json'?: { schema?: { properties?: Record<string, unknown> } } } }
>
}
}
>
}
// better-auth's device-authorization endpoints come from its openAPI plugin,
// not hand-written stubs — prefixed under /api/auth.
const authPaths = Object.keys(doc.paths).filter((p) => p.startsWith('/api/auth/'))
expect(authPaths.length).toBeGreaterThan(0)
expect(authPaths.some((p) => p.includes('/device/'))).toBe(true)
expect(
doc.paths['/api/auth/device/token']?.post?.responses?.['200']?.content?.['application/json']?.schema?.properties,
).toMatchObject({
access_token: { type: 'string' },
token_type: { type: 'string' },
expires_in: { type: 'integer' },
scope: { type: 'string' },
})
})
})
+13
View File
@@ -201,6 +201,7 @@ const AUTH_SCHEMA_SQL = `
authorization_code_id TEXT,
resources TEXT,
requested_user_info_claims TEXT,
authorization_details TEXT,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
revoked INTEGER,
@@ -225,6 +226,7 @@ const AUTH_SCHEMA_SQL = `
authorization_code_id TEXT,
resources TEXT,
requested_user_info_claims TEXT,
authorization_details 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)),
@@ -244,6 +246,7 @@ const AUTH_SCHEMA_SQL = `
reference_id TEXT,
resources TEXT,
requested_user_info_claims TEXT,
authorization_details TEXT,
scopes TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
last_used_at INTEGER,
@@ -255,6 +258,16 @@ const AUTH_SCHEMA_SQL = `
id TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS oauthPushedAuthorizationRequest (
id TEXT PRIMARY KEY,
request_uri TEXT NOT NULL UNIQUE,
client_id TEXT NOT NULL REFERENCES oauthClient(client_id) ON DELETE CASCADE,
parameters TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS oauthPushedAuthorizationRequest_client_id_idx ON oauthPushedAuthorizationRequest(client_id);
CREATE INDEX IF NOT EXISTS oauthPushedAuthorizationRequest_expires_at_idx ON oauthPushedAuthorizationRequest(expires_at);
CREATE TABLE IF NOT EXISTS oauthJwtRevocation (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
+25 -22
View File
@@ -1,4 +1,5 @@
import { AuthorizationScope } from '@shared/authorization'
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '@shared/oauth'
import { describe, expect, it, vi } from 'vitest'
import { getOAuthConsentContext } from './oauth-consent'
import type { OAuthGateway, OrgRepo } from './ports'
@@ -9,6 +10,7 @@ const CLIENT_NAME = 'FlareAuth'
function org(overrides: Partial<OrgRepo> = {}): OrgRepo {
return {
listUserOrgs: vi.fn(async () => [{ id: 'org-1', name: 'Personal' }]),
findPersonalOrg: vi.fn(),
getMemberRole: vi.fn(),
getOrgNames: vi.fn(async () => new Map([['org-1', 'Personal']])),
@@ -54,6 +56,7 @@ function oauthQuery(overrides: Record<string, string> = {}) {
redirect_uri: 'http://127.0.0.1:8484/callback',
response_type: 'code',
scope: `openid offline_access ${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ}`,
authorization_details: JSON.stringify([{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE }]),
...overrides,
}).toString()
}
@@ -74,8 +77,6 @@ describe('OAuth consent usecase', () => {
{
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: dynamicQuery,
},
),
@@ -91,15 +92,14 @@ describe('OAuth consent usecase', () => {
getOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: oauthQuery(),
}),
).resolves.toEqual({
clientId: CLIENT_ID,
clientName: CLIENT_NAME,
instanceOrigin: 'https://zpan.example.test',
workspace: { id: 'org-1', name: 'Personal' },
clientOrigin: 'http://127.0.0.1:8484',
workspaces: [{ id: 'org-1', name: 'Personal' }],
requestedWorkspaceIds: [],
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'http://127.0.0.1:8484/callback',
@@ -110,17 +110,18 @@ describe('OAuth consent usecase', () => {
})
})
it('keeps the active workspace id when the workspace name is unavailable', async () => {
it('honors a workspace identifier fixed by the client', async () => {
await expect(
getOAuthConsentContext(deps(org({ getOrgNames: vi.fn(async () => new Map()) })), {
getOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: oauthQuery(),
oauthQuery: oauthQuery({
authorization_details: JSON.stringify([{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'org-1' }]),
}),
}),
).resolves.toMatchObject({
workspace: { id: 'org-1', name: null },
workspaces: [{ id: 'org-1', name: 'Personal' }],
requestedWorkspaceIds: ['org-1'],
})
})
@@ -129,8 +130,6 @@ describe('OAuth consent usecase', () => {
getOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: oauthQuery({ response_type: 'token' }),
}),
).rejects.toMatchObject({ httpStatus: 400 })
@@ -141,8 +140,6 @@ describe('OAuth consent usecase', () => {
getOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: oauthQuery({ redirect_uri: 'https://evil.example/callback' }),
}),
).rejects.toMatchObject({ httpStatus: 400 })
@@ -151,22 +148,28 @@ describe('OAuth consent usecase', () => {
getOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: oauthQuery({ scope: 'objects:purge' }),
}),
).rejects.toMatchObject({ httpStatus: 400 })
})
it('rejects missing or inaccessible workspaces', async () => {
it('rejects users without available workspaces', async () => {
await expect(
getOAuthConsentContext(deps(org({ canReadOrg: vi.fn(async () => false) })), {
getOAuthConsentContext(deps(org({ listUserOrgs: vi.fn(async () => []) })), {
db,
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/oauth-consent',
oauthQuery: oauthQuery(),
}),
).rejects.toMatchObject({ httpStatus: 403 })
})
it('rejects malformed authorization details', async () => {
await expect(
getOAuthConsentContext(deps(org()), {
db,
userId: 'user-1',
oauthQuery: oauthQuery({ authorization_details: 'not-json' }),
}),
).rejects.toMatchObject({ httpStatus: 400, message: 'Invalid OAuth authorization details' })
})
})
+23 -8
View File
@@ -1,19 +1,25 @@
import { isAuthorizationScope } from '@shared/authorization'
import { OAUTH_ACCESS_TOKEN_SECONDS, OAUTH_REFRESH_TOKEN_SECONDS, OAUTH_STANDARD_SCOPES } from '@shared/oauth'
import { type OAuthConsentContext, type OAuthResourceScope, oauthResourceScopeSchema } from '@shared/schemas'
import {
type OAuthConsentContext,
type OAuthResourceScope,
oauthResourceScopeSchema,
parseWorkspaceAuthorizationDetails,
} from '@shared/schemas'
import type { Database } from '../platform/interface'
import type { Deps } from './deps'
import { badRequest, forbidden } from './ports'
export async function getOAuthConsentContext(
deps: Pick<Deps, 'oauth' | 'org'>,
input: { db: Database; userId: string; orgId: string | null; requestUrl: string; oauthQuery: string },
input: { db: Database; userId: string; oauthQuery: string },
): Promise<OAuthConsentContext> {
const params = new URLSearchParams(input.oauthQuery)
const clientId = params.get('client_id')
const redirectUri = params.get('redirect_uri')
const responseType = params.get('response_type')
const scopeValue = params.get('scope') ?? ''
const authorizationDetailsValue = params.get('authorization_details')
if (!clientId || responseType !== 'code' || !redirectUri) {
throw badRequest('Invalid OAuth request')
@@ -39,17 +45,26 @@ export async function getOAuthConsentContext(
throw badRequest('Invalid OAuth scope')
}
const orgId = input.orgId
if (!orgId || !(await deps.org.canReadOrg(input.userId, orgId))) {
throw forbidden('Workspace access is required for OAuth')
let authorizationDetails: ReturnType<typeof parseWorkspaceAuthorizationDetails>
try {
authorizationDetails = parseWorkspaceAuthorizationDetails(authorizationDetailsValue)
} catch {
throw badRequest('Invalid OAuth authorization details')
}
const names = await deps.org.getOrgNames([orgId])
if (authorizationDetails.length !== 1) throw badRequest('Exactly one workspace authorization request is required')
const requestedWorkspaceId = authorizationDetails[0].identifier
const availableWorkspaces = await deps.org.listUserOrgs(input.userId)
const workspaces = requestedWorkspaceId
? availableWorkspaces.filter((workspace) => workspace.id === requestedWorkspaceId)
: availableWorkspaces
if (workspaces.length === 0) throw forbidden('Workspace access is required for OAuth')
return {
clientId,
clientName: client.clientName,
instanceOrigin: new URL(input.requestUrl).origin,
workspace: { id: orgId, name: names.get(orgId) ?? null },
clientOrigin: new URL(redirectUri).origin,
workspaces: workspaces.map((workspace) => ({ id: workspace.id, name: workspace.name })),
requestedWorkspaceIds: requestedWorkspaceId ? [requestedWorkspaceId] : [],
scopes,
standardScopes,
redirectUri,
+3 -3
View File
@@ -18,6 +18,7 @@ function gateway(overrides: Partial<OAuthGateway> = {}): OAuthGateway {
function org(overrides: Partial<OrgRepo> = {}): OrgRepo {
return {
listUserOrgs: vi.fn(async () => []),
findPersonalOrg: vi.fn(),
getMemberRole: vi.fn(),
getOrgNames: vi.fn(async () => new Map([['org-1', 'Personal']])),
@@ -37,7 +38,7 @@ describe('OAuth grant usecases', () => {
clientId: 'dynamic-client',
clientName: 'FlareAuth',
userId: 'user-1',
orgId: 'org-1',
workspaceIds: ['org-1'],
scopes: [],
createdAt: '2026-07-29T12:00:00.000Z',
lastUsedAt: null,
@@ -52,8 +53,7 @@ describe('OAuth grant usecases', () => {
clientId: 'dynamic-client',
clientName: 'FlareAuth',
userId: 'user-1',
orgId: 'org-1',
workspaceName: 'Personal',
workspaces: [{ id: 'org-1', name: 'Personal' }],
scopes: [],
createdAt: '2026-07-29T12:00:00.000Z',
lastUsedAt: null,
+9 -7
View File
@@ -14,15 +14,17 @@ export async function listOAuthGrants(
input: { userId: string },
): Promise<{ items: OAuthGrantDTO[] }> {
const items = await deps.oauth.listGrants(db, input.userId)
const orgNames = await deps.org.getOrgNames(items.map((item) => item.orgId))
const workspaceIds = [...new Set(items.flatMap((item) => item.workspaceIds))]
const orgNames = await deps.org.getOrgNames(workspaceIds)
return {
items: items.map((item) =>
oauthGrantDTO({
...item,
items: items.map((item) => {
const { workspaceIds: itemWorkspaceIds, ...grant } = item
return oauthGrantDTO({
...grant,
scopes: item.scopes.filter(isOAuthResourceScope),
workspaceName: orgNames.get(item.orgId) ?? null,
}),
),
workspaces: itemWorkspaceIds.map((id) => ({ id, name: orgNames.get(id) ?? null })),
})
}),
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ export interface OAuthGrant {
clientId: string
clientName: string
userId: string
orgId: string
workspaceIds: string[]
scopes: AuthorizationScope[]
createdAt: string
lastUsedAt: string | null
+1
View File
@@ -1,4 +1,5 @@
export interface OrgRepo {
listUserOrgs(userId: string): Promise<Array<{ id: string; name: string }>>
findPersonalOrg(userId: string): Promise<string | null>
getMemberRole(orgId: string, userId: string): Promise<string | null>
getOrgNames(orgIds: string[]): Promise<Map<string, string>>
+1
View File
@@ -51,6 +51,7 @@ export interface UserAdminRepo {
// Whether the user is banned/disabled — checked by the auth middleware on every
// authenticated request to reject sessions of users disabled mid-session.
isBanned(userId: string): Promise<boolean>
getSiteRole(userId: string): Promise<string | null>
// Resolve an active user by email or username for WebDAV Basic Auth.
findActiveUserIdByUsername(username: string): Promise<string | null>
+78 -5
View File
@@ -779,7 +779,7 @@ describe('cloud-store usecase', () => {
ok: true,
kind: 'delivered',
paymentResponseHeader: 'response-header',
attempt: { id: 'attempt-1', status: 'delivered' },
purchase: { attemptId: 'attempt-1', status: 'delivered' },
})
expect(requests[8]).toMatchObject({
method: 'GET',
@@ -801,6 +801,7 @@ describe('cloud-store usecase', () => {
expect(requests[11]).toMatchObject({
method: 'POST',
path: 'stores/:storeId/orders/:orderId/x402/payment-attempts/:attemptId/fulfillment-attempts',
input: { json: { deliveryCallbackUrl: `${params.origin}/api/store/webhook` } },
})
})
@@ -828,14 +829,40 @@ describe('cloud-store usecase', () => {
ok: true,
kind: 'delivered',
paymentResponseHeader: 'response-header',
attempt: { id: 'attempt-1', status: 'delivered' },
purchase: { attemptId: 'attempt-1', status: 'delivered' },
})
expect(
requests.filter((request) => request.path === 'stores/:storeId/orders/:orderId/x402/payment-attempts'),
).toHaveLength(1)
})
it('retries paid-pending fulfillment after quote expiry without creating a replacement quote', async () => {
it('rejects a terminal Cloud attempt without its resource identity', async () => {
const quoted = attempt()
const delivered = { ...attempt('delivered'), resourceId: null, expiresAt: '2026-07-30T00:00:00.000Z' }
const { deps } = makeDeps({
responses: [
ok(publication()),
ok(pkg()),
ok(receiver),
ok(order()),
ok({ ...quoted, reused: false }),
ok(publication()),
ok(pkg()),
ok(receiver),
ok(delivered),
],
})
await purchaseCapacity(deps, CLOUD, params)
const out = await purchaseCapacity(deps, CLOUD, params)
expectError(out, {
httpStatus: 502,
message: 'Cloud capacity purchase response is missing its resource identity',
})
})
it('retries paid-pending fulfillment with the current callback and no payment replay', async () => {
const quoted = attempt()
const paidPending = { ...attempt('paid_pending_fulfillment'), expiresAt: '2026-07-30T00:00:00.000Z' }
const delivered = { ...attempt('delivered'), expiresAt: '2026-07-30T00:00:00.000Z' }
@@ -855,17 +882,63 @@ describe('cloud-store usecase', () => {
})
await purchaseCapacity(deps, CLOUD, params)
const out = await purchaseCapacity(deps, CLOUD, { ...params, paymentSignature: 'signature' })
const out = await purchaseCapacity(deps, CLOUD, { ...params, idempotencyKey: 'fresh-caller-key' })
expect(out).toMatchObject({
ok: true,
kind: 'delivered',
paymentResponseHeader: 'response-header',
attempt: { id: 'attempt-1', status: 'delivered' },
purchase: { attemptId: 'attempt-1', status: 'delivered' },
})
expect(
requests.filter((request) => request.path === 'stores/:storeId/orders/:orderId/x402/payment-attempts'),
).toHaveLength(1)
expect(requests.at(-1)).toMatchObject({
method: 'POST',
path: 'stores/:storeId/orders/:orderId/x402/payment-attempts/:attemptId/fulfillment-attempts',
input: { json: { deliveryCallbackUrl: `${params.origin}/api/store/webhook` } },
})
expect(requests).not.toContainEqual(
expect.objectContaining({
path: 'stores/:storeId/orders/:orderId/x402/payment-attempts/:attemptId/settlements',
}),
)
})
it('continues a verified attempt through settlement without replaying the payment signature', async () => {
const quoted = attempt()
const verified = { ...attempt('verified'), expiresAt: '2026-07-30T00:00:00.000Z' }
const paidPending = { ...attempt('paid_pending_fulfillment'), expiresAt: '2026-07-30T00:00:00.000Z' }
const delivered = { ...attempt('delivered'), expiresAt: '2026-07-30T00:00:00.000Z' }
const { deps, requests } = makeDeps({
responses: [
ok(publication()),
ok(pkg()),
ok(receiver),
ok(order()),
ok({ ...quoted, reused: false }),
ok(publication()),
ok(pkg()),
ok(receiver),
ok(verified),
ok(paidPending),
ok(delivered),
],
})
await purchaseCapacity(deps, CLOUD, params)
const out = await purchaseCapacity(deps, CLOUD, { ...params, idempotencyKey: 'fresh-caller-key' })
expect(out).toMatchObject({ ok: true, kind: 'delivered', purchase: { status: 'delivered' } })
expect(requests).not.toContainEqual(
expect.objectContaining({
path: 'stores/:storeId/orders/:orderId/x402/payment-attempts/:attemptId/verifications',
}),
)
expect(requests.at(-2)).toMatchObject({
method: 'POST',
path: 'stores/:storeId/orders/:orderId/x402/payment-attempts/:attemptId/settlements',
})
expect(requests.at(-1)).toMatchObject({
method: 'POST',
path: 'stores/:storeId/orders/:orderId/x402/payment-attempts/:attemptId/fulfillment-attempts',
+19 -4
View File
@@ -15,6 +15,7 @@
// idempotent fulfillment.
import {
type CapacityPurchaseResult,
type CheckoutInput,
type CloudOrderQuotaChange,
cloudCreditBalanceResponseSchema,
@@ -25,6 +26,7 @@ import {
discountQuoteSchema,
type RedeemGiftCardInput,
redeemGiftCardResponseSchema,
type X402PaymentRequired,
} from '@shared/schemas'
import type { CloudStoreTarget } from '@shared/types'
import type { z } from 'zod'
@@ -324,13 +326,13 @@ export type CapacityPurchaseOutcome =
| {
ok: true
kind: 'payment_required'
paymentRequired: unknown
paymentRequired: X402PaymentRequired
paymentRequiredHeader: string
}
| {
ok: true
kind: 'pending' | 'delivered'
attempt: z.infer<typeof x402PaymentAttemptSchema>
purchase: CapacityPurchaseResult
paymentResponseHeader: string | null
}
| { ok: false; error: AppError }
@@ -530,7 +532,10 @@ export async function purchaseCapacity(
})
}
if (!params.paymentSignature || quoteWasReplaced) {
if (
quoteWasReplaced ||
(!params.paymentSignature && attempt.status !== 'verified' && attempt.status !== 'paid_pending_fulfillment')
) {
if (attempt.status !== 'quoted') {
return terminalCapacityPurchaseOutcome(attempt)
}
@@ -582,6 +587,7 @@ export async function purchaseCapacity(
storeId,
orderId: cloudOrderId,
attemptId: attempt.id,
deliveryCallbackUrl: `${params.origin}/api/store/webhook`,
}),
x402PaymentAttemptSchema,
),
@@ -606,10 +612,19 @@ function terminalCapacityPurchaseOutcome(attempt: z.infer<typeof x402PaymentAtte
error: conflict('Payment was not completed', `X402_PAYMENT_${attempt.status.toUpperCase()}`),
}
}
if (!attempt.resourceId) {
return { ok: false, error: badGateway('Cloud capacity purchase response is missing its resource identity') }
}
return {
ok: true,
kind: attempt.status === 'delivered' ? 'delivered' : 'pending',
attempt,
purchase: {
attemptId: attempt.id,
orderId: attempt.orderId,
resourceId: attempt.resourceId,
requestHash: attempt.requestHash,
status: attempt.status === 'delivered' ? 'delivered' : 'pending',
},
paymentResponseHeader: attempt.settlementResponseHeader,
}
}
+1
View File
@@ -85,6 +85,7 @@ function makeDeps(
const deps: TeamDeps = {
audit: { record: async () => {}, list: async () => ({ items: [], total: 0 }) } as unknown as AuditRepo,
org: {
listUserOrgs: async () => [],
findPersonalOrg: async () => null,
getMemberRole: async () => null,
getOrgNames: async () => new Map(),
+1
View File
@@ -38,6 +38,7 @@ const failure: UserOperationFailure = { error: 'User not found: missing', status
function makeDeps(userAdmin: Partial<UserAdminRepo> = {}) {
const repo: UserAdminRepo = {
isBanned: async () => false,
getSiteRole: async () => null,
findActiveUserIdByUsername: async () => null,
listUserPersonalEntitlements: async () => ({ orgId: 'org-1', items: [] }),
grantUserPersonalEntitlement: async () => sampleResult,
+19 -14
View File
@@ -1,4 +1,4 @@
import { AuthorizationScope } from './authorization'
import { AuthorizationScope, CANONICAL_AUTHORIZATION_SCOPES } from './authorization'
export const OAUTH_ACCESS_TOKEN_SECONDS = 15 * 60
export const OAUTH_REFRESH_TOKEN_SECONDS = 30 * 24 * 60 * 60
@@ -7,21 +7,13 @@ export const JWT_BEARER_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-beare
export const TOKEN_EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'
export const OAUTH_ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'
export const AGENT_ACTOR_RESOURCE = 'urn:zpan:oauth:agent-actor'
export const WORKSPACE_AUTHORIZATION_DETAIL_TYPE = 'https://zpan.space/authorization-details/workspace'
export const OAUTH_STANDARD_SCOPES = ['openid', 'profile', 'email', 'offline_access'] as const
export const OAUTH_RESOURCE_SCOPES = [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.OBJECTS_CREATE,
AuthorizationScope.OBJECTS_UPDATE,
AuthorizationScope.OBJECTS_DELETE,
AuthorizationScope.SHARES_READ,
AuthorizationScope.SHARES_CREATE,
AuthorizationScope.SHARES_DELETE,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.QUOTA_PURCHASE,
AuthorizationScope.STORAGE_USAGE_READ,
] as const
export const OAUTH_RESOURCE_SCOPES = CANONICAL_AUTHORIZATION_SCOPES.filter(
(scope) => scope !== AuthorizationScope.OBJECTS_PURGE,
)
export const OAUTH_SCOPES = [...OAUTH_STANDARD_SCOPES, ...OAUTH_RESOURCE_SCOPES] as const
export const OAUTH_SCOPE_DESCRIPTIONS: Record<(typeof OAUTH_RESOURCE_SCOPES)[number], string> = {
const EXPLICIT_SCOPE_DESCRIPTIONS: Partial<Record<AuthorizationScope, string>> = {
[AuthorizationScope.OBJECTS_READ]: 'List, inspect, and download objects',
[AuthorizationScope.OBJECTS_CREATE]: 'Create folders and upload objects',
[AuthorizationScope.OBJECTS_UPDATE]: 'Rename, move, and copy objects',
@@ -33,3 +25,16 @@ export const OAUTH_SCOPE_DESCRIPTIONS: Record<(typeof OAUTH_RESOURCE_SCOPES)[num
[AuthorizationScope.QUOTA_PURCHASE]: 'Purchase workspace storage capacity',
[AuthorizationScope.STORAGE_USAGE_READ]: 'Inspect workspace storage usage',
}
export const OAUTH_SCOPE_DESCRIPTIONS: Record<(typeof OAUTH_RESOURCE_SCOPES)[number], string> = Object.fromEntries(
OAUTH_RESOURCE_SCOPES.map((scope) => [scope, describeScope(scope)]),
) as Record<(typeof OAUTH_RESOURCE_SCOPES)[number], string>
function describeScope(scope: string): string {
if (scope in EXPLICIT_SCOPE_DESCRIPTIONS) return EXPLICIT_SCOPE_DESCRIPTIONS[scope as AuthorizationScope]!
const [resource, action] = scope.split(':')
return `${capitalize(action)} ${resource.replaceAll('-', ' ')}`
}
function capitalize(value: string): string {
return value.length === 0 ? value : value[0].toUpperCase() + value.slice(1)
}
+24
View File
@@ -6,6 +6,7 @@ import {
orderListResponseSchema,
productPriceSchema,
updateProductSchema,
x402PaymentAttemptSchema,
} from 'zpan-cloud-sdk'
import { type CloudOrderQuotaChange, legacyCloudProductDeliverableSchema } from './cloud-store-legacy'
@@ -45,6 +46,29 @@ export const cloudOrdersResponseSchema = orderListResponseSchema.extend({
items: z.array(cloudOrderSchema),
})
export const x402PaymentRequiredSchema = x402PaymentAttemptSchema.shape.paymentRequired
export type X402PaymentRequired = z.infer<typeof x402PaymentRequiredSchema>
const capacityPurchaseResultBaseSchema = z
.object({
attemptId: z.string().min(1),
orderId: z.string().min(1),
resourceId: z.string().min(1),
requestHash: z.string().min(1),
})
.strict()
export const capacityPurchasePendingResultSchema = capacityPurchaseResultBaseSchema.extend({
status: z.literal('pending'),
})
export const capacityPurchaseDeliveredResultSchema = capacityPurchaseResultBaseSchema.extend({
status: z.literal('delivered'),
})
export const capacityPurchaseResultSchema = z.discriminatedUnion('status', [
capacityPurchasePendingResultSchema,
capacityPurchaseDeliveredResultSchema,
])
export type CapacityPurchaseResult = z.infer<typeof capacityPurchaseResultSchema>
function validateUniformPriceBilling(
prices: CloudProductPrice[],
ctx: z.RefinementCtx,
+8
View File
@@ -40,6 +40,7 @@ export {
listBackgroundJobsQuerySchema,
} from './background-jobs'
export type {
CapacityPurchaseResult,
CheckoutInput,
CloudCreditBalanceResponse,
CloudCreditBucket,
@@ -59,8 +60,12 @@ export type {
GiftCardStatus,
RedeemGiftCardInput,
RedeemGiftCardResponse,
X402PaymentRequired,
} from './cloud-store'
export {
capacityPurchaseDeliveredResultSchema,
capacityPurchasePendingResultSchema,
capacityPurchaseResultSchema,
checkoutInputSchema,
cloudCreditBalanceResponseSchema,
cloudCreditBucketSchema,
@@ -81,6 +86,7 @@ export {
giftCardStatusSchema,
redeemGiftCardInputSchema,
redeemGiftCardResponseSchema,
x402PaymentRequiredSchema,
} from './cloud-store'
export type {
CompleteObjectUploadInput,
@@ -143,6 +149,8 @@ export {
} from './errors'
export type { ListNotificationsQuery } from './notification'
export { listNotificationsQuerySchema } from './notification'
export type { WorkspaceAuthorizationDetail } from './oauth-authorization'
export { parseWorkspaceAuthorizationDetails, workspaceAuthorizationDetailSchema } from './oauth-authorization'
export type {
OAuthConsentContext,
OAuthConsentContextRequest,
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '../oauth'
import { parseWorkspaceAuthorizationDetails } from './oauth-authorization'
describe('parseWorkspaceAuthorizationDetails', () => {
it('rejects unknown authorization-detail members', () => {
expect(() =>
parseWorkspaceAuthorizationDetails([
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'workspace-1', unexpected: true },
]),
).toThrow()
})
})
+16
View File
@@ -0,0 +1,16 @@
import { z } from 'zod'
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '../oauth'
export const workspaceAuthorizationDetailSchema = z
.object({
type: z.literal(WORKSPACE_AUTHORIZATION_DETAIL_TYPE),
identifier: z.string().min(1).optional(),
})
.strict()
export type WorkspaceAuthorizationDetail = z.infer<typeof workspaceAuthorizationDetailSchema>
export function parseWorkspaceAuthorizationDetails(value: unknown): WorkspaceAuthorizationDetail[] {
const parsed = typeof value === 'string' ? JSON.parse(value) : value
return z.array(workspaceAuthorizationDetailSchema).parse(parsed)
}
+12 -7
View File
@@ -10,8 +10,7 @@ export const oauthGrantSchema = z.object({
clientId: z.string(),
clientName: z.string(),
userId: z.string(),
orgId: z.string(),
workspaceName: z.string().nullable(),
workspaces: z.array(z.object({ id: z.string(), name: z.string().nullable() })).min(1),
scopes: z.array(oauthResourceScopeSchema),
createdAt: z.string(),
lastUsedAt: z.string().nullable(),
@@ -25,11 +24,16 @@ export type OAuthGrantList = z.infer<typeof oauthGrantListSchema>
export const oauthConsentContextSchema = z.object({
clientId: z.string(),
clientName: z.string(),
instanceOrigin: z.string(),
workspace: z.object({
id: z.string(),
name: z.string().nullable(),
}),
clientOrigin: z.string(),
workspaces: z
.array(
z.object({
id: z.string(),
name: z.string().nullable(),
}),
)
.min(1),
requestedWorkspaceIds: z.array(z.string()),
scopes: z.array(oauthResourceScopeSchema),
standardScopes: z.array(z.string()),
redirectUri: z.string(),
@@ -48,6 +52,7 @@ export type OAuthConsentContextRequest = z.infer<typeof oauthConsentContextReque
export const oauthConsentSubmitSchema = z.object({
accept: z.boolean(),
oauthQuery: z.string().min(1),
workspaceIds: z.array(z.string().min(1)),
})
export type OAuthConsentSubmit = z.infer<typeof oauthConsentSubmitSchema>
+6 -2
View File
@@ -5,7 +5,7 @@ import { OAUTH_RESOURCE_SCOPES } from '../oauth'
export const oauthResourceScopeSchema = z.enum(OAUTH_RESOURCE_SCOPES)
export type OAuthResourceScope = z.infer<typeof oauthResourceScopeSchema>
export const oauthResourceScopeLabels = {
const explicitOAuthResourceScopeLabels: Partial<Record<OAuthResourceScope, string>> = {
[AuthorizationScope.OBJECTS_READ]: 'settings.oauthApps.scope.objectsRead',
[AuthorizationScope.OBJECTS_CREATE]: 'settings.oauthApps.scope.objectsCreate',
[AuthorizationScope.OBJECTS_UPDATE]: 'settings.oauthApps.scope.objectsUpdate',
@@ -16,4 +16,8 @@ export const oauthResourceScopeLabels = {
[AuthorizationScope.QUOTA_READ]: 'settings.oauthApps.scope.quotaRead',
[AuthorizationScope.QUOTA_PURCHASE]: 'settings.oauthApps.scope.quotaPurchase',
[AuthorizationScope.STORAGE_USAGE_READ]: 'settings.oauthApps.scope.storageUsageRead',
} as const satisfies Record<OAuthResourceScope, string>
}
export const oauthResourceScopeLabels = Object.fromEntries(
OAUTH_RESOURCE_SCOPES.map((scope) => [scope, explicitOAuthResourceScopeLabels[scope] ?? scope]),
) as Record<OAuthResourceScope, string>
+13
View File
@@ -1238,6 +1238,19 @@
"settings.oauthApps.oauthExpiredTitle": "OAuth request expired",
"settings.oauthApps.oauthExpiredDescription": "Start the connection again to create a fresh authorization request.",
"settings.oauthApps.oauthConsentFailed": "Could not finish OAuth consent.",
"settings.oauthApps.oauthWantsAccess": "{{client}} wants to access your workspaces",
"settings.oauthApps.oauthReviewAccess": "Review the workspaces and permissions below. You can revoke this connection later.",
"settings.oauthApps.oauthApplicationOrigin": "Application origin",
"settings.oauthApps.oauthAccessDuration": "Access duration",
"settings.oauthApps.oauthAccessDurationValue": "Up to {{days}} days",
"settings.oauthApps.oauthWorkspaceAccess": "Workspace access",
"settings.oauthApps.oauthWorkspaceHelp": "Choose which workspaces this application may use.",
"settings.oauthApps.oauthPermissions": "Permissions",
"settings.oauthApps.oauthStandardScopeOpenid": "Verify your identity",
"settings.oauthApps.oauthStandardScopeProfile": "Read your basic profile",
"settings.oauthApps.oauthStandardScopeEmail": "Read your email address",
"settings.oauthApps.oauthStandardScopeOfflineAccess": "Keep access while you are away",
"settings.oauthApps.oauthReturnNotice": "After approval, you will return to {{origin}}.",
"settings.oauthApps.oauthWorkspaceFailed": "Could not switch workspace.",
"settings.oauthApps.oauthGrantsSection": "Authorized OAuth Apps",
"settings.oauthApps.oauthGrantsDescription": "Review and revoke apps authorized to access your workspaces.",
+13
View File
@@ -1238,6 +1238,19 @@
"settings.oauthApps.oauthExpiredTitle": "OAuth 请求已过期",
"settings.oauthApps.oauthExpiredDescription": "请重新发起连接,生成新的授权请求。",
"settings.oauthApps.oauthConsentFailed": "无法完成 OAuth 授权。",
"settings.oauthApps.oauthWantsAccess": "{{client}} 想要访问你的工作空间",
"settings.oauthApps.oauthReviewAccess": "请检查下方的工作空间和权限。你之后可以随时撤销此连接。",
"settings.oauthApps.oauthApplicationOrigin": "应用来源",
"settings.oauthApps.oauthAccessDuration": "访问期限",
"settings.oauthApps.oauthAccessDurationValue": "最长 {{days}} 天",
"settings.oauthApps.oauthWorkspaceAccess": "工作空间访问权限",
"settings.oauthApps.oauthWorkspaceHelp": "选择此应用可以使用的工作空间。",
"settings.oauthApps.oauthPermissions": "权限",
"settings.oauthApps.oauthStandardScopeOpenid": "验证你的身份",
"settings.oauthApps.oauthStandardScopeProfile": "读取你的基本资料",
"settings.oauthApps.oauthStandardScopeEmail": "读取你的电子邮箱地址",
"settings.oauthApps.oauthStandardScopeOfflineAccess": "在你离开后继续保持访问权限",
"settings.oauthApps.oauthReturnNotice": "批准后,你将返回 {{origin}}。",
"settings.oauthApps.oauthWorkspaceFailed": "无法切换工作空间。",
"settings.oauthApps.oauthGrantsSection": "已授权的 OAuth 应用",
"settings.oauthApps.oauthGrantsDescription": "查看并撤销已获准访问你工作空间的应用。",
+13 -8
View File
@@ -3126,8 +3126,7 @@ describe('api', () => {
clientId: 'dynamic-client',
clientName: 'ZPan Agent',
userId: 'user-1',
orgId: 'org-1',
workspaceName: 'Personal',
workspaces: [{ id: 'org-1', name: 'Personal' }],
scopes: ['objects:read'],
createdAt: '2026-07-29T00:00:00.000Z',
lastUsedAt: null,
@@ -3140,8 +3139,9 @@ describe('api', () => {
const payload = {
clientId: 'dynamic-client',
clientName: 'ZPan Agent',
instanceOrigin: 'https://zpan.example.test',
workspace: { id: 'org-1', name: 'Personal' },
clientOrigin: 'http://127.0.0.1:8484',
workspaces: [{ id: 'org-1', name: 'Personal' }],
requestedWorkspaceIds: [],
scopes: ['objects:read'],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'http://127.0.0.1:8484/callback',
@@ -3161,7 +3161,11 @@ describe('api', () => {
it('submits full OAuth consent through the Hono RPC wrapper without sending scope overrides', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ url: 'http://127.0.0.1:8484/callback?code=abc' }))
const result = await submitOAuthConsent({ accept: true, oauthQuery: 'client_id=dynamic-client' })
const result = await submitOAuthConsent({
accept: true,
oauthQuery: 'client_id=dynamic-client',
workspaceIds: ['org-1'],
})
expect(result).toEqual({ url: 'http://127.0.0.1:8484/callback?code=abc' })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
@@ -3171,6 +3175,7 @@ describe('api', () => {
expect(JSON.parse(init.body as string)).toEqual({
accept: true,
oauthQuery: 'client_id=dynamic-client',
workspaceIds: ['org-1'],
})
})
@@ -3184,9 +3189,9 @@ describe('api', () => {
},
} as unknown as Response)
await expect(submitOAuthConsent({ accept: false, oauthQuery: 'client_id=dynamic-client' })).rejects.toThrow(
ApiError,
)
await expect(
submitOAuthConsent({ accept: false, oauthQuery: 'client_id=dynamic-client', workspaceIds: [] }),
).rejects.toThrow(ApiError)
})
it('lists delegated OAuth grants', async () => {
+52
View File
@@ -12,11 +12,13 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as DeviceRouteImport } from './routes/device'
import { Route as URouteRouteImport } from './routes/u/route'
import { Route as SRouteRouteImport } from './routes/s/route'
import { Route as OauthRouteRouteImport } from './routes/oauth/route'
import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/route'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
import { Route as UUsernameRouteImport } from './routes/u/$username'
import { Route as StoreCheckoutRouteImport } from './routes/store/checkout'
import { Route as STokenRouteImport } from './routes/s/$token'
import { Route as OauthConsentRouteImport } from './routes/oauth/consent'
import { Route as AuthenticatedStorageRouteImport } from './routes/_authenticated/storage'
import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up'
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
@@ -78,6 +80,11 @@ const SRouteRoute = SRouteRouteImport.update({
path: '/s',
getParentRoute: () => rootRouteImport,
} as any)
const OauthRouteRoute = OauthRouteRouteImport.update({
id: '/oauth',
path: '/oauth',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedRouteRoute = AuthenticatedRouteRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
@@ -102,6 +109,11 @@ const STokenRoute = STokenRouteImport.update({
path: '/$token',
getParentRoute: () => SRouteRoute,
} as any)
const OauthConsentRoute = OauthConsentRouteImport.update({
id: '/consent',
path: '/consent',
getParentRoute: () => OauthRouteRoute,
} as any)
const AuthenticatedStorageRoute = AuthenticatedStorageRouteImport.update({
id: '/storage',
path: '/storage',
@@ -362,6 +374,7 @@ const AuthenticatedAdminSettingsEmailRoute =
export interface FileRoutesByFullPath {
'/': typeof AuthenticatedIndexRoute
'/oauth': typeof OauthRouteRouteWithChildren
'/s': typeof SRouteRouteWithChildren
'/u': typeof URouteRouteWithChildren
'/device': typeof DeviceRoute
@@ -372,6 +385,7 @@ export interface FileRoutesByFullPath {
'/sign-in': typeof authSignInRoute
'/sign-up': typeof authSignUpRoute
'/storage': typeof AuthenticatedStorageRoute
'/oauth/consent': typeof OauthConsentRoute
'/s/$token': typeof STokenRoute
'/store/checkout': typeof StoreCheckoutRoute
'/u/$username': typeof UUsernameRoute
@@ -415,6 +429,7 @@ export interface FileRoutesByFullPath {
'/teams/$teamId/': typeof AuthenticatedTeamsTeamIdIndexRoute
}
export interface FileRoutesByTo {
'/oauth': typeof OauthRouteRouteWithChildren
'/s': typeof SRouteRouteWithChildren
'/u': typeof URouteRouteWithChildren
'/device': typeof DeviceRoute
@@ -423,6 +438,7 @@ export interface FileRoutesByTo {
'/sign-in': typeof authSignInRoute
'/sign-up': typeof authSignUpRoute
'/storage': typeof AuthenticatedStorageRoute
'/oauth/consent': typeof OauthConsentRoute
'/s/$token': typeof STokenRoute
'/store/checkout': typeof StoreCheckoutRoute
'/u/$username': typeof UUsernameRoute
@@ -468,6 +484,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_authenticated': typeof AuthenticatedRouteRouteWithChildren
'/oauth': typeof OauthRouteRouteWithChildren
'/s': typeof SRouteRouteWithChildren
'/u': typeof URouteRouteWithChildren
'/device': typeof DeviceRoute
@@ -478,6 +495,7 @@ export interface FileRoutesById {
'/(auth)/sign-in': typeof authSignInRoute
'/(auth)/sign-up': typeof authSignUpRoute
'/_authenticated/storage': typeof AuthenticatedStorageRoute
'/oauth/consent': typeof OauthConsentRoute
'/s/$token': typeof STokenRoute
'/store/checkout': typeof StoreCheckoutRoute
'/u/$username': typeof UUsernameRoute
@@ -525,6 +543,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/oauth'
| '/s'
| '/u'
| '/device'
@@ -535,6 +554,7 @@ export interface FileRouteTypes {
| '/sign-in'
| '/sign-up'
| '/storage'
| '/oauth/consent'
| '/s/$token'
| '/store/checkout'
| '/u/$username'
@@ -578,6 +598,7 @@ export interface FileRouteTypes {
| '/teams/$teamId/'
fileRoutesByTo: FileRoutesByTo
to:
| '/oauth'
| '/s'
| '/u'
| '/device'
@@ -586,6 +607,7 @@ export interface FileRouteTypes {
| '/sign-in'
| '/sign-up'
| '/storage'
| '/oauth/consent'
| '/s/$token'
| '/store/checkout'
| '/u/$username'
@@ -630,6 +652,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/_authenticated'
| '/oauth'
| '/s'
| '/u'
| '/device'
@@ -640,6 +663,7 @@ export interface FileRouteTypes {
| '/(auth)/sign-in'
| '/(auth)/sign-up'
| '/_authenticated/storage'
| '/oauth/consent'
| '/s/$token'
| '/store/checkout'
| '/u/$username'
@@ -686,6 +710,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren
OauthRouteRoute: typeof OauthRouteRouteWithChildren
SRouteRoute: typeof SRouteRouteWithChildren
URouteRoute: typeof URouteRouteWithChildren
DeviceRoute: typeof DeviceRoute
@@ -719,6 +744,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/oauth': {
id: '/oauth'
path: '/oauth'
fullPath: '/oauth'
preLoaderRoute: typeof OauthRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated': {
id: '/_authenticated'
path: ''
@@ -754,6 +786,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof STokenRouteImport
parentRoute: typeof SRouteRoute
}
'/oauth/consent': {
id: '/oauth/consent'
path: '/consent'
fullPath: '/oauth/consent'
preLoaderRoute: typeof OauthConsentRouteImport
parentRoute: typeof OauthRouteRoute
}
'/_authenticated/storage': {
id: '/_authenticated/storage'
path: '/storage'
@@ -1204,6 +1243,18 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
const AuthenticatedRouteRouteWithChildren =
AuthenticatedRouteRoute._addFileChildren(AuthenticatedRouteRouteChildren)
interface OauthRouteRouteChildren {
OauthConsentRoute: typeof OauthConsentRoute
}
const OauthRouteRouteChildren: OauthRouteRouteChildren = {
OauthConsentRoute: OauthConsentRoute,
}
const OauthRouteRouteWithChildren = OauthRouteRoute._addFileChildren(
OauthRouteRouteChildren,
)
interface SRouteRouteChildren {
STokenRoute: typeof STokenRoute
}
@@ -1228,6 +1279,7 @@ const URouteRouteWithChildren =
const rootRouteChildren: RootRouteChildren = {
AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren,
OauthRouteRoute: OauthRouteRouteWithChildren,
SRouteRoute: SRouteRouteWithChildren,
URouteRoute: URouteRouteWithChildren,
DeviceRoute: DeviceRoute,
@@ -1,7 +1,7 @@
import { oauthResourceScopeLabels } from '@shared/schemas'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Check, Plug, ShieldAlert, Trash2, X } from 'lucide-react'
import { Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -16,166 +16,17 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
getOAuthConsentContext,
listOAuthGrants,
type OAuthGrant,
revokeOAuthGrant,
submitOAuthConsent,
} from '@/lib/api'
import { setActive, useListOrganizations } from '@/lib/auth-client'
import { redirectExternal } from '@/lib/browser-navigation'
import { listOAuthGrants, type OAuthGrant, revokeOAuthGrant } from '@/lib/api'
export const Route = createFileRoute('/_authenticated/settings/oauth-apps')({
component: OAuthAppsSettingsPage,
})
interface Organization {
id: string
name: string
}
function oauthQueryFromLocation(): string {
if (typeof window === 'undefined') return ''
const query = window.location.search.slice(1)
const params = new URLSearchParams(query)
return params.has('client_id') && params.has('redirect_uri') ? query : ''
}
function formatDate(value: string | null) {
return value ? new Date(value).toLocaleString() : null
}
function OAuthConsentPanel({ oauthQuery, organizations }: { oauthQuery: string; organizations: Organization[] }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const consentQuery = useQuery({
queryKey: ['oauth-consent', oauthQuery],
queryFn: () => getOAuthConsentContext(oauthQuery),
enabled: !!oauthQuery,
retry: false,
})
const submitMutation = useMutation({
mutationFn: (accept: boolean) => submitOAuthConsent({ accept, oauthQuery }),
onSuccess: (result) => redirectExternal(result.url),
onError: (error) =>
setSubmitError(error instanceof Error ? error.message : t('settings.oauthApps.oauthConsentFailed')),
})
async function changeWorkspace(nextOrgId: string) {
setSwitchingOrgId(nextOrgId)
setSubmitError(null)
try {
const { error } = await setActive({ organizationId: nextOrgId })
if (error) throw error
await queryClient.invalidateQueries({ queryKey: ['oauth-consent', oauthQuery] })
} catch (error) {
toast.error(error instanceof Error ? error.message : t('settings.oauthApps.oauthWorkspaceFailed'))
} finally {
setSwitchingOrgId(null)
}
}
if (consentQuery.isLoading) {
return <p className="p-6 text-sm text-muted-foreground">{t('common.loading')}</p>
}
if (consentQuery.isError || !consentQuery.data) {
return (
<div className="flex max-w-3xl items-start gap-3 rounded-md border bg-background p-6">
<ShieldAlert className="mt-0.5 size-5 text-destructive" />
<div className="space-y-2">
<h1 className="text-xl font-semibold">{t('settings.oauthApps.oauthExpiredTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('settings.oauthApps.oauthExpiredDescription')}</p>
</div>
</div>
)
}
const context = consentQuery.data
const lifetimeDays = Math.round(context.grantLifetime.refreshTokenSeconds / 86400)
return (
<div className="max-w-3xl rounded-md border bg-background p-6 shadow-sm">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Plug className="size-4" />
{t('settings.oauthApps.oauthConsentEyebrow')}
</div>
<h1 className="text-2xl font-semibold">{t('settings.oauthApps.oauthConsentTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('settings.oauthApps.oauthConsentDescription')}</p>
</div>
<dl className="mt-6 grid gap-4 rounded-md border bg-muted/30 p-4 sm:grid-cols-2">
<div>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.oauthApps.oauthClient')}</dt>
<dd className="mt-1 font-medium">{context.clientName}</dd>
</div>
<div>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.oauthApps.oauthOrigin')}</dt>
<dd className="mt-1 break-all font-medium">{context.instanceOrigin}</dd>
</div>
<div>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.oauthApps.oauthReturn')}</dt>
<dd className="mt-1 break-all font-medium">{context.redirectUri}</dd>
</div>
<div>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.oauthApps.oauthLifetime')}</dt>
<dd className="mt-1 font-medium">{t('settings.oauthApps.oauthLifetimeValue', { days: lifetimeDays })}</dd>
</div>
</dl>
<div className="mt-6 space-y-2">
<Label htmlFor="oauth-workspace">{t('settings.oauthApps.workspaceLabel')}</Label>
<Select value={context.workspace.id} onValueChange={changeWorkspace} disabled={!!switchingOrgId}>
<SelectTrigger id="oauth-workspace">
<SelectValue />
</SelectTrigger>
<SelectContent>
{organizations.map((organization) => (
<SelectItem key={organization.id} value={organization.id}>
{organization.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-6 grid gap-2 sm:grid-cols-2">
{context.scopes.map((scope) => (
<div key={scope} className="rounded-md border px-3 py-2 text-sm">
{t(oauthResourceScopeLabels[scope])}
</div>
))}
</div>
{submitError ? (
<p role="alert" className="mt-4 text-sm text-destructive">
{submitError}
</p>
) : null}
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
disabled={submitMutation.isPending || !!switchingOrgId}
onClick={() => submitMutation.mutate(false)}
>
<X className="size-4" />
{t('settings.oauthApps.oauthDeny')}
</Button>
<Button
type="button"
disabled={submitMutation.isPending || !!switchingOrgId}
onClick={() => submitMutation.mutate(true)}
>
<Check className="size-4" />
{submitMutation.isPending ? t('common.loading') : t('settings.oauthApps.oauthApprove')}
</Button>
</div>
</div>
)
}
function RevokeGrantDialog({ grant, onClose }: { grant: OAuthGrant | null; onClose: () => void }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
@@ -198,7 +49,7 @@ function RevokeGrantDialog({ grant, onClose }: { grant: OAuthGrant | null; onClo
<DialogDescription>
{t('settings.oauthApps.oauthGrantRevokeConfirm', {
client: grant.clientName,
workspace: grant.workspaceName ?? grant.orgId,
workspace: grant.workspaces.map((workspace) => workspace.name ?? workspace.id).join(', '),
})}
</DialogDescription>
</DialogHeader>
@@ -250,7 +101,9 @@ function OAuthGrants() {
{grants.map((grant) => (
<TableRow key={grant.id}>
<TableCell className="font-medium">{grant.clientName}</TableCell>
<TableCell>{grant.workspaceName ?? grant.orgId}</TableCell>
<TableCell>
{grant.workspaces.map((workspace) => workspace.name ?? workspace.id).join(', ')}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{grant.scopes.map((scope) => (
@@ -285,10 +138,6 @@ function OAuthGrants() {
}
export function OAuthAppsSettingsPage() {
const { data } = useListOrganizations()
const organizations = (data ?? []) as Organization[]
const oauthQuery = oauthQueryFromLocation()
if (oauthQuery) return <OAuthConsentPanel oauthQuery={oauthQuery} organizations={organizations} />
return (
<div className="max-w-6xl space-y-6">
<OAuthGrants />
+25
View File
@@ -0,0 +1,25 @@
import { AuthorizationScope } from '@shared/authorization'
import { describe, expect, it } from 'vitest'
import { consentScopeLabel, requestedConsentScopes } from './consent'
describe('requestedConsentScopes', () => {
it('shows standard and resource scopes in consent', () => {
expect(
requestedConsentScopes({
standardScopes: ['openid', 'offline_access'],
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.OBJECTS_CREATE],
}),
).toEqual(['openid', 'offline_access', AuthorizationScope.OBJECTS_READ, AuthorizationScope.OBJECTS_CREATE])
})
it('renders standard scopes with user-facing localized labels', () => {
const labels: Record<string, string> = {
'settings.oauthApps.oauthStandardScopeOpenid': 'Verify your identity',
'settings.oauthApps.oauthStandardScopeOfflineAccess': 'Keep access when you are away',
}
const translate = (key: string) => labels[key] ?? key
expect(consentScopeLabel('openid', translate)).toBe('Verify your identity')
expect(consentScopeLabel('offline_access', translate)).toBe('Keep access when you are away')
})
})
+192
View File
@@ -0,0 +1,192 @@
import { DEFAULT_SITE_NAME } from '@shared/constants'
import { type OAuthConsentContext, oauthResourceScopeLabels } from '@shared/schemas'
import { useMutation, useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Check, ExternalLink, LockKeyhole, ShieldAlert, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { useSiteConfig } from '@/hooks/use-site-config'
import { getOAuthConsentContext, submitOAuthConsent } from '@/lib/api'
import { redirectExternal } from '@/lib/browser-navigation'
export const Route = createFileRoute('/oauth/consent')({ component: OAuthConsentPage })
const oauthStandardScopeLabels = {
openid: 'settings.oauthApps.oauthStandardScopeOpenid',
profile: 'settings.oauthApps.oauthStandardScopeProfile',
email: 'settings.oauthApps.oauthStandardScopeEmail',
offline_access: 'settings.oauthApps.oauthStandardScopeOfflineAccess',
} as const
type ConsentScopeLabelKey =
| (typeof oauthStandardScopeLabels)[keyof typeof oauthStandardScopeLabels]
| (typeof oauthResourceScopeLabels)[keyof typeof oauthResourceScopeLabels]
function oauthQueryFromLocation(): string {
return typeof window === 'undefined' ? '' : window.location.search.slice(1)
}
export function requestedConsentScopes(context: Pick<OAuthConsentContext, 'standardScopes' | 'scopes'>): string[] {
return [...context.standardScopes, ...context.scopes]
}
export function consentScopeLabel(scope: string, translate: (key: ConsentScopeLabelKey) => string): string {
if (scope in oauthStandardScopeLabels) {
return translate(oauthStandardScopeLabels[scope as keyof typeof oauthStandardScopeLabels])
}
if (scope in oauthResourceScopeLabels) {
return translate(oauthResourceScopeLabels[scope as keyof typeof oauthResourceScopeLabels])
}
return scope
}
function OAuthConsentPage() {
const { t } = useTranslation()
const { data: siteConfig } = useSiteConfig()
const siteName = siteConfig?.site.name ?? DEFAULT_SITE_NAME
const [oauthQuery] = useState(oauthQueryFromLocation)
const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState<string[]>([])
const [submitError, setSubmitError] = useState<string | null>(null)
const consentQuery = useQuery({
queryKey: ['oauth-consent', oauthQuery],
queryFn: () => getOAuthConsentContext(oauthQuery),
enabled: oauthQuery.length > 0,
retry: false,
})
useEffect(() => {
if (consentQuery.data?.requestedWorkspaceIds.length) {
setSelectedWorkspaceIds(consentQuery.data.requestedWorkspaceIds)
}
}, [consentQuery.data])
const submitMutation = useMutation({
mutationFn: (accept: boolean) =>
submitOAuthConsent({
accept,
oauthQuery,
workspaceIds: accept ? selectedWorkspaceIds : [],
}),
onSuccess: (result) => redirectExternal(result.url),
onError: (error) =>
setSubmitError(error instanceof Error ? error.message : t('settings.oauthApps.oauthConsentFailed')),
})
if (consentQuery.isLoading) {
return <p className="mx-auto max-w-xl py-20 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
}
if (consentQuery.isError || !consentQuery.data) {
return (
<Card className="mx-auto max-w-xl">
<CardHeader className="flex-row items-start gap-3">
<ShieldAlert className="mt-1 size-5 text-destructive" />
<div>
<h1 className="leading-none font-semibold">{t('settings.oauthApps.oauthExpiredTitle')}</h1>
<p className="mt-2 text-sm text-muted-foreground">{t('settings.oauthApps.oauthExpiredDescription')}</p>
</div>
</CardHeader>
</Card>
)
}
const context = consentQuery.data
const requestedScopes = requestedConsentScopes(context)
const lifetimeDays = Math.round(context.grantLifetime.refreshTokenSeconds / 86400)
const selectionLocked = context.requestedWorkspaceIds.length > 0
function toggleWorkspace(id: string, checked: boolean) {
setSubmitError(null)
setSelectedWorkspaceIds((current) => (checked ? [...current, id] : current.filter((value) => value !== id)))
}
return (
<Card className="mx-auto max-w-xl shadow-lg">
<CardHeader className="space-y-5 text-center">
<div className="mx-auto flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<LockKeyhole className="size-6" />
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">{siteName}</p>
<h1 className="text-2xl leading-none font-semibold">
{t('settings.oauthApps.oauthWantsAccess', { client: context.clientName })}
</h1>
<p className="text-sm text-muted-foreground">{t('settings.oauthApps.oauthReviewAccess')}</p>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="rounded-lg border bg-muted/30 p-4 text-sm">
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">{t('settings.oauthApps.oauthApplicationOrigin')}</span>
<span className="truncate font-medium">{context.clientOrigin}</span>
</div>
<div className="mt-3 flex items-center justify-between gap-4">
<span className="text-muted-foreground">{t('settings.oauthApps.oauthAccessDuration')}</span>
<span className="font-medium">
{t('settings.oauthApps.oauthAccessDurationValue', { days: lifetimeDays })}
</span>
</div>
</div>
<section className="space-y-3">
<div>
<h2 className="font-medium">{t('settings.oauthApps.oauthWorkspaceAccess')}</h2>
<p className="text-sm text-muted-foreground">{t('settings.oauthApps.oauthWorkspaceHelp')}</p>
</div>
<div className="space-y-2">
{context.workspaces.map((workspace) => (
<Label
key={workspace.id}
htmlFor={`workspace-${workspace.id}`}
className="flex cursor-pointer items-center gap-3 rounded-lg border p-3 font-normal"
>
<Checkbox
id={`workspace-${workspace.id}`}
checked={selectedWorkspaceIds.includes(workspace.id)}
disabled={selectionLocked}
onCheckedChange={(checked) => toggleWorkspace(workspace.id, checked === true)}
/>
<span className="min-w-0 flex-1 truncate font-medium">{workspace.name ?? workspace.id}</span>
</Label>
))}
</div>
</section>
<Separator />
<section className="space-y-3">
<h2 className="font-medium">{t('settings.oauthApps.oauthPermissions')}</h2>
<div className="flex flex-wrap gap-2">
{requestedScopes.map((scope) => (
<Badge key={scope} variant="secondary">
{consentScopeLabel(scope, (key) => t(key))}
</Badge>
))}
</div>
</section>
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<ExternalLink className="size-3.5" />
{t('settings.oauthApps.oauthReturnNotice', { origin: new URL(context.redirectUri).origin })}
</p>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
</CardContent>
<CardFooter className="flex flex-col-reverse gap-2 border-t sm:flex-row sm:justify-end">
<Button variant="outline" disabled={submitMutation.isPending} onClick={() => submitMutation.mutate(false)}>
<X className="size-4" />
{t('settings.oauthApps.oauthDeny')}
</Button>
<Button
disabled={submitMutation.isPending || selectedWorkspaceIds.length === 0}
onClick={() => submitMutation.mutate(true)}
>
<Check className="size-4" />
{submitMutation.isPending ? t('common.loading') : t('settings.oauthApps.oauthApprove')}
</Button>
</CardFooter>
</Card>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { SessionGateError, SessionGatePending } from '@/components/auth/session-gate'
import { getSession } from '@/lib/api'
export const Route = createFileRoute('/oauth')({
beforeLoad: async ({ location }) => {
const data = await getSession()
if (!data?.session) {
const redirectUrl = encodeURIComponent(`${location.pathname}${location.searchStr ?? ''}`)
throw redirect({ to: '/sign-in', search: { redirect: redirectUrl } as never })
}
return { user: data.user }
},
pendingComponent: SessionGatePending,
errorComponent: SessionGateError,
component: OAuthLayout,
})
function OAuthLayout() {
return (
<main className="min-h-screen bg-canvas px-4 py-10 sm:py-16">
<Outlet />
</main>
)
}