From 8513b18a8e0ec49db5cfce06face01dbc19fca6f Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Sun, 2 Aug 2026 21:00:20 -0400 Subject: [PATCH] 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 02009f098fb6ebe70668c92016179904056ce20b. * fix(oauth): refresh configured resource scopes --- cmd/internal/openapi/client.gen.go | 164 ++++++++++++++++++ docs/design/oauth-server.md | 10 ++ server/adapters/repos/oauth.test.ts | 47 ++++- server/adapters/repos/oauth.ts | 29 ++++ server/adapters/repos/org.integration.test.ts | 16 ++ server/adapters/repos/org.ts | 31 +++- server/app.ts | 17 +- server/auth.integration.test.ts | 45 ++++- server/auth/oauth-provider.test.ts | 23 +++ server/auth/oauth-provider.ts | 9 +- ...-authorization-details.integration.test.ts | 144 +++++++++++++++ server/http/oauth-authorization-details.ts | 59 +++++++ server/openapi.test.ts | 5 + .../usecases/oauth-authorization-details.ts | 58 +++++++ server/usecases/oauth-consent.test.ts | 14 ++ server/usecases/oauth-consent.ts | 10 +- server/usecases/oauth-grants.test.ts | 2 + server/usecases/oauth-grants.ts | 10 +- server/usecases/ports/oauth.ts | 7 + server/usecases/ports/org.ts | 8 + server/usecases/team.test.ts | 1 + shared/authorization.test.ts | 7 +- shared/authorization.ts | 1 + shared/oauth.ts | 6 +- shared/schemas/index.ts | 4 +- shared/schemas/oauth-grants.ts | 6 +- shared/schemas/oauth-resource.ts | 11 +- src/i18n/locales/en.json | 1 + src/i18n/locales/zh.json | 1 + 29 files changed, 716 insertions(+), 30 deletions(-) create mode 100644 server/http/oauth-authorization-details.integration.test.ts create mode 100644 server/http/oauth-authorization-details.ts create mode 100644 server/usecases/oauth-authorization-details.ts diff --git a/cmd/internal/openapi/client.gen.go b/cmd/internal/openapi/client.gen.go index 39c7a569..a23eecdf 100644 --- a/cmd/internal/openapi/client.gen.go +++ b/cmd/internal/openapi/client.gen.go @@ -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) diff --git a/docs/design/oauth-server.md b/docs/design/oauth-server.md index e20c435f..0a6a25e3 100644 --- a/docs/design/oauth-server.md +++ b/docs/design/oauth-server.md @@ -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. diff --git a/server/adapters/repos/oauth.test.ts b/server/adapters/repos/oauth.test.ts index 999ad9a9..3509eb47 100644 --- a/server/adapters/repos/oauth.test.ts +++ b/server/adapters/repos/oauth.test.ts @@ -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') diff --git a/server/adapters/repos/oauth.ts b/server/adapters/repos/oauth.ts index 047ab3e2..889d4460 100644 --- a/server/adapters/repos/oauth.ts +++ b/server/adapters/repos/oauth.ts @@ -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 { + 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(/=+$/, '') +} diff --git a/server/adapters/repos/org.integration.test.ts b/server/adapters/repos/org.integration.test.ts index 5eaed7b5..cf7db01b 100644 --- a/server/adapters/repos/org.integration.test.ts +++ b/server/adapters/repos/org.integration.test.ts @@ -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' }, + ]) + }) +}) diff --git a/server/adapters/repos/org.ts b/server/adapters/repos/org.ts index ea183ed1..6b740dfb 100644 --- a/server/adapters/repos/org.ts +++ b/server/adapters/repos/org.ts @@ -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, + } } diff --git a/server/app.ts b/server/app.ts index 6a3d09b0..88fe9442 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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 + 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', }) diff --git a/server/auth.integration.test.ts b/server/auth.integration.test.ts index c2d9e5f4..0bd954a8 100644 --- a/server/auth.integration.test.ts +++ b/server/auth.integration.test.ts @@ -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) diff --git a/server/auth/oauth-provider.test.ts b/server/auth/oauth-provider.test.ts index c4bbd07c..f6aa5279 100644 --- a/server/auth/oauth-provider.test.ts +++ b/server/auth/oauth-provider.test.ts @@ -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() + }) }) diff --git a/server/auth/oauth-provider.ts b/server/auth/oauth-provider.ts index 7730afbc..45523886 100644 --- a/server/auth/oauth-provider.ts +++ b/server/auth/oauth-provider.ts @@ -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') diff --git a/server/http/oauth-authorization-details.integration.test.ts b/server/http/oauth-authorization-details.integration.test.ts new file mode 100644 index 00000000..f2e46ede --- /dev/null +++ b/server/http/oauth-authorization-details.integration.test.ts @@ -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>['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 }) +} diff --git a/server/http/oauth-authorization-details.ts b/server/http/oauth-authorization-details.ts new file mode 100644 index 00000000..9e1462c1 --- /dev/null +++ b/server/http/oauth-authorization-details.ts @@ -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().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) +}) diff --git a/server/openapi.test.ts b/server/openapi.test.ts index fe7dc6fc..b09b5118 100644 --- a/server/openapi.test.ts +++ b/server/openapi.test.ts @@ -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 () => { diff --git a/server/usecases/oauth-authorization-details.ts b/server/usecases/oauth-authorization-details.ts new file mode 100644 index 00000000..0bcace26 --- /dev/null +++ b/server/usecases/oauth-authorization-details.ts @@ -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 + +export async function listOAuthAuthorizationDetailsCatalog( + deps: CatalogDeps, + input: { + db: Database + token: string + verifyJwtToken: () => Promise + }, +) { + 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) { + 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 + } +} diff --git a/server/usecases/oauth-consent.test.ts b/server/usecases/oauth-consent.test.ts index 320fa5fc..4aaa9644 100644 --- a/server/usecases/oauth-consent.test.ts +++ b/server/usecases/oauth-consent.test.ts @@ -11,6 +11,7 @@ const CLIENT_NAME = 'FlareAuth' function org(overrides: Partial = {}): 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()), { diff --git a/server/usecases/oauth-consent.ts b/server/usecases/oauth-consent.ts index 4ba6dbe0..2410c7cf 100644 --- a/server/usecases/oauth-consent.ts +++ b/server/usecases/oauth-consent.ts @@ -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 } diff --git a/server/usecases/oauth-grants.test.ts b/server/usecases/oauth-grants.test.ts index d142a3be..c077792a 100644 --- a/server/usecases/oauth-grants.test.ts +++ b/server/usecases/oauth-grants.test.ts @@ -7,6 +7,7 @@ const db = {} as never function gateway(overrides: Partial = {}): 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 { function org(overrides: Partial = {}): 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']])), diff --git a/server/usecases/oauth-grants.ts b/server/usecases/oauth-grants.ts index 7a443276..a401844e 100644 --- a/server/usecases/oauth-grants.ts +++ b/server/usecases/oauth-grants.ts @@ -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( diff --git a/server/usecases/ports/oauth.ts b/server/usecases/ports/oauth.ts index 3d9908e4..7e5abdeb 100644 --- a/server/usecases/ports/oauth.ts +++ b/server/usecases/ports/oauth.ts @@ -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 + resolveAccountAccessToken(db: Database, token: string, now?: Date): Promise listRegisteredApplications(db: Database): Promise revokeJwtAccessToken(db: Database, token: string): Promise isJwtAccessTokenRevoked(db: Database, tokenId: string): Promise diff --git a/server/usecases/ports/org.ts b/server/usecases/ports/org.ts index bd2c2e06..e26cc6ad 100644 --- a/server/usecases/ports/org.ts +++ b/server/usecases/ports/org.ts @@ -1,5 +1,13 @@ +export interface UserWorkspaceCatalogItem { + id: string + name: string + type: 'personal' | 'organization' + role: string +} + export interface OrgRepo { listUserOrgs(userId: string): Promise> + listUserWorkspaceCatalog(userId: string): Promise findPersonalOrg(userId: string): Promise getMemberRole(orgId: string, userId: string): Promise getOrgNames(orgIds: string[]): Promise> diff --git a/server/usecases/team.test.ts b/server/usecases/team.test.ts index b7b0c4bf..26f78ff1 100644 --- a/server/usecases/team.test.ts +++ b/server/usecases/team.test.ts @@ -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(), diff --git a/shared/authorization.test.ts b/shared/authorization.test.ts index 4cb2713a..5521da91 100644 --- a/shared/authorization.test.ts +++ b/shared/authorization.test.ts @@ -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']) }) diff --git a/shared/authorization.ts b/shared/authorization.ts index 3a2b5f2a..f68e4c98 100644 --- a/shared/authorization.ts +++ b/shared/authorization.ts @@ -1,4 +1,5 @@ export const AuthorizationScope = { + WORKSPACES_DISCOVER: 'workspaces:discover', OBJECTS_READ: 'objects:read', OBJECTS_CREATE: 'objects:create', OBJECTS_UPDATE: 'objects:update', diff --git a/shared/oauth.ts b/shared/oauth.ts index 7c944daf..af581463 100644 --- a/shared/oauth.ts +++ b/shared/oauth.ts @@ -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> = { [AuthorizationScope.OBJECTS_READ]: 'List, inspect, and download objects', [AuthorizationScope.OBJECTS_CREATE]: 'Create folders and upload objects', diff --git a/shared/schemas/index.ts b/shared/schemas/index.ts index ea2e7fa0..2606c350 100644 --- a/shared/schemas/index.ts +++ b/shared/schemas/index.ts @@ -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' diff --git a/shared/schemas/oauth-grants.ts b/shared/schemas/oauth-grants.ts index 4214b191..d246d6f1 100644 --- a/shared/schemas/oauth-grants.ts +++ b/shared/schemas/oauth-grants.ts @@ -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 @@ -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({ diff --git a/shared/schemas/oauth-resource.ts b/shared/schemas/oauth-resource.ts index bc3ca44f..04a957bb 100644 --- a/shared/schemas/oauth-resource.ts +++ b/shared/schemas/oauth-resource.ts @@ -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 +export const oauthGrantScopeSchema = z.enum(OAUTH_GRANT_SCOPES) +export type OAuthGrantScope = z.infer -const explicitOAuthResourceScopeLabels: Partial> = { +const explicitOAuthResourceScopeLabels: Partial> = { + [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 [scope, explicitOAuthResourceScopeLabels[scope] ?? scope]), -) as Record + OAUTH_GRANT_SCOPES.map((scope) => [scope, explicitOAuthResourceScopeLabels[scope] ?? scope]), +) as Record diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 79634ea9..bd4f7867 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 3dbf0adf..b651e361 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "文件:删除对象",