feat(oauth): expose workspace authorization catalog (#551)

* feat(oauth): expose workspace authorization catalog

* fix(oauth): verify catalog tokens against canonical audience

* fix(auth): normalize Workers preview requests

* Revert "fix(auth): normalize Workers preview requests"

This reverts commit 02009f098f.

* fix(oauth): refresh configured resource scopes
This commit is contained in:
Jasper Van
2026-08-02 21:00:20 -04:00
committed by GitHub
parent 58b88b427b
commit 8513b18a8e
29 changed files with 716 additions and 30 deletions
+164
View File
@@ -1102,6 +1102,21 @@ func (e AuditEventActorType) Valid() bool {
}
}
// Defines values for AuthorizationDetailsCatalogItemsAuthorizationDetailType.
const (
HttpszpanSpaceauthorizationDetailsworkspace AuthorizationDetailsCatalogItemsAuthorizationDetailType = "https://zpan.space/authorization-details/workspace"
)
// Valid indicates whether the value is a known member of the AuthorizationDetailsCatalogItemsAuthorizationDetailType enum.
func (e AuthorizationDetailsCatalogItemsAuthorizationDetailType) Valid() bool {
switch e {
case HttpszpanSpaceauthorizationDetailsworkspace:
return true
default:
return false
}
}
// Defines values for BrandingThemePreset.
const (
Default BrandingThemePreset = "default"
@@ -3140,6 +3155,7 @@ const (
GetOAuthConsentContext200JSONResponseBodyScopesUserEntitlementsUpdate GetOAuthConsentContext200JSONResponseBodyScopes = "user-entitlements:update"
GetOAuthConsentContext200JSONResponseBodyScopesUsersRead GetOAuthConsentContext200JSONResponseBodyScopes = "users:read"
GetOAuthConsentContext200JSONResponseBodyScopesUsersUpdate GetOAuthConsentContext200JSONResponseBodyScopes = "users:update"
GetOAuthConsentContext200JSONResponseBodyScopesWorkspacesDiscover GetOAuthConsentContext200JSONResponseBodyScopes = "workspaces:discover"
)
// Valid indicates whether the value is a known member of the GetOAuthConsentContext200JSONResponseBodyScopes enum.
@@ -3311,6 +3327,8 @@ func (e GetOAuthConsentContext200JSONResponseBodyScopes) Valid() bool {
return true
case GetOAuthConsentContext200JSONResponseBodyScopesUsersUpdate:
return true
case GetOAuthConsentContext200JSONResponseBodyScopesWorkspacesDiscover:
return true
default:
return false
}
@@ -3401,6 +3419,7 @@ const (
ListOAuthGrants200JSONResponseBodyItemsScopesUserEntitlementsUpdate ListOAuthGrants200JSONResponseBodyItemsScopes = "user-entitlements:update"
ListOAuthGrants200JSONResponseBodyItemsScopesUsersRead ListOAuthGrants200JSONResponseBodyItemsScopes = "users:read"
ListOAuthGrants200JSONResponseBodyItemsScopesUsersUpdate ListOAuthGrants200JSONResponseBodyItemsScopes = "users:update"
ListOAuthGrants200JSONResponseBodyItemsScopesWorkspacesDiscover ListOAuthGrants200JSONResponseBodyItemsScopes = "workspaces:discover"
)
// Valid indicates whether the value is a known member of the ListOAuthGrants200JSONResponseBodyItemsScopes enum.
@@ -3572,6 +3591,8 @@ func (e ListOAuthGrants200JSONResponseBodyItemsScopes) Valid() bool {
return true
case ListOAuthGrants200JSONResponseBodyItemsScopesUsersUpdate:
return true
case ListOAuthGrants200JSONResponseBodyItemsScopesWorkspacesDiscover:
return true
default:
return false
}
@@ -5429,6 +5450,23 @@ type AuthProviderList struct {
Total int `json:"total"`
}
// AuthorizationDetailsCatalog defines model for AuthorizationDetailsCatalog.
type AuthorizationDetailsCatalog struct {
Items []struct {
AuthorizationDetail struct {
Identifier string `json:"identifier"`
Type AuthorizationDetailsCatalogItemsAuthorizationDetailType `json:"type"`
} `json:"authorizationDetail"`
Display struct {
Label string `json:"label"`
Metadata map[string]string `json:"metadata"`
} `json:"display"`
} `json:"items"`
}
// AuthorizationDetailsCatalogItemsAuthorizationDetailType defines model for AuthorizationDetailsCatalog.Items.AuthorizationDetail.Type.
type AuthorizationDetailsCatalogItemsAuthorizationDetailType string
// BackgroundJob defines model for BackgroundJob.
type BackgroundJob struct {
Cancelable bool `json:"cancelable"`
@@ -11178,6 +11216,9 @@ type ClientInterface interface {
// ListUserSessions request
ListUserSessions(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// ListAuthorizationDetailsCatalog request
ListAuthorizationDetailsCatalog(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// GetApiAuthOauth2Authorize request
GetApiAuthOauth2Authorize(ctx context.Context, params *GetApiAuthOauth2AuthorizeParams, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -12854,6 +12895,18 @@ func (c *Client) ListUserSessions(ctx context.Context, reqEditors ...RequestEdit
return c.Client.Do(req)
}
func (c *Client) ListAuthorizationDetailsCatalog(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewListAuthorizationDetailsCatalogRequest(c.Server)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) GetApiAuthOauth2Authorize(ctx context.Context, params *GetApiAuthOauth2AuthorizeParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewGetApiAuthOauth2AuthorizeRequest(c.Server, params)
if err != nil {
@@ -18421,6 +18474,33 @@ func NewListUserSessionsRequest(server string) (*http.Request, error) {
return req, nil
}
// NewListAuthorizationDetailsCatalogRequest generates requests for ListAuthorizationDetailsCatalog
func NewListAuthorizationDetailsCatalogRequest(server string) (*http.Request, error) {
var err error
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/auth/oauth2/authorization-details/catalog")
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewGetApiAuthOauth2AuthorizeRequest generates requests for GetApiAuthOauth2Authorize
func NewGetApiAuthOauth2AuthorizeRequest(server string, params *GetApiAuthOauth2AuthorizeParams) (*http.Request, error) {
var err error
@@ -28414,6 +28494,9 @@ type ClientWithResponsesInterface interface {
// ListUserSessionsWithResponse request
ListUserSessionsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListUserSessionsResponse, error)
// ListAuthorizationDetailsCatalogWithResponse request
ListAuthorizationDetailsCatalogWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListAuthorizationDetailsCatalogResponse, error)
// GetApiAuthOauth2AuthorizeWithResponse request
GetApiAuthOauth2AuthorizeWithResponse(ctx context.Context, params *GetApiAuthOauth2AuthorizeParams, reqEditors ...RequestEditorFn) (*GetApiAuthOauth2AuthorizeResponse, error)
@@ -31674,6 +31757,38 @@ func (r ListUserSessionsResponse) ContentType() string {
return ""
}
type ListAuthorizationDetailsCatalogResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *AuthorizationDetailsCatalog
JSON401 *Error
JSON403 *Error
}
// Status returns HTTPResponse.Status
func (r ListAuthorizationDetailsCatalogResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r ListAuthorizationDetailsCatalogResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r ListAuthorizationDetailsCatalogResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type GetApiAuthOauth2AuthorizeResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -40940,6 +41055,15 @@ func (c *ClientWithResponses) ListUserSessionsWithResponse(ctx context.Context,
return ParseListUserSessionsResponse(rsp)
}
// ListAuthorizationDetailsCatalogWithResponse request returning *ListAuthorizationDetailsCatalogResponse
func (c *ClientWithResponses) ListAuthorizationDetailsCatalogWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListAuthorizationDetailsCatalogResponse, error) {
rsp, err := c.ListAuthorizationDetailsCatalog(ctx, reqEditors...)
if err != nil {
return nil, err
}
return ParseListAuthorizationDetailsCatalogResponse(rsp)
}
// GetApiAuthOauth2AuthorizeWithResponse request returning *GetApiAuthOauth2AuthorizeResponse
func (c *ClientWithResponses) GetApiAuthOauth2AuthorizeWithResponse(ctx context.Context, params *GetApiAuthOauth2AuthorizeParams, reqEditors ...RequestEditorFn) (*GetApiAuthOauth2AuthorizeResponse, error) {
rsp, err := c.GetApiAuthOauth2Authorize(ctx, params, reqEditors...)
@@ -47389,6 +47513,46 @@ func ParseListUserSessionsResponse(rsp *http.Response) (*ListUserSessionsRespons
return response, nil
}
// ParseListAuthorizationDetailsCatalogResponse parses an HTTP response from a ListAuthorizationDetailsCatalogWithResponse call
func ParseListAuthorizationDetailsCatalogResponse(rsp *http.Response) (*ListAuthorizationDetailsCatalogResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &ListAuthorizationDetailsCatalogResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest AuthorizationDetailsCatalog
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON401 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON403 = &dest
}
return response, nil
}
// ParseGetApiAuthOauth2AuthorizeResponse parses an HTTP response from a GetApiAuthOauth2AuthorizeWithResponse call
func ParseGetApiAuthOauth2AuthorizeResponse(rsp *http.Response) (*GetApiAuthOauth2AuthorizeResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
+10
View File
@@ -70,6 +70,16 @@ 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.
Connected-account clients may request the account-only `workspaces:discover`
scope. Authorization-server metadata advertises both
`authorization_details_catalog_endpoint` and
`authorization_details_catalog_scope`, allowing a generic broker to discover
the required scope and catalog URL without knowing ZPan routes. The catalog
accepts only the connected account's subject Bearer token and returns the
workspace authorization detail plus a safe display label and metadata for the
workspace `type` and current membership `role`. It does not accept Agent target
tokens and grants no access to workspace files or data.
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.
+46 -1
View File
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto'
import { AuthorizationScope } from '@shared/authorization'
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '@shared/oauth'
import { isNull } from 'drizzle-orm'
import { eq, isNull } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../../db/auth-schema'
import { createTestApp } from '../../test/setup'
@@ -69,6 +70,50 @@ describe('OAuth gateway', () => {
])
})
it('resolves only live account access tokens from enabled clients', async () => {
const { db } = await createTestApp()
await insertClient(db, CLIENT_ID, 'FlareAuth')
await insertUserAndOrg(db, 'oauth-user', 'oauth-org')
await db.insert(authSchema.oauthAccessToken).values([
{
id: 'live',
token: createHash('sha256').update('live-token').digest('base64url'),
clientId: CLIENT_ID,
userId: 'oauth-user',
expiresAt: new Date('2026-08-03T00:00:00.000Z'),
scopes: JSON.stringify([AuthorizationScope.WORKSPACES_DISCOVER]),
},
{
id: 'expired',
token: createHash('sha256').update('expired-token').digest('base64url'),
clientId: CLIENT_ID,
userId: 'oauth-user',
expiresAt: new Date('2026-08-01T00:00:00.000Z'),
scopes: JSON.stringify([AuthorizationScope.WORKSPACES_DISCOVER]),
},
])
const gateway = createOAuthGateway()
await expect(
gateway.resolveAccountAccessToken(db, 'live-token', new Date('2026-08-02T00:00:00.000Z')),
).resolves.toEqual({
clientId: CLIENT_ID,
userId: 'oauth-user',
scopes: [AuthorizationScope.WORKSPACES_DISCOVER],
})
await expect(
gateway.resolveAccountAccessToken(db, 'expired-token', new Date('2026-08-02T00:00:00.000Z')),
).resolves.toBeNull()
await expect(gateway.resolveAccountAccessToken(db, 'unknown')).resolves.toBeNull()
await db
.update(authSchema.oauthClient)
.set({ disabled: true })
.where(eq(authSchema.oauthClient.clientId, CLIENT_ID))
await expect(
gateway.resolveAccountAccessToken(db, 'live-token', new Date('2026-08-02T00:00:00.000Z')),
).resolves.toBeNull()
})
it('revokes the selected dynamic-client grant family only', async () => {
const { db } = await createTestApp()
await insertClient(db, CLIENT_ID, 'FlareAuth')
+29
View File
@@ -39,6 +39,29 @@ export function createOAuthGateway(): OAuthGateway {
} satisfies OAuthClient
},
async resolveAccountAccessToken(db, token, now = new Date()) {
const storedToken = await hashOAuthToken(token)
const [row] = await db
.select({
clientId: oauthAccessToken.clientId,
userId: oauthAccessToken.userId,
scopes: oauthAccessToken.scopes,
clientDisabled: oauthClient.disabled,
})
.from(oauthAccessToken)
.innerJoin(oauthClient, eq(oauthClient.clientId, oauthAccessToken.clientId))
.where(
and(
eq(oauthAccessToken.token, storedToken),
isNull(oauthAccessToken.revoked),
gt(oauthAccessToken.expiresAt, now),
),
)
.limit(1)
if (!row?.userId || row.clientDisabled === true) return null
return { clientId: row.clientId, userId: row.userId, scopes: parseScopes(row.scopes) }
},
async listRegisteredApplications(db) {
const rows = await db
.select({
@@ -188,3 +211,9 @@ function toIso(value: Date | number | string): string {
if (Number.isNaN(date.getTime())) throw new Error('invalid_oauth_date')
return date.toISOString()
}
async function hashOAuthToken(token: string): Promise<string> {
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)))
const base64 = btoa(String.fromCharCode(...digest))
return base64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
}
@@ -102,3 +102,19 @@ describe('findPersonalOrg', () => {
expect(result).toBeNull()
})
})
describe('listUserWorkspaceCatalog', () => {
it('returns current memberships with safe type and role metadata', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const personalId = await insertOrg(db, { metadata: '{"type":"personal"}' })
const teamId = await insertOrg(db, { slug: 'team-workspace' })
await insertMember(db, personalId, userId)
await insertMember(db, teamId, userId)
await expect(createOrgRepo(db).listUserWorkspaceCatalog(userId)).resolves.toEqual([
{ id: personalId, name: 'Test Org', type: 'personal', role: 'owner' },
{ id: teamId, name: 'Test Org', type: 'organization', role: 'owner' },
])
})
})
+30 -1
View File
@@ -15,6 +15,26 @@ export function createOrgRepo(db: Database): OrgRepo {
.where(eq(member.userId, userId))
}
async function listUserWorkspaceCatalog(userId: string) {
const rows = await db
.select({
id: organization.id,
name: organization.name,
slug: organization.slug,
metadata: organization.metadata,
role: member.role,
})
.from(member)
.innerJoin(organization, eq(organization.id, member.organizationId))
.where(eq(member.userId, userId))
return rows.map((row) => ({
id: row.id,
name: row.name,
type: isPersonalOrgLike(row) ? ('personal' as const) : ('organization' as const),
role: row.role,
}))
}
// 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
@@ -73,5 +93,14 @@ export function createOrgRepo(db: Database): OrgRepo {
return orgId === (await findPersonalOrg(userId))
}
return { listUserOrgs, findPersonalOrg, getMemberRole, getOrgNames, canReadOrg, canWriteToOrg, isPersonalOrg }
return {
listUserOrgs,
listUserWorkspaceCatalog,
findPersonalOrg,
getMemberRole,
getOrgNames,
canReadOrg,
canWriteToOrg,
isPersonalOrg,
}
}
+14 -3
View File
@@ -1,7 +1,8 @@
import { release as osRelease } from 'node:os'
import { OpenAPIHono } from '@hono/zod-openapi'
import { Scalar } from '@scalar/hono-api-reference'
import { OAUTH_RESOURCE_SCOPES, OAUTH_SCOPE_DESCRIPTIONS, OAUTH_SCOPES } from '@shared/oauth'
import { AuthorizationScope } from '@shared/authorization'
import { OAUTH_RESOURCE_SCOPES, OAUTH_SCOPE_DESCRIPTIONS } from '@shared/oauth'
import type { Context } from 'hono'
import { cors } from 'hono/cors'
import type { Auth } from './auth'
@@ -21,6 +22,7 @@ import ihostConfig from './http/image-hosting/config'
import ihost from './http/image-hosting/images'
import internal from './http/internal'
import { notifications } from './http/notifications'
import { oauthAuthorizationDetails } from './http/oauth-authorization-details'
import { oauthGrants } from './http/oauth-grants'
import objects from './http/objects'
import { adminQuotas, userQuotas } from './http/quotas'
@@ -133,6 +135,8 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
}),
)
app.route('/api/auth/oauth2/authorization-details/catalog', oauthAuthorizationDetails)
app.on(['POST', 'GET', 'HEAD'], '/api/auth/*', async (c) => {
const a = c.get('auth')
const revokeRequest = c.req.path === '/api/auth/oauth2/revoke' ? c.req.raw.clone() : null
@@ -157,7 +161,14 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
})
app.on(['GET', 'HEAD'], '/.well-known/oauth-authorization-server/api/auth', async (c) => {
return c.get('auth').handler(c.req.raw)
const response = await c.get('auth').handler(c.req.raw)
if (c.req.method === 'HEAD' || !response.ok) return response
const metadata = (await response.json()) as Record<string, unknown>
return c.json({
...metadata,
authorization_details_catalog_endpoint: `${new URL(c.req.url).origin}/api/auth/oauth2/authorization-details/catalog`,
authorization_details_catalog_scope: AuthorizationScope.WORKSPACES_DISCOVER,
})
})
app.on(['GET', 'HEAD'], '/.well-known/openid-configuration/api/auth', async (c) => {
@@ -171,7 +182,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
resource: `${origin}/api`,
authorization_servers: [authorizationServer],
bearer_methods_supported: ['header'],
scopes_supported: OAUTH_SCOPES.filter((scope) => scope.includes(':')),
scopes_supported: OAUTH_RESOURCE_SCOPES,
dpop_signing_alg_values_supported: ['ES256', 'EdDSA'],
resource_name: 'ZPan API',
})
+43 -2
View File
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { AuthorizationScope } from '@shared/authorization'
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '@shared/oauth'
import { isPersonalOrgLike } from '@shared/org-slugs'
import { deriveDpopAth } from 'better-auth/oauth2'
@@ -739,6 +740,24 @@ describe('loadProviderConfigs — builtin social provider resolution', () => {
expect(selectCalls).toBe(3)
})
it('refreshes configured OAuth resource scopes on an existing database', async () => {
const ctx = await createTestApp()
const identifier = 'http://localhost:3000/api'
await ctx.db
.update(authSchema.oauthResource)
.set({ allowedScopes: JSON.stringify(['openid', 'offline_access']) })
.where(eq(authSchema.oauthResource.identifier, identifier))
await createAuth(ctx.platform, 'test-secret', 'http://localhost:3000')
const [resource] = await ctx.db
.select({ allowedScopes: authSchema.oauthResource.allowedScopes })
.from(authSchema.oauthResource)
.where(eq(authSchema.oauthResource.identifier, identifier))
.limit(1)
expect(JSON.parse(resource?.allowedScopes ?? '[]')).toContain(AuthorizationScope.WORKSPACES_DISCOVER)
})
it('createAuth resolves better-auth $context before returning', async () => {
// A cached auth instance must never carry a pending init promise: on
// Cloudflare Workers a promise created in one request never settles when
@@ -818,6 +837,8 @@ describe('OAuth consent guards', () => {
})
await expect(authorizationServer.json()).resolves.toMatchObject({
registration_endpoint: 'http://localhost:3000/api/auth/oauth2/register',
authorization_details_catalog_endpoint: 'http://localhost:3000/api/auth/oauth2/authorization-details/catalog',
authorization_details_catalog_scope: AuthorizationScope.WORKSPACES_DISCOVER,
grant_types_supported: expect.arrayContaining([
'urn:ietf:params:oauth:grant-type:jwt-bearer',
'urn:ietf:params:oauth:grant-type:token-exchange',
@@ -856,7 +877,9 @@ describe('OAuth consent guards', () => {
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']))
expect(String(body.scope).split(' ')).toEqual(
expect.arrayContaining(['openid', 'offline_access', 'workspaces:discover', 'objects:read']),
)
const applicationsResponse = await ctx.app.request('/api/site/auth-providers', {
headers: await adminHeaders(ctx.app),
@@ -1010,7 +1033,7 @@ describe('OAuth consent guards', () => {
const verifier = 'external-resource-verifier-with-sufficient-entropy-1234567890'
const challenge = createHash('sha256').update(verifier).digest('base64url')
const redirectUri = 'https://broker.example.com/api/account-connections/oauth/callback'
const scope = 'openid offline_access objects:read quota:read'
const scope = 'openid offline_access workspaces:discover objects:read quota:read'
const authorizeParams = new URLSearchParams({
client_id: registered.client_id,
redirect_uri: redirectUri,
@@ -1065,6 +1088,24 @@ describe('OAuth consent guards', () => {
expect(subject.authorization_details).toEqual([
{ type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: workspaceId },
])
const catalogResponse = await ctx.app.request(
'http://localhost:3000/api/auth/oauth2/authorization-details/catalog',
{
headers: { Authorization: `Bearer ${subject.access_token}` },
},
)
expect(catalogResponse.status).toBe(200)
await expect(catalogResponse.json()).resolves.toMatchObject({
items: expect.arrayContaining([
expect.objectContaining({
authorizationDetail: { type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: workspaceId },
display: expect.objectContaining({
label: expect.any(String),
metadata: { type: 'personal', role: 'owner' },
}),
}),
]),
})
const reusedVerifier = 'reused-consent-verifier-with-sufficient-entropy-1234567890'
const reusedParams = new URLSearchParams(authorizeParams)
+23
View File
@@ -34,6 +34,7 @@ describe('createOAuthProviderOptions', () => {
consentPage: '/oauth/consent',
accessTokenExpiresIn: OAUTH_ACCESS_TOKEN_SECONDS,
grantTypes: ['authorization_code', 'refresh_token'],
resourceSeedMode: 'merge',
authorizationDetails: { typesSupported: [WORKSPACE_AUTHORIZATION_DETAIL_TYPE] },
})
expect(options.scopes).toEqual([...OAUTH_SCOPES])
@@ -231,4 +232,26 @@ describe('createOAuthProviderOptions', () => {
expect(grants).toEqual(expect.arrayContaining([JWT_BEARER_GRANT_TYPE, TOKEN_EXCHANGE_GRANT_TYPE]))
})
it('does not exchange the account discovery scope into an Agent target token', async () => {
const resourceAudience = 'https://files.example/api'
const options = createOptions({ resourceAudience })
const grant = options.extensions?.find((candidate) => candidate.grants?.[TOKEN_EXCHANGE_GRANT_TYPE])?.grants?.[
TOKEN_EXCHANGE_GRANT_TYPE
]
if (!grant) throw new Error('token exchange grant is not configured')
const authenticateClient = vi.fn()
await expect(
grant({
ctx: {
body: { scope: AuthorizationScope.WORKSPACES_DISCOVER },
headers: new Headers({ DPoP: 'proof' }),
},
opts: {},
provider: { authenticateClient },
} as never),
).rejects.toMatchObject({ body: expect.objectContaining({ error: 'invalid_scope' }) })
expect(authenticateClient).not.toHaveBeenCalled()
})
})
+8 -1
View File
@@ -16,6 +16,7 @@ import {
OAUTH_ACCESS_TOKEN_TYPE,
OAUTH_ACTOR_TOKEN_SECONDS,
OAUTH_REFRESH_TOKEN_SECONDS,
OAUTH_RESOURCE_SCOPES,
OAUTH_SCOPES,
OAUTH_STANDARD_SCOPES,
TOKEN_EXCHANGE_GRANT_TYPE,
@@ -61,6 +62,7 @@ export function createOAuthProviderOptions(input: {
grantTypes: ['authorization_code', 'refresh_token'],
scopes: [...OAUTH_SCOPES],
resources,
resourceSeedMode: 'merge',
enforcePerClientResources: false,
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
@@ -154,7 +156,12 @@ function externalResourceGrantExtension(resourceAudience: string): OAuthProvider
[TOKEN_EXCHANGE_GRANT_TYPE]: async ({ ctx, provider }) => {
if (!ctx.headers?.get('dpop')) throw oauthError('invalid_dpop_proof', 'DPoP proof header is required')
const requestedScopes = uniqueScopes(bodyString(ctx.body, 'scope'))
if (requestedScopes.length === 0 || requestedScopes.some((scope) => !isAuthorizationScope(scope))) {
if (
requestedScopes.length === 0 ||
requestedScopes.some(
(scope) => !isAuthorizationScope(scope) || !(OAUTH_RESOURCE_SCOPES as readonly string[]).includes(scope),
)
) {
throw oauthError('invalid_scope', 'Token exchange requires ZPan API scopes')
}
requireTokenType(ctx.body, 'subject_token_type')
@@ -0,0 +1,144 @@
import { createHash } from 'node:crypto'
import { AuthorizationScope } from '@shared/authorization'
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '@shared/oauth'
import { eq } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../db/auth-schema'
import { createTestApp } from '../test/setup'
describe('OAuth authorization details catalog', () => {
it('lists only the connected user current workspaces through the account credential', async () => {
const { app, db } = await createTestApp()
const token = await seedAccountToken(db, [AuthorizationScope.WORKSPACES_DISCOVER])
await seedWorkspace(db, 'user-1', 'personal-1', 'Personal Files', 'owner', { type: 'personal' })
await seedWorkspace(db, 'user-1', 'team-1', 'Build Team', 'editor')
await seedWorkspace(db, 'other-user', 'other-1', 'Other Team', 'owner')
const response = await app.request('/api/auth/oauth2/authorization-details/catalog', {
headers: { Authorization: `Bearer ${token}` },
})
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('no-store')
await expect(response.json()).resolves.toEqual({
items: [
{
authorizationDetail: { type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'personal-1' },
display: { label: 'Personal Files', metadata: { type: 'personal', role: 'owner' } },
},
{
authorizationDetail: { type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE, identifier: 'team-1' },
display: { label: 'Build Team', metadata: { type: 'organization', role: 'editor' } },
},
],
})
await db.delete(authSchema.member).where(eq(authSchema.member.organizationId, 'team-1'))
const afterRevocation = await app.request('/api/auth/oauth2/authorization-details/catalog', {
headers: { Authorization: `Bearer ${token}` },
})
const body = (await afterRevocation.json()) as { items: Array<{ authorizationDetail: { identifier: string } }> }
expect(body.items.map((item) => item.authorizationDetail.identifier)).toEqual(['personal-1'])
})
it('rejects missing, expired, target, and under-scoped credentials', async () => {
const { app, db } = await createTestApp()
const underScoped = await seedAccountToken(db, [AuthorizationScope.OBJECTS_READ], 'under-scoped')
const expired = await seedAccountToken(db, [AuthorizationScope.WORKSPACES_DISCOVER], 'expired', new Date(0))
expect((await app.request('/api/auth/oauth2/authorization-details/catalog')).status).toBe(401)
expect(
(
await app.request('/api/auth/oauth2/authorization-details/catalog', {
headers: { Authorization: 'DPoP target-jwt' },
})
).status,
).toBe(401)
expect(
(
await app.request('/api/auth/oauth2/authorization-details/catalog', {
headers: { Authorization: `Bearer ${underScoped}` },
})
).status,
).toBe(403)
expect(
(
await app.request('/api/auth/oauth2/authorization-details/catalog', {
headers: { Authorization: `Bearer ${expired}` },
})
).status,
).toBe(401)
})
})
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
async function seedAccountToken(
db: TestDb,
scopes: AuthorizationScope[],
token = 'account-token',
expiresAt = new Date(Date.now() + 60_000),
) {
await db
.insert(authSchema.user)
.values({
id: 'user-1',
name: 'Connected User',
email: 'connected@example.com',
emailVerified: true,
})
.onConflictDoNothing()
await db
.insert(authSchema.oauthClient)
.values({
id: 'client-1',
clientId: 'client-1',
clientSecret: null,
disabled: false,
skipConsent: false,
enableEndSession: false,
subjectType: 'public',
scopes: JSON.stringify(scopes),
name: 'Realmroot',
redirectUris: JSON.stringify(['https://realmroot.example/callback']),
tokenEndpointAuthMethod: 'none',
grantTypes: JSON.stringify(['authorization_code', 'refresh_token']),
responseTypes: JSON.stringify(['code']),
public: true,
type: 'native',
requirePKCE: true,
})
.onConflictDoNothing()
await db.insert(authSchema.oauthAccessToken).values({
id: `access-${token}`,
token: createHash('sha256').update(token).digest('base64url'),
clientId: 'client-1',
userId: 'user-1',
expiresAt,
scopes: JSON.stringify(scopes),
})
return token
}
async function seedWorkspace(
db: TestDb,
userId: string,
id: string,
name: string,
role: string,
metadata?: { type: 'personal' },
) {
await db
.insert(authSchema.user)
.values({
id: userId,
name: userId,
email: `${userId}@example.com`,
emailVerified: true,
})
.onConflictDoNothing()
await db
.insert(authSchema.organization)
.values({ id, name, slug: id, metadata: metadata ? JSON.stringify(metadata) : null })
await db.insert(authSchema.member).values({ id: `member-${id}`, organizationId: id, userId, role })
}
@@ -0,0 +1,59 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '@shared/oauth'
import { createLocalJWKSet, jwtVerify } from 'jose'
import type { Env } from '../middleware/platform'
import { listOAuthAuthorizationDetailsCatalog } from '../usecases/oauth-authorization-details'
import { unauthorized } from '../usecases/ports'
import { errorResponse, jsonContent } from './openapi'
const catalogEntrySchema = z.object({
authorizationDetail: z.object({
type: z.literal(WORKSPACE_AUTHORIZATION_DETAIL_TYPE),
identifier: z.string().min(1),
}),
display: z.object({
label: z.string(),
metadata: z.record(z.string(), z.string()),
}),
})
const catalogSchema = z.object({ items: z.array(catalogEntrySchema) }).openapi('AuthorizationDetailsCatalog')
const catalogRoute = {
operationId: 'listAuthorizationDetailsCatalog',
summary: 'List available authorization details',
description:
'Lists the workspace authorization details currently available to the connected user. This account-level endpoint accepts the OAuth subject credential and does not grant access to workspace files or data.',
tags: ['OAuth'],
method: 'get' as const,
path: '/',
security: [{ oauth2: [AuthorizationScope.WORKSPACES_DISCOVER] }],
responses: {
200: jsonContent(catalogSchema, 'Available workspace authorization details'),
401: errorResponse('Invalid or expired account access token'),
403: errorResponse('Missing workspace discovery scope'),
},
}
export const oauthAuthorizationDetails = new OpenAPIHono<Env>().openapi(catalogRoute, async (c) => {
const authorization = c.req.header('Authorization')
const token = authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length).trim() : ''
if (!token) throw unauthorized('Unauthorized')
const auth = c.get('auth')
const authContext = await auth.$context
const items = await listOAuthAuthorizationDetailsCatalog(c.get('deps'), {
db: c.get('platform').db,
token,
verifyJwtToken: async () =>
(
await jwtVerify(token, createLocalJWKSet(await auth.api.getJwks()), {
issuer: authContext.baseURL,
audience: `${new URL(authContext.baseURL).origin}/api`,
})
).payload,
})
c.header('Cache-Control', 'no-store')
return c.json({ items }, 200)
})
+5
View File
@@ -31,6 +31,7 @@ describe('global OpenAPI document', () => {
'/api/downloads/downloaders',
'/api/downloads/downloaders/{id}',
'/api/events',
'/api/auth/oauth2/authorization-details/catalog',
'/api/objects',
'/api/objects/{id}',
'/api/objects/{id}/uploads/{uploadSessionId}/parts',
@@ -41,6 +42,10 @@ describe('global OpenAPI document', () => {
'/api/trash/objects/{id}/restorations',
]),
)
expect(doc.paths['/api/auth/oauth2/authorization-details/catalog']?.get).toMatchObject({
operationId: 'listAuthorizationDetailsCatalog',
security: [{ oauth2: [AuthorizationScope.WORKSPACES_DISCOVER] }],
})
})
it('serves the Scalar reference UI at /api/docs pointing at the spec', async () => {
@@ -0,0 +1,58 @@
import { AuthorizationScope } from '@shared/authorization'
import { WORKSPACE_AUTHORIZATION_DETAIL_TYPE } from '@shared/oauth'
import type { JWTPayload } from 'jose'
import type { Database } from '../platform/interface'
import type { Deps } from './deps'
import { forbidden, unauthorized } from './ports'
type CatalogDeps = Pick<Deps, 'oauth' | 'org' | 'userAdmin'>
export async function listOAuthAuthorizationDetailsCatalog(
deps: CatalogDeps,
input: {
db: Database
token: string
verifyJwtToken: () => Promise<JWTPayload>
},
) {
const account =
(await deps.oauth.resolveAccountAccessToken(input.db, input.token)) ??
(await resolveJwtAccountToken(deps, input.db, input.verifyJwtToken))
if (!account) throw unauthorized('Unauthorized')
if (!account.scopes.includes(AuthorizationScope.WORKSPACES_DISCOVER)) throw forbidden('Forbidden')
const workspaces = await deps.org.listUserWorkspaceCatalog(account.userId)
return workspaces.map((workspace) => ({
authorizationDetail: {
type: WORKSPACE_AUTHORIZATION_DETAIL_TYPE as typeof WORKSPACE_AUTHORIZATION_DETAIL_TYPE,
identifier: workspace.id,
},
display: {
label: workspace.name,
metadata: { type: workspace.type, role: workspace.role },
},
}))
}
async function resolveJwtAccountToken(deps: CatalogDeps, db: Database, verifyJwtToken: () => Promise<JWTPayload>) {
try {
const payload = await verifyJwtToken()
if (payload.act !== undefined || typeof payload.sub !== 'string' || typeof payload.jti !== 'string') return null
const clientId =
typeof payload.client_id === 'string' ? payload.client_id : typeof payload.azp === 'string' ? payload.azp : null
const client = clientId ? await deps.oauth.findClient(db, clientId) : null
if (!client || client.disabled || (await deps.oauth.isJwtAccessTokenRevoked(db, payload.jti))) return null
if (await deps.userAdmin.isBanned(payload.sub)) return null
const scopes =
typeof payload.scope === 'string'
? payload.scope
.split(/\s+/)
.filter((scope): scope is AuthorizationScope =>
Object.values(AuthorizationScope).includes(scope as AuthorizationScope),
)
: []
return { clientId, userId: payload.sub, scopes }
} catch {
return null
}
}
+14
View File
@@ -11,6 +11,7 @@ const CLIENT_NAME = 'FlareAuth'
function org(overrides: Partial<OrgRepo> = {}): OrgRepo {
return {
listUserOrgs: vi.fn(async () => [{ id: 'org-1', name: 'Personal' }]),
listUserWorkspaceCatalog: vi.fn(async () => []),
findPersonalOrg: vi.fn(),
getMemberRole: vi.fn(),
getOrgNames: vi.fn(async () => new Map([['org-1', 'Personal']])),
@@ -110,6 +111,19 @@ describe('OAuth consent usecase', () => {
})
})
it('accepts account-level workspace discovery as a consented scope', async () => {
const scope = `${AuthorizationScope.WORKSPACES_DISCOVER} ${AuthorizationScope.OBJECTS_READ}`
await expect(
getOAuthConsentContext(deps(org(), { scopes: scope.split(' ') }), {
db,
userId: 'user-1',
oauthQuery: oauthQuery({ scope }),
}),
).resolves.toMatchObject({
scopes: [AuthorizationScope.WORKSPACES_DISCOVER, AuthorizationScope.OBJECTS_READ],
})
})
it('honors a workspace identifier fixed by the client', async () => {
await expect(
getOAuthConsentContext(deps(org()), {
+5 -5
View File
@@ -2,8 +2,8 @@ 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,
type OAuthGrantScope,
oauthGrantScopeSchema,
parseWorkspaceAuthorizationDetails,
} from '@shared/schemas'
import type { Database } from '../platform/interface'
@@ -36,7 +36,7 @@ export async function getOAuthConsentContext(
const requestedScopes = scopeValue.split(/\s+/).filter(Boolean)
const standardScopes = requestedScopes.filter((scope) => (OAUTH_STANDARD_SCOPES as readonly string[]).includes(scope))
const scopes = requestedScopes.filter(isOAuthResourceScope)
const scopes = requestedScopes.filter(isOAuthGrantScope)
if (
scopes.length === 0 ||
requestedScopes.length !== standardScopes.length + scopes.length ||
@@ -75,6 +75,6 @@ export async function getOAuthConsentContext(
}
}
function isOAuthResourceScope(scope: string): scope is OAuthResourceScope {
return isAuthorizationScope(scope) && oauthResourceScopeSchema.safeParse(scope).success
function isOAuthGrantScope(scope: string): scope is OAuthGrantScope {
return isAuthorizationScope(scope) && oauthGrantScopeSchema.safeParse(scope).success
}
+2
View File
@@ -7,6 +7,7 @@ const db = {} as never
function gateway(overrides: Partial<OAuthGateway> = {}): OAuthGateway {
return {
findClient: vi.fn(),
resolveAccountAccessToken: vi.fn(),
listRegisteredApplications: vi.fn(),
revokeJwtAccessToken: vi.fn(),
isJwtAccessTokenRevoked: vi.fn(),
@@ -19,6 +20,7 @@ function gateway(overrides: Partial<OAuthGateway> = {}): OAuthGateway {
function org(overrides: Partial<OrgRepo> = {}): OrgRepo {
return {
listUserOrgs: vi.fn(async () => []),
listUserWorkspaceCatalog: vi.fn(async () => []),
findPersonalOrg: vi.fn(),
getMemberRole: vi.fn(),
getOrgNames: vi.fn(async () => new Map([['org-1', 'Personal']])),
+5 -5
View File
@@ -1,8 +1,8 @@
import {
type OAuthGrant as OAuthGrantDTO,
type OAuthResourceScope,
type OAuthGrantScope,
oauthGrantDTO,
oauthResourceScopeSchema,
oauthGrantScopeSchema,
} from '@shared/schemas'
import type { Database } from '../platform/interface'
import type { Deps } from './deps'
@@ -21,15 +21,15 @@ export async function listOAuthGrants(
const { workspaceIds: itemWorkspaceIds, ...grant } = item
return oauthGrantDTO({
...grant,
scopes: item.scopes.filter(isOAuthResourceScope),
scopes: item.scopes.filter(isOAuthGrantScope),
workspaces: itemWorkspaceIds.map((id) => ({ id, name: orgNames.get(id) ?? null })),
})
}),
}
}
function isOAuthResourceScope(scope: string): scope is OAuthResourceScope {
return oauthResourceScopeSchema.safeParse(scope).success
function isOAuthGrantScope(scope: string): scope is OAuthGrantScope {
return oauthGrantScopeSchema.safeParse(scope).success
}
export async function revokeOAuthGrant(
+7
View File
@@ -21,6 +21,12 @@ export interface OAuthClient {
scopes: string[]
}
export interface OAuthAccountAccessToken {
clientId: string
userId: string
scopes: AuthorizationScope[]
}
export interface RegisteredOAuthApplication {
clientId: string
name: string
@@ -34,6 +40,7 @@ export interface RegisteredOAuthApplication {
export interface OAuthGateway {
findClient(db: Database, clientId: string): Promise<OAuthClient | null>
resolveAccountAccessToken(db: Database, token: string, now?: Date): Promise<OAuthAccountAccessToken | null>
listRegisteredApplications(db: Database): Promise<RegisteredOAuthApplication[]>
revokeJwtAccessToken(db: Database, token: string): Promise<void>
isJwtAccessTokenRevoked(db: Database, tokenId: string): Promise<boolean>
+8
View File
@@ -1,5 +1,13 @@
export interface UserWorkspaceCatalogItem {
id: string
name: string
type: 'personal' | 'organization'
role: string
}
export interface OrgRepo {
listUserOrgs(userId: string): Promise<Array<{ id: string; name: string }>>
listUserWorkspaceCatalog(userId: string): Promise<UserWorkspaceCatalogItem[]>
findPersonalOrg(userId: string): Promise<string | null>
getMemberRole(orgId: string, userId: string): Promise<string | null>
getOrgNames(orgIds: string[]): Promise<Map<string, string>>
+1
View File
@@ -86,6 +86,7 @@ function makeDeps(
audit: { record: async () => {}, list: async () => ({ items: [], total: 0 }) } as unknown as AuditRepo,
org: {
listUserOrgs: async () => [],
listUserWorkspaceCatalog: async () => [],
findPersonalOrg: async () => null,
getMemberRole: async () => null,
getOrgNames: async () => new Map(),
+6 -1
View File
@@ -6,7 +6,7 @@ import {
CANONICAL_AUTHORIZATION_SCOPES,
scopePermissions,
} from './authorization'
import { OAUTH_RESOURCE_SCOPES } from './oauth'
import { OAUTH_ACCOUNT_SCOPES, OAUTH_RESOURCE_SCOPES } from './oauth'
describe('authorization scope registry', () => {
it('uses lowercase resource:action scopes without wildcard semantics', () => {
@@ -26,6 +26,11 @@ describe('authorization scope registry', () => {
expect(scopePermissions([AuthorizationScope.OBJECTS_DELETE])).toEqual({ objects: ['delete'] })
})
it('keeps account workspace discovery out of Agent target scopes', () => {
expect(OAUTH_ACCOUNT_SCOPES).toEqual([AuthorizationScope.WORKSPACES_DISCOVER])
expect(OAUTH_RESOURCE_SCOPES).not.toContain(AuthorizationScope.WORKSPACES_DISCOVER)
})
it('does not grant share mutation scopes to user-wide WebDAV app passwords', () => {
expect(WEBDAV_API_KEY_PERMISSIONS.shares).toEqual(['read'])
})
+1
View File
@@ -1,4 +1,5 @@
export const AuthorizationScope = {
WORKSPACES_DISCOVER: 'workspaces:discover',
OBJECTS_READ: 'objects:read',
OBJECTS_CREATE: 'objects:create',
OBJECTS_UPDATE: 'objects:update',
+4 -2
View File
@@ -9,10 +9,12 @@ export const OAUTH_ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_
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_ACCOUNT_SCOPES = [AuthorizationScope.WORKSPACES_DISCOVER] as const
export const OAUTH_RESOURCE_SCOPES = CANONICAL_AUTHORIZATION_SCOPES.filter(
(scope) => scope !== AuthorizationScope.OBJECTS_PURGE,
(scope) => scope !== AuthorizationScope.OBJECTS_PURGE && !(OAUTH_ACCOUNT_SCOPES as readonly string[]).includes(scope),
)
export const OAUTH_SCOPES = [...OAUTH_STANDARD_SCOPES, ...OAUTH_RESOURCE_SCOPES] as const
export const OAUTH_GRANT_SCOPES = [...OAUTH_ACCOUNT_SCOPES, ...OAUTH_RESOURCE_SCOPES] as const
export const OAUTH_SCOPES = [...OAUTH_STANDARD_SCOPES, ...OAUTH_GRANT_SCOPES] as const
const EXPLICIT_SCOPE_DESCRIPTIONS: Partial<Record<AuthorizationScope, string>> = {
[AuthorizationScope.OBJECTS_READ]: 'List, inspect, and download objects',
[AuthorizationScope.OBJECTS_CREATE]: 'Create folders and upload objects',
+2 -2
View File
@@ -170,8 +170,8 @@ export {
oauthGrantSchema,
oauthGrantStatusSchema,
} from './oauth-grants'
export type { OAuthResourceScope } from './oauth-resource'
export { oauthResourceScopeLabels, oauthResourceScopeSchema } from './oauth-resource'
export type { OAuthGrantScope, OAuthResourceScope } from './oauth-resource'
export { oauthGrantScopeSchema, oauthResourceScopeLabels, oauthResourceScopeSchema } from './oauth-resource'
export type { CursorPage, CursorPageQuery, Page, PageQuery } from './pagination'
export { cursorPageQuerySchema, cursorPageSchema, pageQuerySchema, pageSchema } from './pagination'
export type { PublicProfile, PublicProfileShare, PublicUser } from './profile'
+3 -3
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'
import { OAUTH_ACCESS_TOKEN_SECONDS, OAUTH_REFRESH_TOKEN_SECONDS } from '../oauth'
import { oauthResourceScopeSchema } from './oauth-resource'
import { oauthGrantScopeSchema } from './oauth-resource'
export const oauthGrantStatusSchema = z.enum(['active'])
export type OAuthGrantStatus = z.infer<typeof oauthGrantStatusSchema>
@@ -11,7 +11,7 @@ export const oauthGrantSchema = z.object({
clientName: z.string(),
userId: z.string(),
workspaces: z.array(z.object({ id: z.string(), name: z.string().nullable() })).min(1),
scopes: z.array(oauthResourceScopeSchema),
scopes: z.array(oauthGrantScopeSchema),
createdAt: z.string(),
lastUsedAt: z.string().nullable(),
status: oauthGrantStatusSchema,
@@ -34,7 +34,7 @@ export const oauthConsentContextSchema = z.object({
)
.min(1),
requestedWorkspaceIds: z.array(z.string()),
scopes: z.array(oauthResourceScopeSchema),
scopes: z.array(oauthGrantScopeSchema),
standardScopes: z.array(z.string()),
redirectUri: z.string(),
grantLifetime: z.object({
+7 -4
View File
@@ -1,11 +1,14 @@
import { z } from 'zod'
import { AuthorizationScope } from '../authorization'
import { OAUTH_RESOURCE_SCOPES } from '../oauth'
import { OAUTH_GRANT_SCOPES, OAUTH_RESOURCE_SCOPES } from '../oauth'
export const oauthResourceScopeSchema = z.enum(OAUTH_RESOURCE_SCOPES)
export type OAuthResourceScope = z.infer<typeof oauthResourceScopeSchema>
export const oauthGrantScopeSchema = z.enum(OAUTH_GRANT_SCOPES)
export type OAuthGrantScope = z.infer<typeof oauthGrantScopeSchema>
const explicitOAuthResourceScopeLabels: Partial<Record<OAuthResourceScope, string>> = {
const explicitOAuthResourceScopeLabels: Partial<Record<OAuthGrantScope, string>> = {
[AuthorizationScope.WORKSPACES_DISCOVER]: 'settings.oauthApps.scope.workspacesDiscover',
[AuthorizationScope.OBJECTS_READ]: 'settings.oauthApps.scope.objectsRead',
[AuthorizationScope.OBJECTS_CREATE]: 'settings.oauthApps.scope.objectsCreate',
[AuthorizationScope.OBJECTS_UPDATE]: 'settings.oauthApps.scope.objectsUpdate',
@@ -19,5 +22,5 @@ const explicitOAuthResourceScopeLabels: Partial<Record<OAuthResourceScope, strin
}
export const oauthResourceScopeLabels = Object.fromEntries(
OAUTH_RESOURCE_SCOPES.map((scope) => [scope, explicitOAuthResourceScopeLabels[scope] ?? scope]),
) as Record<OAuthResourceScope, string>
OAUTH_GRANT_SCOPES.map((scope) => [scope, explicitOAuthResourceScopeLabels[scope] ?? scope]),
) as Record<OAuthGrantScope, string>
+1
View File
@@ -1209,6 +1209,7 @@
"settings.apiKeys.manage": "Manage API Keys",
"settings.oauthApps.workspaceLabel": "Workspace",
"settings.oauthApps.scope.objectsRead": "Files: read objects",
"settings.oauthApps.scope.workspacesDiscover": "Workspaces: discover accessible workspaces",
"settings.oauthApps.scope.objectsCreate": "Files: create objects",
"settings.oauthApps.scope.objectsUpdate": "Files: update objects",
"settings.oauthApps.scope.objectsDelete": "Files: delete objects",
+1
View File
@@ -1209,6 +1209,7 @@
"settings.apiKeys.manage": "管理 API Key",
"settings.oauthApps.workspaceLabel": "工作空间",
"settings.oauthApps.scope.objectsRead": "文件:读取对象",
"settings.oauthApps.scope.workspacesDiscover": "工作空间:发现可访问的工作空间",
"settings.oauthApps.scope.objectsCreate": "文件:创建对象",
"settings.oauthApps.scope.objectsUpdate": "文件:更新对象",
"settings.oauthApps.scope.objectsDelete": "文件:删除对象",