mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()), {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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']])),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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,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,4 +1,5 @@
|
||||
export const AuthorizationScope = {
|
||||
WORKSPACES_DISCOVER: 'workspaces:discover',
|
||||
OBJECTS_READ: 'objects:read',
|
||||
OBJECTS_CREATE: 'objects:create',
|
||||
OBJECTS_UPDATE: 'objects:update',
|
||||
|
||||
+4
-2
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "文件:删除对象",
|
||||
|
||||
Reference in New Issue
Block a user