feat: add agent oauth consent management UI (#541)

* feat: add agent oauth consent management UI

Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a

* test: cover agent oauth consent edge paths

Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a

* fix: route agent oauth consent through rpc

Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a

* test: cover agent oauth consent rpc on workers

Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a

* test: cover agent oauth grant-use middleware

Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a

---------

Co-authored-by: Iris Tan <iris-tan@mails.agent-kanban.dev>
This commit is contained in:
agent-kanban[bot]
2026-07-29 16:52:17 -04:00
committed by GitHub
parent 00d298627c
commit 88916f4f03
34 changed files with 7603 additions and 105 deletions
+475 -64
View File
@@ -2029,41 +2029,65 @@ func (e WebDavVerificationStatus) Valid() bool {
}
}
// Defines values for GetAgentOAuthConsentContext200JSONResponseBodyScopes.
const (
GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsCreate GetAgentOAuthConsentContext200JSONResponseBodyScopes = "objects:create"
GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsDelete GetAgentOAuthConsentContext200JSONResponseBodyScopes = "objects:delete"
GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsRead GetAgentOAuthConsentContext200JSONResponseBodyScopes = "objects:read"
GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsUpdate GetAgentOAuthConsentContext200JSONResponseBodyScopes = "objects:update"
GetAgentOAuthConsentContext200JSONResponseBodyScopesQuotaRead GetAgentOAuthConsentContext200JSONResponseBodyScopes = "quota:read"
GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesCreate GetAgentOAuthConsentContext200JSONResponseBodyScopes = "shares:create"
GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesDelete GetAgentOAuthConsentContext200JSONResponseBodyScopes = "shares:delete"
GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesRead GetAgentOAuthConsentContext200JSONResponseBodyScopes = "shares:read"
GetAgentOAuthConsentContext200JSONResponseBodyScopesStorageUsageRead GetAgentOAuthConsentContext200JSONResponseBodyScopes = "storage-usage:read"
)
// Valid indicates whether the value is a known member of the GetAgentOAuthConsentContext200JSONResponseBodyScopes enum.
func (e GetAgentOAuthConsentContext200JSONResponseBodyScopes) Valid() bool {
switch e {
case GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsCreate:
return true
case GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsDelete:
return true
case GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsRead:
return true
case GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsUpdate:
return true
case GetAgentOAuthConsentContext200JSONResponseBodyScopesQuotaRead:
return true
case GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesCreate:
return true
case GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesDelete:
return true
case GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesRead:
return true
case GetAgentOAuthConsentContext200JSONResponseBodyScopesStorageUsageRead:
return true
default:
return false
}
}
// Defines values for ListAgentOAuthGrants200JSONResponseBodyItemsScopes.
const (
ListAgentOAuthGrants200JSONResponseBodyItemsScopesDownloadTasksCancel ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "download-tasks:cancel"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesDownloadTasksCreate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "download-tasks:create"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesDownloadTasksRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "download-tasks:read"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesImagesUpload ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "images:upload"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsCreate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:create"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsDelete ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:delete"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsPurge ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:purge"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:read"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsUpdate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:update"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesQuotaRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "quota:read"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesCreate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:create"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesDelete ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:delete"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:read"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesStorageUsageRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "storage-usage:read"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsCreate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:create"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsDelete ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:delete"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:read"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsUpdate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:update"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesQuotaRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "quota:read"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesCreate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:create"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesDelete ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:delete"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:read"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesStorageUsageRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "storage-usage:read"
)
// Valid indicates whether the value is a known member of the ListAgentOAuthGrants200JSONResponseBodyItemsScopes enum.
func (e ListAgentOAuthGrants200JSONResponseBodyItemsScopes) Valid() bool {
switch e {
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesDownloadTasksCancel:
return true
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesDownloadTasksCreate:
return true
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesDownloadTasksRead:
return true
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesImagesUpload:
return true
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsCreate:
return true
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsDelete:
return true
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsPurge:
return true
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsRead:
return true
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsUpdate:
@@ -2083,6 +2107,21 @@ func (e ListAgentOAuthGrants200JSONResponseBodyItemsScopes) Valid() bool {
}
}
// Defines values for ListAgentOAuthGrants200JSONResponseBodyItemsStatus.
const (
ListAgentOAuthGrants200JSONResponseBodyItemsStatusActive ListAgentOAuthGrants200JSONResponseBodyItemsStatus = "active"
)
// Valid indicates whether the value is a known member of the ListAgentOAuthGrants200JSONResponseBodyItemsStatus enum.
func (e ListAgentOAuthGrants200JSONResponseBodyItemsStatus) Valid() bool {
switch e {
case ListAgentOAuthGrants200JSONResponseBodyItemsStatusActive:
return true
default:
return false
}
}
// Defines values for ChangeEmail200JSONResponseBodyMessage.
const (
ChangeEmail200JSONResponseBodyMessageEmailUpdated ChangeEmail200JSONResponseBodyMessage = "Email updated"
@@ -3660,37 +3699,37 @@ func (e RotateWorkspaceAgentApiKeyJSONBodyScopes) Valid() bool {
// Defines values for RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes.
const (
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsCreate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:create"
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsDelete RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:delete"
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:read"
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsUpdate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:update"
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesQuotaRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "quota:read"
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesCreate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:create"
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesDelete RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:delete"
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:read"
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesStorageUsageRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "storage-usage:read"
ObjectsCreate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:create"
ObjectsDelete RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:delete"
ObjectsRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:read"
ObjectsUpdate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:update"
QuotaRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "quota:read"
SharesCreate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:create"
SharesDelete RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:delete"
SharesRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:read"
StorageUsageRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "storage-usage:read"
)
// Valid indicates whether the value is a known member of the RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes enum.
func (e RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes) Valid() bool {
switch e {
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsCreate:
case ObjectsCreate:
return true
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsDelete:
case ObjectsDelete:
return true
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsRead:
case ObjectsRead:
return true
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsUpdate:
case ObjectsUpdate:
return true
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesQuotaRead:
case QuotaRead:
return true
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesCreate:
case SharesCreate:
return true
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesDelete:
case SharesDelete:
return true
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesRead:
case SharesRead:
return true
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesStorageUsageRead:
case StorageUsageRead:
return true
default:
return false
@@ -3699,22 +3738,22 @@ func (e RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes) Valid() bool {
// Defines values for RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus.
const (
RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusActive RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "active"
RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusExpired RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "expired"
RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusInaccessible RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "inaccessible"
RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusRevoked RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "revoked"
Active RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "active"
Expired RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "expired"
Inaccessible RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "inaccessible"
Revoked RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "revoked"
)
// Valid indicates whether the value is a known member of the RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus enum.
func (e RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus) Valid() bool {
switch e {
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusActive:
case Active:
return true
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusExpired:
case Expired:
return true
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusInaccessible:
case Inaccessible:
return true
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusRevoked:
case Revoked:
return true
default:
return false
@@ -5989,9 +6028,26 @@ type User struct {
// WebDavVerificationStatus defines model for WebDavVerificationStatus.
type WebDavVerificationStatus string
// GetAgentOAuthConsentContextParams defines parameters for GetAgentOAuthConsentContext.
type GetAgentOAuthConsentContextParams struct {
OauthQuery string `form:"oauthQuery" json:"oauthQuery"`
}
// GetAgentOAuthConsentContext200JSONResponseBodyScopes defines parameters for GetAgentOAuthConsentContext.
type GetAgentOAuthConsentContext200JSONResponseBodyScopes string
// SubmitAgentOAuthConsentJSONBody defines parameters for SubmitAgentOAuthConsent.
type SubmitAgentOAuthConsentJSONBody struct {
Accept bool `json:"accept"`
OauthQuery string `json:"oauthQuery"`
}
// ListAgentOAuthGrants200JSONResponseBodyItemsScopes defines parameters for ListAgentOAuthGrants.
type ListAgentOAuthGrants200JSONResponseBodyItemsScopes string
// ListAgentOAuthGrants200JSONResponseBodyItemsStatus defines parameters for ListAgentOAuthGrants.
type ListAgentOAuthGrants200JSONResponseBodyItemsStatus string
// BanUserJSONBody defines parameters for BanUser.
type BanUserJSONBody struct {
// BanExpiresIn The number of seconds until the ban expires
@@ -7798,6 +7854,9 @@ type RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes string
// RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus defines parameters for RotateWorkspaceAgentApiKey.
type RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus string
// SubmitAgentOAuthConsentJSONRequestBody defines body for SubmitAgentOAuthConsent for application/json ContentType.
type SubmitAgentOAuthConsentJSONRequestBody SubmitAgentOAuthConsentJSONBody
// BanUserJSONRequestBody defines body for BanUser for application/json ContentType.
type BanUserJSONRequestBody BanUserJSONBody
@@ -8842,6 +8901,14 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption {
// The interface specification for the client above.
type ClientInterface interface {
// GetAgentOAuthConsentContext request
GetAgentOAuthConsentContext(ctx context.Context, params *GetAgentOAuthConsentContextParams, reqEditors ...RequestEditorFn) (*http.Response, error)
// SubmitAgentOAuthConsentWithBody request with any body
SubmitAgentOAuthConsentWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
SubmitAgentOAuthConsent(ctx context.Context, body SubmitAgentOAuthConsentJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// ListAgentOAuthGrants request
ListAgentOAuthGrants(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -9873,6 +9940,42 @@ type ClientInterface interface {
RotateWorkspaceAgentApiKey(ctx context.Context, orgId string, keyId string, body RotateWorkspaceAgentApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
}
func (c *Client) GetAgentOAuthConsentContext(ctx context.Context, params *GetAgentOAuthConsentContextParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewGetAgentOAuthConsentContextRequest(c.Server, params)
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) SubmitAgentOAuthConsentWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewSubmitAgentOAuthConsentRequestWithBody(c.Server, contentType, body)
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) SubmitAgentOAuthConsent(ctx context.Context, body SubmitAgentOAuthConsentJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewSubmitAgentOAuthConsentRequest(c.Server, body)
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) ListAgentOAuthGrants(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewListAgentOAuthGrantsRequest(c.Server)
if err != nil {
@@ -14493,6 +14596,96 @@ func (c *Client) RotateWorkspaceAgentApiKey(ctx context.Context, orgId string, k
return c.Client.Do(req)
}
// NewGetAgentOAuthConsentContextRequest generates requests for GetAgentOAuthConsentContext
func NewGetAgentOAuthConsentContextRequest(server string, params *GetAgentOAuthConsentContextParams) (*http.Request, error) {
var err error
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/agent-oauth-consent")
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
if params != nil {
// queryValues collects non-styled parameters (passthrough, JSON)
// that are safe to round-trip through url.Values.Encode().
queryValues := queryURL.Query()
// rawQueryFragments collects pre-encoded query fragments from
// styled parameters, preserving literal commas as delimiters
// per the OpenAPI spec (e.g. "color=blue,black,brown").
var rawQueryFragments []string
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "oauthQuery", params.OauthQuery, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
return nil, err
} else {
for _, qp := range strings.Split(queryFrag, "&") {
rawQueryFragments = append(rawQueryFragments, qp)
}
}
if encoded := queryValues.Encode(); encoded != "" {
rawQueryFragments = append(rawQueryFragments, encoded)
}
queryURL.RawQuery = strings.Join(rawQueryFragments, "&")
}
req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewSubmitAgentOAuthConsentRequest calls the generic SubmitAgentOAuthConsent builder with application/json body
func NewSubmitAgentOAuthConsentRequest(server string, body SubmitAgentOAuthConsentJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(buf)
return NewSubmitAgentOAuthConsentRequestWithBody(server, "application/json", bodyReader)
}
// NewSubmitAgentOAuthConsentRequestWithBody generates requests for SubmitAgentOAuthConsent with any type of body
func NewSubmitAgentOAuthConsentRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) {
var err error
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/agent-oauth-consent")
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, queryURL.String(), body)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", contentType)
return req, nil
}
// NewListAgentOAuthGrantsRequest generates requests for ListAgentOAuthGrants
func NewListAgentOAuthGrantsRequest(server string) (*http.Request, error) {
var err error
@@ -25881,6 +26074,14 @@ func WithBaseURL(baseURL string) ClientOption {
// ClientWithResponsesInterface is the interface specification for the client with responses above.
type ClientWithResponsesInterface interface {
// GetAgentOAuthConsentContextWithResponse request
GetAgentOAuthConsentContextWithResponse(ctx context.Context, params *GetAgentOAuthConsentContextParams, reqEditors ...RequestEditorFn) (*GetAgentOAuthConsentContextResponse, error)
// SubmitAgentOAuthConsentWithBodyWithResponse request with any body
SubmitAgentOAuthConsentWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SubmitAgentOAuthConsentResponse, error)
SubmitAgentOAuthConsentWithResponse(ctx context.Context, body SubmitAgentOAuthConsentJSONRequestBody, reqEditors ...RequestEditorFn) (*SubmitAgentOAuthConsentResponse, error)
// ListAgentOAuthGrantsWithResponse request
ListAgentOAuthGrantsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListAgentOAuthGrantsResponse, error)
@@ -26912,18 +27113,102 @@ type ClientWithResponsesInterface interface {
RotateWorkspaceAgentApiKeyWithResponse(ctx context.Context, orgId string, keyId string, body RotateWorkspaceAgentApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*RotateWorkspaceAgentApiKeyResponse, error)
}
type GetAgentOAuthConsentContextResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *struct {
ClientId string `json:"clientId"`
ClientName string `json:"clientName"`
GrantLifetime struct {
AccessTokenSeconds *int `json:"accessTokenSeconds,omitempty"`
RefreshTokenSeconds *int `json:"refreshTokenSeconds,omitempty"`
} `json:"grantLifetime"`
InstanceOrigin string `json:"instanceOrigin"`
RedirectUri string `json:"redirectUri"`
Scopes []GetAgentOAuthConsentContext200JSONResponseBodyScopes `json:"scopes"`
StandardScopes []string `json:"standardScopes"`
Workspace struct {
Id string `json:"id"`
Name *string `json:"name"`
} `json:"workspace"`
}
JSON400 *Error
JSON403 *Error
}
// Status returns HTTPResponse.Status
func (r GetAgentOAuthConsentContextResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r GetAgentOAuthConsentContextResponse) 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 GetAgentOAuthConsentContextResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type SubmitAgentOAuthConsentResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *struct {
Url string `json:"url"`
}
JSON400 *Error
JSON403 *Error
}
// Status returns HTTPResponse.Status
func (r SubmitAgentOAuthConsentResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r SubmitAgentOAuthConsentResponse) 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 SubmitAgentOAuthConsentResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type ListAgentOAuthGrantsResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *struct {
Items []struct {
ClientId string `json:"clientId"`
CreatedAt string `json:"createdAt"`
Id string `json:"id"`
OrgId string `json:"orgId"`
Scopes []ListAgentOAuthGrants200JSONResponseBodyItemsScopes `json:"scopes"`
UpdatedAt string `json:"updatedAt"`
UserId string `json:"userId"`
ClientId string `json:"clientId"`
ClientName *string `json:"clientName,omitempty"`
CreatedAt string `json:"createdAt"`
Id string `json:"id"`
LastUsedAt *string `json:"lastUsedAt"`
OrgId string `json:"orgId"`
Scopes []ListAgentOAuthGrants200JSONResponseBodyItemsScopes `json:"scopes"`
Status ListAgentOAuthGrants200JSONResponseBodyItemsStatus `json:"status"`
UserId string `json:"userId"`
WorkspaceName *string `json:"workspaceName"`
} `json:"items"`
}
}
@@ -37771,6 +38056,32 @@ func (r RotateWorkspaceAgentApiKeyResponse) ContentType() string {
return ""
}
// GetAgentOAuthConsentContextWithResponse request returning *GetAgentOAuthConsentContextResponse
func (c *ClientWithResponses) GetAgentOAuthConsentContextWithResponse(ctx context.Context, params *GetAgentOAuthConsentContextParams, reqEditors ...RequestEditorFn) (*GetAgentOAuthConsentContextResponse, error) {
rsp, err := c.GetAgentOAuthConsentContext(ctx, params, reqEditors...)
if err != nil {
return nil, err
}
return ParseGetAgentOAuthConsentContextResponse(rsp)
}
// SubmitAgentOAuthConsentWithBodyWithResponse request with arbitrary body returning *SubmitAgentOAuthConsentResponse
func (c *ClientWithResponses) SubmitAgentOAuthConsentWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SubmitAgentOAuthConsentResponse, error) {
rsp, err := c.SubmitAgentOAuthConsentWithBody(ctx, contentType, body, reqEditors...)
if err != nil {
return nil, err
}
return ParseSubmitAgentOAuthConsentResponse(rsp)
}
func (c *ClientWithResponses) SubmitAgentOAuthConsentWithResponse(ctx context.Context, body SubmitAgentOAuthConsentJSONRequestBody, reqEditors ...RequestEditorFn) (*SubmitAgentOAuthConsentResponse, error) {
rsp, err := c.SubmitAgentOAuthConsent(ctx, body, reqEditors...)
if err != nil {
return nil, err
}
return ParseSubmitAgentOAuthConsentResponse(rsp)
}
// ListAgentOAuthGrantsWithResponse request returning *ListAgentOAuthGrantsResponse
func (c *ClientWithResponses) ListAgentOAuthGrantsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListAgentOAuthGrantsResponse, error) {
rsp, err := c.ListAgentOAuthGrants(ctx, reqEditors...)
@@ -41111,6 +41422,103 @@ func (c *ClientWithResponses) RotateWorkspaceAgentApiKeyWithResponse(ctx context
return ParseRotateWorkspaceAgentApiKeyResponse(rsp)
}
// ParseGetAgentOAuthConsentContextResponse parses an HTTP response from a GetAgentOAuthConsentContextWithResponse call
func ParseGetAgentOAuthConsentContextResponse(rsp *http.Response) (*GetAgentOAuthConsentContextResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &GetAgentOAuthConsentContextResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest struct {
ClientId string `json:"clientId"`
ClientName string `json:"clientName"`
GrantLifetime struct {
AccessTokenSeconds *int `json:"accessTokenSeconds,omitempty"`
RefreshTokenSeconds *int `json:"refreshTokenSeconds,omitempty"`
} `json:"grantLifetime"`
InstanceOrigin string `json:"instanceOrigin"`
RedirectUri string `json:"redirectUri"`
Scopes []GetAgentOAuthConsentContext200JSONResponseBodyScopes `json:"scopes"`
StandardScopes []string `json:"standardScopes"`
Workspace struct {
Id string `json:"id"`
Name *string `json:"name"`
} `json:"workspace"`
}
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 == 400:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON400 = &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
}
// ParseSubmitAgentOAuthConsentResponse parses an HTTP response from a SubmitAgentOAuthConsentWithResponse call
func ParseSubmitAgentOAuthConsentResponse(rsp *http.Response) (*SubmitAgentOAuthConsentResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &SubmitAgentOAuthConsentResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest struct {
Url string `json:"url"`
}
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 == 400:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON400 = &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
}
// ParseListAgentOAuthGrantsResponse parses an HTTP response from a ListAgentOAuthGrantsWithResponse call
func ParseListAgentOAuthGrantsResponse(rsp *http.Response) (*ListAgentOAuthGrantsResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
@@ -41128,13 +41536,16 @@ func ParseListAgentOAuthGrantsResponse(rsp *http.Response) (*ListAgentOAuthGrant
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest struct {
Items []struct {
ClientId string `json:"clientId"`
CreatedAt string `json:"createdAt"`
Id string `json:"id"`
OrgId string `json:"orgId"`
Scopes []ListAgentOAuthGrants200JSONResponseBodyItemsScopes `json:"scopes"`
UpdatedAt string `json:"updatedAt"`
UserId string `json:"userId"`
ClientId string `json:"clientId"`
ClientName *string `json:"clientName,omitempty"`
CreatedAt string `json:"createdAt"`
Id string `json:"id"`
LastUsedAt *string `json:"lastUsedAt"`
OrgId string `json:"orgId"`
Scopes []ListAgentOAuthGrants200JSONResponseBodyItemsScopes `json:"scopes"`
Status ListAgentOAuthGrants200JSONResponseBodyItemsStatus `json:"status"`
UserId string `json:"userId"`
WorkspaceName *string `json:"workspaceName"`
} `json:"items"`
}
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+169
View File
@@ -0,0 +1,169 @@
import { expect, test } from '@playwright/test'
import { signUpAndGoToFiles } from './helpers'
const oauthQuery =
'client_id=zpan-agent&redirect_uri=http%3A%2F%2F127.0.0.1%3A8484%2Fcallback&response_type=code&scope=openid%20offline_access%20objects%3Aread%20shares%3Acreate%20quota%3Aread'
test.describe('Agent Access OAuth UI', () => {
test('renders consent details and submits full approval @desktop', async ({ page }) => {
await signUpAndGoToFiles(page)
await page.route('**/api/agent-oauth-consent?*', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
instanceOrigin: 'http://localhost:5185',
workspace: { id: 'org-e2e', name: 'Personal' },
scopes: ['objects:read', 'shares:create', 'quota:read'],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'http://127.0.0.1:8484/callback',
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
}),
})
})
await page.route('**/api/agent-oauth-consent', async (route) => {
if (route.request().method() !== 'POST') return route.fallback()
expect(route.request().method()).toBe('POST')
const body = route.request().postDataJSON() as { accept: boolean; oauthQuery?: string; scope?: string }
expect(body).toEqual({ accept: true, oauthQuery })
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ url: 'http://127.0.0.1:8484/callback?code=e2e-code' }),
})
})
await page.route('http://127.0.0.1:8484/callback?code=e2e-code', async (route) => {
await route.fulfill({ contentType: 'text/html', body: '<main>Returned to Restish</main>' })
})
await page.goto(`/settings/agent-access?${oauthQuery}`)
await expect(page.getByRole('heading', { name: 'Authorize ZPan Agent' })).toBeVisible()
await expect(page.getByText('http://localhost:5185')).toBeVisible()
await expect(page.getByText('http://127.0.0.1:8484/callback')).toBeVisible()
await expect(page.getByText('Files: read objects')).toBeVisible()
await expect(page.getByText('Shares: create shares')).toBeVisible()
await expect(page.getByText('Quota: read workspace quota')).toBeVisible()
await page.getByRole('button', { name: 'Approve Access' }).click()
await expect(page).toHaveURL(/127\.0\.0\.1:8484\/callback\?code=e2e-code/, { timeout: 10000 })
await expect(page.getByText('Returned to Restish')).toBeVisible()
})
test('lists and revokes delegated grants in settings @desktop', async ({ page }) => {
await signUpAndGoToFiles(page)
let revoked = false
await page.route('**/api/agent-oauth-grants', async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
items: revoked
? []
: [
{
id: 'grant-e2e',
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
userId: 'user-e2e',
orgId: 'org-e2e',
workspaceName: 'Personal',
scopes: ['objects:read', 'shares:create'],
createdAt: '2026-07-29T12:00:00.000Z',
lastUsedAt: null,
status: 'active',
},
],
}),
})
})
await page.route('**/api/agent-oauth-grants/grant-e2e', async (route) => {
expect(route.request().method()).toBe('DELETE')
revoked = true
await route.fulfill({ status: 204 })
})
await page.goto('/settings/agent-access')
await expect(page.getByText('Delegated OAuth Grants')).toBeVisible()
await expect(page.getByRole('cell', { name: 'ZPan Agent' })).toBeVisible()
await expect(page.getByText('Shares: create shares')).toBeVisible()
const revokeButtons = page.getByRole('button', { name: 'Revoke' })
await revokeButtons.last().click()
await expect(page.getByRole('dialog', { name: 'Revoke OAuth Grant' })).toBeVisible()
await page.getByRole('dialog').getByRole('button', { name: 'Revoke' }).click()
await expect(page.getByText('No delegated OAuth grants yet')).toBeVisible()
})
test('keeps consent and delegated grants usable on narrow screens @mobile', async ({ page }) => {
await signUpAndGoToFiles(page)
await page.route('**/api/agent-oauth-consent?*', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
instanceOrigin: 'http://localhost:5185',
workspace: { id: 'org-e2e', name: 'Personal' },
scopes: ['objects:read', 'shares:create', 'quota:read'],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'http://127.0.0.1:8484/callback',
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
}),
})
})
await page.route('**/api/agent-oauth-grants', async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
items: [
{
id: 'grant-mobile',
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
userId: 'user-e2e',
orgId: 'org-e2e',
workspaceName: 'Personal',
scopes: ['objects:read', 'shares:create', 'quota:read'],
createdAt: '2026-07-29T12:00:00.000Z',
lastUsedAt: '2026-07-29T12:30:00.000Z',
status: 'active',
},
],
}),
})
})
await page.goto(`/settings/agent-access?${oauthQuery}`)
await expect(page.getByRole('heading', { name: 'Authorize ZPan Agent' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Approve Access' })).toBeVisible()
await expect(page.getByText('Files: read objects')).toBeVisible()
await expect(page.getByText('Shares: create shares')).toBeVisible()
await expect
.poll(async () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1))
.toBe(true)
await page.goto('/settings/agent-access')
await expect(page.getByText('Delegated OAuth Grants')).toBeVisible()
await expect(page.getByRole('cell', { name: 'ZPan Agent' })).toBeVisible()
const grantsTableContainer = page.locator('[data-slot="table-container"]').last()
await expect(grantsTableContainer).toBeVisible()
await expect
.poll(async () =>
grantsTableContainer.evaluate((node) => (node as HTMLElement).scrollWidth > (node as HTMLElement).clientWidth),
)
.toBe(true)
await expect
.poll(async () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1))
.toBe(true)
await grantsTableContainer.evaluate((node) => {
node.scrollLeft = node.scrollWidth
})
await expect(page.getByRole('button', { name: 'Revoke' }).last()).toBeVisible()
})
})
@@ -0,0 +1 @@
ALTER TABLE `oauthConsent` ADD `last_used_at` integer;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -568,6 +568,13 @@
"when": 1785337905649,
"tag": "0081_spotty_boomerang",
"breakpoints": true
},
{
"idx": 82,
"version": "6",
"when": 1785351402721,
"tag": "0082_agent_oauth_consent_last_used_at",
"breakpoints": true
}
]
}
+65 -1
View File
@@ -124,6 +124,7 @@ describe('Agent OAuth gateway', () => {
referenceId: orgId,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date('2026-07-29T12:00:00.000Z'),
lastUsedAt: new Date('2026-07-29T12:20:00.000Z'),
updatedAt: new Date('2026-07-29T12:01:00.000Z'),
},
{
@@ -133,9 +134,32 @@ describe('Agent OAuth gateway', () => {
referenceId: null,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date('2026-07-29T12:02:00.000Z'),
lastUsedAt: null,
updatedAt: new Date('2026-07-29T12:03:00.000Z'),
},
])
await db.insert(authSchema.oauthAccessToken).values([
{
id: 'access-older',
token: 'hashed-access-older',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date('2026-07-29T12:05:00.000Z'),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
},
{
id: 'access-newer',
token: 'hashed-access-newer',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date('2026-07-29T12:10:00.000Z'),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
},
])
await expect(createAgentOAuthGateway().listGrants(db, userId)).resolves.toEqual([
{
@@ -145,11 +169,51 @@ describe('Agent OAuth gateway', () => {
orgId,
scopes: [AuthorizationScope.OBJECTS_READ],
createdAt: '2026-07-29T12:00:00.000Z',
updatedAt: '2026-07-29T12:01:00.000Z',
lastUsedAt: '2026-07-29T12:20:00.000Z',
},
])
})
it('records actual delegated grant use without treating token issuance as use', async () => {
const { db } = await createTestApp()
const gateway = createAgentOAuthGateway()
const userId = 'oauth-user'
const orgId = 'oauth-org'
await insertUserAndOrg(db, userId, orgId)
await db.insert(authSchema.oauthConsent).values({
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
createdAt: new Date('2026-07-29T12:00:00.000Z'),
updatedAt: new Date('2026-07-29T12:01:00.000Z'),
})
await db.insert(authSchema.oauthAccessToken).values({
id: 'access-1',
token: 'hashed-access',
clientId: AGENT_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date('2026-07-29T12:10:00.000Z'),
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
})
await expect(gateway.listGrants(db, userId)).resolves.toMatchObject([{ id: 'grant-1', lastUsedAt: null }])
await gateway.recordGrantUse(db, {
grantId: 'grant-1',
userId,
orgId,
now: new Date('2026-07-29T12:30:00.000Z'),
})
await expect(gateway.listGrants(db, userId)).resolves.toMatchObject([
{ id: 'grant-1', lastUsedAt: '2026-07-29T12:30:00.000Z' },
])
})
it('revokes only the managed client grant for the selected workspace', async () => {
const { db } = await createTestApp()
const userId = 'oauth-user'
+16 -2
View File
@@ -125,7 +125,7 @@ export function createAgentOAuthGateway(): AgentOAuthGateway {
orgId: oauthConsent.referenceId,
scopes: oauthConsent.scopes,
createdAt: oauthConsent.createdAt,
updatedAt: oauthConsent.updatedAt,
lastUsedAt: oauthConsent.lastUsedAt,
})
.from(oauthConsent)
.where(and(eq(oauthConsent.userId, userId), eq(oauthConsent.clientId, AGENT_OAUTH_CLIENT_ID)))
@@ -139,12 +139,26 @@ export function createAgentOAuthGateway(): AgentOAuthGateway {
orgId: row.orgId,
scopes: parseScopes(row.scopes).filter(isAuthorizationScope),
createdAt: toIso(row.createdAt),
updatedAt: toIso(row.updatedAt),
lastUsedAt: row.lastUsedAt ? toIso(row.lastUsedAt) : null,
},
]
})
},
async recordGrantUse(db, input) {
await db
.update(oauthConsent)
.set({ lastUsedAt: input.now })
.where(
and(
eq(oauthConsent.id, input.grantId),
eq(oauthConsent.userId, input.userId),
eq(oauthConsent.referenceId, input.orgId),
eq(oauthConsent.clientId, AGENT_OAUTH_CLIENT_ID),
),
)
},
async revokeGrant(db, input) {
const grants = await db
.select({
+12 -2
View File
@@ -1,5 +1,5 @@
import { isPersonalOrgLike } from '@shared/org-slugs'
import { and, eq } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import { member, organization } from '../../db/auth-schema'
import type { Database } from '../../platform/interface'
import type { OrgRepo } from '../../usecases/ports'
@@ -29,6 +29,16 @@ export function createOrgRepo(db: Database): OrgRepo {
return rows[0]?.role ?? null
}
async function getOrgNames(orgIds: string[]): Promise<Map<string, string>> {
const unique = [...new Set(orgIds)].filter(Boolean)
if (unique.length === 0) return new Map()
const rows = await db
.select({ id: organization.id, name: organization.name })
.from(organization)
.where(inArray(organization.id, unique))
return new Map(rows.map((row) => [row.id, row.name]))
}
async function isPersonalOrg(orgId: string): Promise<boolean> {
const rows = await db
.select({ slug: organization.slug, metadata: organization.metadata })
@@ -61,5 +71,5 @@ export function createOrgRepo(db: Database): OrgRepo {
return orgId === (await findPersonalOrg(userId))
}
return { findPersonalOrg, getMemberRole, canReadOrg, canWriteToOrg, canManageAgentAccess, isPersonalOrg }
return { findPersonalOrg, getMemberRole, getOrgNames, canReadOrg, canWriteToOrg, canManageAgentAccess, isPersonalOrg }
}
+1
View File
@@ -537,3 +537,4 @@ export type AdminOverviewRoute = typeof adminOverview
export type AdminStatsRoute = typeof adminStats
export type StorageUsageRoute = typeof storageUsage
export type AgentApiKeysRoute = typeof agentApiKeys
export type AgentOAuthGrantsRoute = typeof agentOAuthGrants
+1
View File
@@ -12,6 +12,7 @@ function createGateway(): AgentOAuthGateway {
assertLiveGrant: vi.fn(),
verifyAccessToken: vi.fn(),
listGrants: vi.fn(),
recordGrantUse: vi.fn(),
revokeGrant: vi.fn(),
}
}
+2
View File
@@ -154,6 +154,8 @@ describe('Agent OAuth tables', () => {
expect(oauthConsent.referenceId.name).toBe('reference_id')
expect(oauthConsent.scopes.notNull).toBe(true)
expect(oauthConsent.lastUsedAt.name).toBe('last_used_at')
expect(oauthConsent.lastUsedAt.notNull).toBe(false)
expect(foreignKeys).toHaveLength(2)
expect(oauthConsent.updatedAt.onUpdateFn?.()).toBeInstanceOf(Date)
expect(foreignKeys.map((foreignKey) => foreignKey.reference().foreignColumns[0].name)).toEqual(['client_id', 'id'])
+1
View File
@@ -327,6 +327,7 @@ export const oauthConsent = sqliteTable(
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
lastUsedAt: integer('last_used_at', { mode: 'timestamp_ms' }),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
@@ -1,7 +1,12 @@
import { createHash } from 'node:crypto'
import { AGENT_OAUTH_CLIENT_ID } from '@shared/agent-oauth'
import {
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
AGENT_OAUTH_CLIENT_ID,
AGENT_OAUTH_CLIENT_NAME,
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
} from '@shared/agent-oauth'
import { AuthorizationScope } from '@shared/authorization'
import { sql } from 'drizzle-orm'
import { eq, sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../db/auth-schema.js'
import { authedHeaders, createTestApp } from '../test/setup.js'
@@ -71,6 +76,88 @@ async function insertGrant(
}
describe('Agent OAuth grants API integration', () => {
it('returns server-owned Agent OAuth consent context for the active workspace', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app, 'agent-consent@example.com')
const { orgId } = await getUserAndPersonalOrg(db, 'agent-consent@example.com')
const oauthQuery = new URLSearchParams({
client_id: AGENT_OAUTH_CLIENT_ID,
redirect_uri: 'http://127.0.0.1:8484/callback',
response_type: 'code',
scope: `${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ} openid offline_access`,
}).toString()
const res = await app.request(`/api/agent-oauth-consent?oauthQuery=${encodeURIComponent(oauthQuery)}`, { headers })
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({
clientId: AGENT_OAUTH_CLIENT_ID,
clientName: AGENT_OAUTH_CLIENT_NAME,
instanceOrigin: 'http://localhost',
workspace: { id: orgId, name: expect.any(String) },
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'http://127.0.0.1:8484/callback',
grantLifetime: {
accessTokenSeconds: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
refreshTokenSeconds: AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
},
})
})
it('revalidates OAuth consent submission through the Agent Access API', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app, 'agent-submit@example.com')
const res = await app.request('/api/agent-oauth-consent', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ accept: true, oauthQuery: 'client_id=zpan-agent&response_type=token' }),
})
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({
error: {
message: 'Invalid Agent OAuth request',
},
})
})
it('submits full OAuth consent through the Agent Access API', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app, 'agent-submit-success@example.com')
const oauthParams = new URLSearchParams({
client_id: AGENT_OAUTH_CLIENT_ID,
redirect_uri: 'http://127.0.0.1:8484/callback',
response_type: 'code',
scope: `${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ} openid offline_access`,
state: 'agent-submit-success',
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
code_challenge_method: 'S256',
})
const authorize = await app.request(`/api/auth/oauth2/authorize?${oauthParams}`, {
headers: { ...headers, Origin: 'http://localhost' },
})
const consentLocation = authorize.headers.get('location')
expect(authorize.status).toBe(302)
expect(consentLocation).toMatch(/^\/settings\/agent-access\?/)
const consent = await app.request('/api/agent-oauth-consent', {
method: 'POST',
headers: { ...headers, Origin: 'http://localhost', 'Content-Type': 'application/json' },
body: JSON.stringify({
accept: true,
oauthQuery: consentLocation?.slice(consentLocation.indexOf('?') + 1),
}),
})
const consentBody = await consent.text()
expect(consent.status, consentBody).toBe(200)
expect(JSON.parse(consentBody)).toMatchObject({
url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:8484\/callback\?code=/),
})
})
it('lists and revokes the current user grant family', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app, 'agent-grants@example.com')
@@ -84,11 +171,14 @@ describe('Agent OAuth grants API integration', () => {
{
id: 'grant-1',
clientId: AGENT_OAUTH_CLIENT_ID,
clientName: 'ZPan Agent',
userId,
orgId,
workspaceName: expect.any(String),
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
createdAt: '2026-07-29T12:00:00.000Z',
updatedAt: '2026-07-29T12:00:00.000Z',
lastUsedAt: null,
status: 'active',
},
],
})
@@ -108,9 +198,18 @@ describe('Agent OAuth grants API integration', () => {
await insertTeamOrg(db, 'other-workspace', userId)
await insertGrant(db, { userId, orgId, scopes: [AuthorizationScope.OBJECTS_READ] })
const list = await app.request('/api/agent-oauth-grants', { headers })
expect(list.status).toBe(200)
await expect(list.json()).resolves.toMatchObject({ items: [{ id: 'grant-1', lastUsedAt: null }] })
const bearer = { Authorization: 'Bearer live-agent-token' }
const allowed = await app.request('/api/objects', { headers: bearer })
expect(allowed.status).toBe(200)
const [usedGrant] = await db
.select({ lastUsedAt: authSchema.oauthConsent.lastUsedAt })
.from(authSchema.oauthConsent)
.where(eq(authSchema.oauthConsent.id, 'grant-1'))
expect(usedGrant.lastUsedAt).toBeInstanceOf(Date)
const wrongWorkspace = await app.request('/api/objects?orgId=other-workspace', { headers: bearer })
expect(wrongWorkspace.status).toBe(403)
+78 -14
View File
@@ -1,22 +1,54 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import {
agentOAuthConsentContextSchema,
agentOAuthConsentResultSchema,
agentOAuthConsentSubmitSchema,
agentOAuthGrantListSchema,
} from '@shared/schemas'
import { requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { getAgentOAuthConsentContext } from '../usecases/agent-oauth-consent'
import { listAgentOAuthGrants, revokeAgentOAuthGrant } from '../usecases/agent-oauth-grants'
import { authRoute, errorResponse, jsonContent } from './openapi'
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
const agentOAuthGrantSchema = z.object({
id: z.string(),
clientId: z.string(),
userId: z.string(),
orgId: z.string(),
scopes: z.array(z.enum(Object.values(AuthorizationScope) as [AuthorizationScope, ...AuthorizationScope[]])),
createdAt: z.string(),
updatedAt: z.string(),
})
const listSchema = z.object({ items: z.array(agentOAuthGrantSchema) })
const paramsSchema = z.object({ grantId: z.string().min(1) })
const consentContextQuerySchema = z.object({ oauthQuery: z.string().min(1) })
const consentContextRoute = authRoute(
{ access: 'session' },
{
operationId: 'getAgentOAuthConsentContext',
summary: 'Get pending Agent OAuth consent context',
tags: ['Agent Access'],
method: 'get',
path: '/agent-oauth-consent',
middleware: [requireAuth] as const,
request: { query: consentContextQuerySchema },
responses: {
200: jsonContent(agentOAuthConsentContextSchema, 'Agent OAuth consent context'),
400: errorResponse('Invalid OAuth request'),
403: errorResponse('Workspace access is required'),
},
},
)
const consentSubmitRoute = authRoute(
{ access: 'session' },
{
operationId: 'submitAgentOAuthConsent',
summary: 'Submit Agent OAuth consent decision',
tags: ['Agent Access'],
method: 'post',
path: '/agent-oauth-consent',
middleware: [requireAuth] as const,
request: jsonBody(agentOAuthConsentSubmitSchema),
responses: {
200: jsonContent(agentOAuthConsentResultSchema, 'Agent OAuth consent result'),
400: errorResponse('Invalid OAuth request'),
403: errorResponse('Workspace access is required'),
},
},
)
const listRoute = authRoute(
{ access: 'session' },
@@ -28,7 +60,7 @@ const listRoute = authRoute(
path: '/agent-oauth-grants',
middleware: [requireAuth] as const,
responses: {
200: jsonContent(listSchema, 'Agent OAuth grants'),
200: jsonContent(agentOAuthGrantListSchema, 'Agent OAuth grants'),
},
},
)
@@ -51,6 +83,38 @@ const revokeRoute = authRoute(
)
export const agentOAuthGrants = new OpenAPIHono<Env>()
.openapi(consentContextRoute, async (c) => {
const { oauthQuery } = c.req.valid('query')
const context = await getAgentOAuthConsentContext(c.get('deps'), {
userId: c.get('userId')!,
orgId: c.get('orgId'),
requestUrl: c.req.url,
oauthQuery,
})
return c.json(context, 200)
})
.openapi(consentSubmitRoute, async (c) => {
const { accept, oauthQuery } = c.req.valid('json')
await getAgentOAuthConsentContext(c.get('deps'), {
userId: c.get('userId')!,
orgId: c.get('orgId'),
requestUrl: c.req.url,
oauthQuery,
})
const headers = new Headers(c.req.raw.headers)
headers.set('content-type', 'application/json')
headers.delete('content-length')
const response = await c.get('auth').handler(
new Request(new URL('/api/auth/oauth2/consent', c.req.url), {
method: 'POST',
headers,
body: JSON.stringify({ accept, oauth_query: oauthQuery }),
}),
)
const body = await response.json().catch(() => null)
if (!response.ok) return c.json(body ?? { error: response.statusText }, response.status as 400 | 403)
return c.json(agentOAuthConsentResultSchema.parse(body), 200)
})
.openapi(listRoute, async (c) => {
const result = await listAgentOAuthGrants(c.get('deps'), c.get('platform').db, { userId: c.get('userId')! })
return c.json(result, 200)
+2 -2
View File
@@ -71,12 +71,12 @@ describe('[CF] Auth API', () => {
expect(authorize.status).toBe(302)
expect(consentLocation).toMatch(/^\/settings\/agent-access\?/)
const consent = await app.request('/api/auth/oauth2/consent', {
const consent = await app.request('/api/agent-oauth-consent', {
method: 'POST',
headers: { Cookie: cookie, Origin: 'http://localhost', 'Content-Type': 'application/json' },
body: JSON.stringify({
accept: true,
oauth_query: consentLocation?.slice(consentLocation.indexOf('?') + 1),
oauthQuery: consentLocation?.slice(consentLocation.indexOf('?') + 1),
}),
})
const consentBody = await consent.text()
+87
View File
@@ -0,0 +1,87 @@
import { AuthorizationScope } from '@shared/authorization'
import { Hono } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { authorize, type RouteAuthorizationDeclaration } from './authz'
import type { AuthzContext, Env } from './platform'
function probeApp(context: AuthzContext, declaration: RouteAuthorizationDeclaration) {
const recordGrantUse = vi.fn(async () => {})
const app = new Hono<Env>()
app.use('/probe', async (c, next) => {
c.set('authzContext', context)
c.set('platform', { db: { kind: 'unit-db' } } as unknown as Env['Variables']['platform'])
c.set('deps', {
agentOAuth: { recordGrantUse },
audit: { record: vi.fn() },
org: {
getMemberRole: vi.fn(async () => 'owner'),
findPersonalOrg: vi.fn(async () => context.orgId),
},
} as unknown as Env['Variables']['deps'])
await next()
})
app.get('/probe', authorize(declaration), (c) => c.json({ ok: true }))
return { app, recordGrantUse }
}
describe('authorize Agent OAuth grant-use tracking', () => {
const context: AuthzContext = {
credential: 'agent_oauth',
userId: 'user-1',
orgId: 'org-1',
fixedOrgId: 'org-1',
grantedScopes: new Set([AuthorizationScope.OBJECTS_READ]),
actor: { type: 'agent_oauth', ref: 'grant-1' },
state: { clientId: 'zpan-agent' },
}
it('records actual Agent OAuth use for scoped protected routes', async () => {
const { app, recordGrantUse } = probeApp(context, {
access: 'protected',
scopes: [AuthorizationScope.OBJECTS_READ],
})
const res = await app.request('/probe')
expect(res.status).toBe(200)
expect(recordGrantUse).toHaveBeenCalledTimes(1)
expect(recordGrantUse).toHaveBeenCalledWith(
{ kind: 'unit-db' },
expect.objectContaining({
grantId: 'grant-1',
userId: 'user-1',
orgId: 'org-1',
now: expect.any(Date),
}),
)
})
it('does not record display-only unscoped protected access as grant use', async () => {
const { app, recordGrantUse } = probeApp(context, { access: 'protected' })
const res = await app.request('/probe')
expect(res.status).toBe(200)
expect(recordGrantUse).not.toHaveBeenCalled()
})
it('does not record non-Agent OAuth protected access as grant use', async () => {
const { app, recordGrantUse } = probeApp(
{
credential: 'session',
userId: 'user-1',
orgId: 'org-1',
fixedOrgId: null,
grantedScopes: null,
actor: { type: 'user', ref: 'user-1' },
state: { firstParty: true },
},
{ access: 'protected', scopes: [AuthorizationScope.OBJECTS_READ] },
)
const res = await app.request('/probe')
expect(res.status).toBe(200)
expect(recordGrantUse).not.toHaveBeenCalled()
})
})
+18
View File
@@ -108,6 +108,7 @@ export function authorize(declaration: RouteAuthorizationDeclaration) {
})
if (decision.allowed) {
if (decision.effectiveOrgId) c.set('orgId', decision.effectiveOrgId)
await recordAgentOAuthGrantUse(c, declaration, decision.effectiveOrgId)
await next()
return
}
@@ -135,6 +136,23 @@ export function requirePermission(
})
}
async function recordAgentOAuthGrantUse(
c: Context<Env>,
declaration: RouteAuthorizationDeclaration,
effectiveOrgId: string | null,
) {
const context = c.get('authzContext')
if (declaration.access !== 'protected' || !declaration.scopes?.length) return
if (context.credential !== 'agent_oauth') return
if (!context.userId || !effectiveOrgId || context.actor?.type !== 'agent_oauth') return
await c.get('deps').agentOAuth.recordGrantUse(c.get('platform').db, {
grantId: context.actor.ref,
userId: context.userId,
orgId: effectiveOrgId,
now: new Date(),
})
}
function deny(
context: AuthzContext,
status: 401 | 403,
+1
View File
@@ -189,6 +189,7 @@ const AUTH_SCHEMA_SQL = `
reference_id TEXT,
scopes TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
last_used_at INTEGER,
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS oauthConsent_client_id_idx ON oauthConsent(client_id);
+126
View File
@@ -0,0 +1,126 @@
import { AGENT_OAUTH_CLIENT_ID, AGENT_OAUTH_CLIENT_NAME } from '@shared/agent-oauth'
import { AuthorizationScope } from '@shared/authorization'
import { describe, expect, it, vi } from 'vitest'
import { getAgentOAuthConsentContext } from './agent-oauth-consent'
import type { OrgRepo } from './ports'
function org(overrides: Partial<OrgRepo> = {}): OrgRepo {
return {
findPersonalOrg: vi.fn(),
getMemberRole: vi.fn(),
getOrgNames: vi.fn(async () => new Map([['org-1', 'Personal']])),
canReadOrg: vi.fn(async () => true),
canWriteToOrg: vi.fn(),
canManageAgentAccess: vi.fn(),
isPersonalOrg: vi.fn(),
...overrides,
}
}
function oauthQuery(overrides: Record<string, string> = {}) {
return new URLSearchParams({
client_id: AGENT_OAUTH_CLIENT_ID,
redirect_uri: 'http://127.0.0.1:8484/callback',
response_type: 'code',
scope: `openid offline_access ${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ}`,
...overrides,
}).toString()
}
describe('Agent OAuth consent usecase', () => {
it('builds server-owned consent context for the active workspace', async () => {
await expect(
getAgentOAuthConsentContext(
{ org: org() },
{
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery(),
},
),
).resolves.toEqual({
clientId: AGENT_OAUTH_CLIENT_ID,
clientName: AGENT_OAUTH_CLIENT_NAME,
instanceOrigin: 'https://zpan.example.test',
workspace: { id: 'org-1', name: 'Personal' },
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'http://127.0.0.1:8484/callback',
grantLifetime: {
accessTokenSeconds: 900,
refreshTokenSeconds: 2_592_000,
},
})
})
it('keeps the active workspace id when the workspace name is unavailable', async () => {
await expect(
getAgentOAuthConsentContext(
{ org: org({ getOrgNames: vi.fn(async () => new Map()) }) },
{
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery(),
},
),
).resolves.toMatchObject({
workspace: { id: 'org-1', name: null },
})
})
it('rejects requests that are not the managed authorization-code client flow', async () => {
await expect(
getAgentOAuthConsentContext(
{ org: org() },
{
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery({ response_type: 'token' }),
},
),
).rejects.toMatchObject({ httpStatus: 400 })
})
it('rejects untrusted redirect URIs and non-grantable scopes', async () => {
await expect(
getAgentOAuthConsentContext(
{ org: org() },
{
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery({ redirect_uri: 'https://evil.example/callback' }),
},
),
).rejects.toMatchObject({ httpStatus: 400 })
await expect(
getAgentOAuthConsentContext(
{ org: org() },
{
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery({ scope: 'objects:purge' }),
},
),
).rejects.toMatchObject({ httpStatus: 400 })
})
it('rejects missing or inaccessible workspaces', async () => {
await expect(
getAgentOAuthConsentContext(
{ org: org({ canReadOrg: vi.fn(async () => false) }) },
{
userId: 'user-1',
orgId: 'org-1',
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
oauthQuery: oauthQuery(),
},
),
).rejects.toMatchObject({ httpStatus: 403 })
})
})
+63
View File
@@ -0,0 +1,63 @@
import {
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
AGENT_OAUTH_CLIENT_ID,
AGENT_OAUTH_CLIENT_NAME,
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
AGENT_OAUTH_STANDARD_SCOPES,
RESTISH_OAUTH_REDIRECT_URIS,
} from '@shared/agent-oauth'
import { isAuthorizationScope } from '@shared/authorization'
import { type AgentGrantableScope, type AgentOAuthConsentContext, agentGrantableScopeSchema } from '@shared/schemas'
import type { Deps } from './deps'
import { badRequest, forbidden } from './ports'
export async function getAgentOAuthConsentContext(
deps: Pick<Deps, 'org'>,
input: { userId: string; orgId: string | null; requestUrl: string; oauthQuery: string },
): Promise<AgentOAuthConsentContext> {
const params = new URLSearchParams(input.oauthQuery)
const clientId = params.get('client_id')
const redirectUri = params.get('redirect_uri')
const responseType = params.get('response_type')
const scopeValue = params.get('scope') ?? ''
if (clientId !== AGENT_OAUTH_CLIENT_ID || responseType !== 'code' || !redirectUri) {
throw badRequest('Invalid Agent OAuth request')
}
if (!RESTISH_OAUTH_REDIRECT_URIS.includes(redirectUri as (typeof RESTISH_OAUTH_REDIRECT_URIS)[number])) {
throw badRequest('Invalid Agent OAuth redirect URI')
}
const requestedScopes = scopeValue.split(/\s+/).filter(Boolean)
const standardScopes = requestedScopes.filter((scope) =>
(AGENT_OAUTH_STANDARD_SCOPES as readonly string[]).includes(scope),
)
const scopes = requestedScopes.filter(isAgentGrantableScope)
if (scopes.length === 0 || requestedScopes.length !== standardScopes.length + scopes.length) {
throw badRequest('Invalid Agent OAuth scope')
}
const orgId = input.orgId
if (!orgId || !(await deps.org.canReadOrg(input.userId, orgId))) {
throw forbidden('Workspace access is required for Agent OAuth')
}
const names = await deps.org.getOrgNames([orgId])
return {
clientId,
clientName: AGENT_OAUTH_CLIENT_NAME,
instanceOrigin: new URL(input.requestUrl).origin,
workspace: { id: orgId, name: names.get(orgId) ?? null },
scopes,
standardScopes,
redirectUri,
grantLifetime: {
accessTokenSeconds: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
refreshTokenSeconds: AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
},
}
}
function isAgentGrantableScope(scope: string): scope is AgentGrantableScope {
return isAuthorizationScope(scope) && agentGrantableScopeSchema.safeParse(scope).success
}
+21 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { listAgentOAuthGrants, revokeAgentOAuthGrant } from './agent-oauth-grants'
import type { AgentOAuthGateway } from './ports'
import type { AgentOAuthGateway, OrgRepo } from './ports'
const db = {} as never
@@ -10,11 +10,25 @@ function gateway(overrides: Partial<AgentOAuthGateway> = {}): AgentOAuthGateway
assertLiveGrant: vi.fn(),
verifyAccessToken: vi.fn(),
listGrants: vi.fn(async () => []),
recordGrantUse: vi.fn(),
revokeGrant: vi.fn(async () => true),
...overrides,
}
}
function org(overrides: Partial<OrgRepo> = {}): OrgRepo {
return {
findPersonalOrg: vi.fn(),
getMemberRole: vi.fn(),
getOrgNames: vi.fn(async () => new Map([['org-1', 'Personal']])),
canReadOrg: vi.fn(),
canWriteToOrg: vi.fn(),
canManageAgentAccess: vi.fn(),
isPersonalOrg: vi.fn(),
...overrides,
}
}
describe('Agent OAuth grant usecases', () => {
it('lists grants through the gateway', async () => {
const agentOAuth = gateway({
@@ -26,21 +40,24 @@ describe('Agent OAuth grant usecases', () => {
orgId: 'org-1',
scopes: [],
createdAt: '2026-07-29T12:00:00.000Z',
updatedAt: '2026-07-29T12:00:00.000Z',
lastUsedAt: null,
},
]),
})
await expect(listAgentOAuthGrants({ agentOAuth }, db, { userId: 'user-1' })).resolves.toEqual({
await expect(listAgentOAuthGrants({ agentOAuth, org: org() }, db, { userId: 'user-1' })).resolves.toEqual({
items: [
{
id: 'grant-1',
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
userId: 'user-1',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: [],
createdAt: '2026-07-29T12:00:00.000Z',
updatedAt: '2026-07-29T12:00:00.000Z',
lastUsedAt: null,
status: 'active',
},
],
})
+23 -4
View File
@@ -1,14 +1,33 @@
import {
type AgentGrantableScope,
type AgentOAuthGrant as AgentOAuthGrantDTO,
agentGrantableScopeSchema,
agentOAuthGrantDTO,
} from '@shared/schemas'
import type { Database } from '../platform/interface'
import type { Deps } from './deps'
import type { AgentOAuthGrant } from './ports'
import { notFound } from './ports'
export async function listAgentOAuthGrants(
deps: Pick<Deps, 'agentOAuth'>,
deps: Pick<Deps, 'agentOAuth' | 'org'>,
db: Database,
input: { userId: string },
): Promise<{ items: AgentOAuthGrant[] }> {
return { items: await deps.agentOAuth.listGrants(db, input.userId) }
): Promise<{ items: AgentOAuthGrantDTO[] }> {
const items = await deps.agentOAuth.listGrants(db, input.userId)
const orgNames = await deps.org.getOrgNames(items.map((item) => item.orgId))
return {
items: items.map((item) =>
agentOAuthGrantDTO({
...item,
scopes: item.scopes.filter(isAgentGrantableScope),
workspaceName: orgNames.get(item.orgId) ?? null,
}),
),
}
}
function isAgentGrantableScope(scope: string): scope is AgentGrantableScope {
return agentGrantableScopeSchema.safeParse(scope).success
}
export async function revokeAgentOAuthGrant(
+2 -1
View File
@@ -16,7 +16,7 @@ export interface AgentOAuthGrant {
orgId: string
scopes: AuthorizationScope[]
createdAt: string
updatedAt: string
lastUsedAt: string | null
}
export interface AgentOAuthGateway {
@@ -27,5 +27,6 @@ export interface AgentOAuthGateway {
): Promise<void>
verifyAccessToken(db: Database, token: string): Promise<VerifiedAgentOAuthToken | null>
listGrants(db: Database, userId: string): Promise<AgentOAuthGrant[]>
recordGrantUse(db: Database, input: { grantId: string; userId: string; orgId: string; now: Date }): Promise<void>
revokeGrant(db: Database, input: { userId: string; grantId: string; now: Date }): Promise<boolean>
}
+1
View File
@@ -1,6 +1,7 @@
export interface OrgRepo {
findPersonalOrg(userId: string): Promise<string | null>
getMemberRole(orgId: string, userId: string): Promise<string | null>
getOrgNames(orgIds: string[]): Promise<Map<string, string>>
canReadOrg(userId: string, orgId: string): Promise<boolean>
canWriteToOrg(userId: string, orgId: string): Promise<boolean>
canManageAgentAccess(userId: string, orgId: string): Promise<boolean>
+1
View File
@@ -87,6 +87,7 @@ function makeDeps(
org: {
findPersonalOrg: async () => null,
getMemberRole: async () => null,
getOrgNames: async () => new Map(),
canReadOrg: async () => false,
canWriteToOrg: async () => false,
canManageAgentAccess: async () => false,
+69
View File
@@ -0,0 +1,69 @@
import { z } from 'zod'
import {
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
AGENT_OAUTH_CLIENT_NAME,
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
} from '../agent-oauth'
import { agentGrantableScopeSchema } from './agent-api-keys'
export const agentOAuthGrantStatusSchema = z.enum(['active'])
export type AgentOAuthGrantStatus = z.infer<typeof agentOAuthGrantStatusSchema>
export const agentOAuthGrantSchema = z.object({
id: z.string(),
clientId: z.string(),
clientName: z.string().default(AGENT_OAUTH_CLIENT_NAME),
userId: z.string(),
orgId: z.string(),
workspaceName: z.string().nullable(),
scopes: z.array(agentGrantableScopeSchema),
createdAt: z.string(),
lastUsedAt: z.string().nullable(),
status: agentOAuthGrantStatusSchema,
})
export type AgentOAuthGrant = z.infer<typeof agentOAuthGrantSchema>
export const agentOAuthGrantListSchema = z.object({ items: z.array(agentOAuthGrantSchema) })
export type AgentOAuthGrantList = z.infer<typeof agentOAuthGrantListSchema>
export const agentOAuthConsentContextSchema = z.object({
clientId: z.string(),
clientName: z.string(),
instanceOrigin: z.string(),
workspace: z.object({
id: z.string(),
name: z.string().nullable(),
}),
scopes: z.array(agentGrantableScopeSchema),
standardScopes: z.array(z.string()),
redirectUri: z.string(),
grantLifetime: z.object({
accessTokenSeconds: z.number().int().default(AGENT_OAUTH_ACCESS_TOKEN_SECONDS),
refreshTokenSeconds: z.number().int().default(AGENT_OAUTH_REFRESH_TOKEN_SECONDS),
}),
})
export type AgentOAuthConsentContext = z.infer<typeof agentOAuthConsentContextSchema>
export const agentOAuthConsentContextRequestSchema = z.object({
oauthQuery: z.string().min(1),
})
export type AgentOAuthConsentContextRequest = z.infer<typeof agentOAuthConsentContextRequestSchema>
export const agentOAuthConsentSubmitSchema = z.object({
accept: z.boolean(),
oauthQuery: z.string().min(1),
})
export type AgentOAuthConsentSubmit = z.infer<typeof agentOAuthConsentSubmitSchema>
export const agentOAuthConsentResultSchema = z.object({
url: z.string(),
})
export type AgentOAuthConsentResult = z.infer<typeof agentOAuthConsentResultSchema>
export function agentOAuthGrantDTO(input: Omit<AgentOAuthGrant, 'clientName' | 'status'>): AgentOAuthGrant {
return {
...input,
clientName: AGENT_OAUTH_CLIENT_NAME,
status: 'active',
}
}
+19
View File
@@ -31,6 +31,25 @@ export {
agentGrantableScopeSchema,
agentScopeLabels,
} from './agent-api-keys'
export type {
AgentOAuthConsentContext,
AgentOAuthConsentContextRequest,
AgentOAuthConsentResult,
AgentOAuthConsentSubmit,
AgentOAuthGrant,
AgentOAuthGrantList,
AgentOAuthGrantStatus,
} from './agent-oauth-grants'
export {
agentOAuthConsentContextRequestSchema,
agentOAuthConsentContextSchema,
agentOAuthConsentResultSchema,
agentOAuthConsentSubmitSchema,
agentOAuthGrantDTO,
agentOAuthGrantListSchema,
agentOAuthGrantSchema,
agentOAuthGrantStatusSchema,
} from './agent-oauth-grants'
export type {
AnnouncementInput,
+23
View File
@@ -1250,6 +1250,29 @@
"settings.agentAccess.revokeSuccess": "Agent API key revoked",
"settings.agentAccess.revealedTitle": "Save Your Agent API Key",
"settings.agentAccess.revealedWarning": "This is the only time this key will be shown. Store it securely.",
"settings.agentAccess.oauthConsentEyebrow": "Delegated OAuth access",
"settings.agentAccess.oauthConsentTitle": "Authorize ZPan Agent",
"settings.agentAccess.oauthConsentDescription": "Review the exact workspace and scopes Restish will receive before continuing.",
"settings.agentAccess.oauthClient": "Client",
"settings.agentAccess.oauthOrigin": "ZPan instance",
"settings.agentAccess.oauthReturn": "Return URL",
"settings.agentAccess.oauthLifetime": "Grant lifetime",
"settings.agentAccess.oauthLifetimeValue": "{{days}} days",
"settings.agentAccess.oauthScopesTitle": "Requested scopes",
"settings.agentAccess.oauthEffects": "This grant can read or change files and public shares only where the listed scopes allow it. Delete and share scopes can remove content or expose public links.",
"settings.agentAccess.oauthApprove": "Approve Access",
"settings.agentAccess.oauthDeny": "Deny",
"settings.agentAccess.oauthExpiredTitle": "OAuth request expired",
"settings.agentAccess.oauthExpiredDescription": "Start the Restish connection again to create a fresh authorization request.",
"settings.agentAccess.oauthConsentFailed": "Could not finish OAuth consent.",
"settings.agentAccess.oauthWorkspaceFailed": "Could not switch workspace.",
"settings.agentAccess.oauthGrantsSection": "Delegated OAuth Grants",
"settings.agentAccess.oauthGrantsDescription": "Manage Restish OAuth grants connected to your workspaces.",
"settings.agentAccess.oauthNoGrants": "No delegated OAuth grants yet",
"settings.agentAccess.oauthGrantsError": "Could not load delegated OAuth grants.",
"settings.agentAccess.oauthGrantRevokeTitle": "Revoke OAuth Grant",
"settings.agentAccess.oauthGrantRevokeConfirm": "Revoke {{client}} access to {{workspace}}? Active Restish sessions for this workspace will stop immediately.",
"settings.agentAccess.oauthGrantRevokeSuccess": "OAuth grant revoked",
"settings.appearance.theme.description": "Choose how ZPan looks. Follows your system setting by default.",
"settings.appearance.language.description": "The display language for the app.",
"settings.appearance.autoSaved": "Changes apply immediately.",
+23
View File
@@ -1250,6 +1250,29 @@
"settings.agentAccess.revokeSuccess": "Agent API Key 已撤销",
"settings.agentAccess.revealedTitle": "保存你的 Agent API Key",
"settings.agentAccess.revealedWarning": "该 Key 只会显示一次,请妥善保存。",
"settings.agentAccess.oauthConsentEyebrow": "委托 OAuth 访问",
"settings.agentAccess.oauthConsentTitle": "授权 ZPan Agent",
"settings.agentAccess.oauthConsentDescription": "继续前请确认 Restish 将获得的具体工作空间和权限。",
"settings.agentAccess.oauthClient": "客户端",
"settings.agentAccess.oauthOrigin": "ZPan 实例",
"settings.agentAccess.oauthReturn": "返回 URL",
"settings.agentAccess.oauthLifetime": "授权有效期",
"settings.agentAccess.oauthLifetimeValue": "{{days}} 天",
"settings.agentAccess.oauthScopesTitle": "请求权限",
"settings.agentAccess.oauthEffects": "该授权只能按列出的权限读取或更改文件与公开分享。删除和分享权限可能移除内容或公开链接。",
"settings.agentAccess.oauthApprove": "批准访问",
"settings.agentAccess.oauthDeny": "拒绝",
"settings.agentAccess.oauthExpiredTitle": "OAuth 请求已过期",
"settings.agentAccess.oauthExpiredDescription": "请从 Restish 重新发起连接,生成新的授权请求。",
"settings.agentAccess.oauthConsentFailed": "无法完成 OAuth 授权。",
"settings.agentAccess.oauthWorkspaceFailed": "无法切换工作空间。",
"settings.agentAccess.oauthGrantsSection": "委托 OAuth 授权",
"settings.agentAccess.oauthGrantsDescription": "管理连接到你工作空间的 Restish OAuth 授权。",
"settings.agentAccess.oauthNoGrants": "暂无委托 OAuth 授权",
"settings.agentAccess.oauthGrantsError": "无法加载委托 OAuth 授权。",
"settings.agentAccess.oauthGrantRevokeTitle": "撤销 OAuth 授权",
"settings.agentAccess.oauthGrantRevokeConfirm": "撤销 {{client}} 对 {{workspace}} 的访问?该工作空间的 Restish 会话将立即停止。",
"settings.agentAccess.oauthGrantRevokeSuccess": "OAuth 授权已撤销",
"settings.appearance.theme.description": "选择 ZPan 的外观,默认跟随系统。",
"settings.appearance.language.description": "界面显示语言。",
"settings.appearance.autoSaved": "修改即时生效。",
+97
View File
@@ -48,6 +48,7 @@ import {
getAdminDashboardStorageStats,
getAdminDashboardTrafficStats,
getAdminOverview,
getAgentOAuthConsentContext,
getAnnouncement,
getBackgroundJob,
getChangelog,
@@ -81,6 +82,7 @@ import {
listAdminAnnouncements,
listAdminAuditLogs,
listAgentApiKeys,
listAgentOAuthGrants,
listAnnouncements,
listApiKeys,
listAuthProviders,
@@ -123,6 +125,7 @@ import {
restoreObject,
retryBackgroundJob,
revokeAgentApiKey,
revokeAgentOAuthGrant,
revokeIhostApiKey,
revokeOrgEntitlement,
revokeRemoteDownloadApiKey,
@@ -139,6 +142,7 @@ import {
sendDownloaderHeartbeat,
serverEventsUrl,
setSharePrivacy,
submitAgentOAuthConsent,
testEmail,
testImageDomainProvider,
transferObject,
@@ -3208,6 +3212,99 @@ describe('api', () => {
})
})
describe('Agent OAuth consent and grants', () => {
const sampleGrantList = {
items: [
{
id: 'grant-1',
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
userId: 'user-1',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read'],
createdAt: '2026-07-29T00:00:00.000Z',
lastUsedAt: null,
status: 'active',
},
],
}
it('loads server-owned OAuth consent context with the raw OAuth query', async () => {
const payload = {
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
instanceOrigin: 'https://zpan.example.test',
workspace: { id: 'org-1', name: 'Personal' },
scopes: ['objects:read'],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'http://127.0.0.1:8484/callback',
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await getAgentOAuthConsentContext('client_id=zpan-agent&scope=objects%3Aread')
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/agent-oauth-consent')
expect(url).toContain('oauthQuery=client_id%3Dzpan-agent%26scope%3Dobjects%253Aread')
expect(init.method).toBe('GET')
})
it('submits full OAuth consent through the Hono RPC wrapper without sending scope overrides', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ url: 'http://127.0.0.1:8484/callback?code=abc' }))
const result = await submitAgentOAuthConsent({ accept: true, oauthQuery: 'client_id=zpan-agent' })
expect(result).toEqual({ url: 'http://127.0.0.1:8484/callback?code=abc' })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/agent-oauth-consent')
expect(init.method).toBe('POST')
expect(init.credentials).toBe('include')
expect(JSON.parse(init.body as string)).toEqual({
accept: true,
oauthQuery: 'client_id=zpan-agent',
})
})
it('throws an API error when OAuth consent submission is rejected with a non-JSON response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => {
throw new Error('not json')
},
} as unknown as Response)
await expect(submitAgentOAuthConsent({ accept: false, oauthQuery: 'client_id=zpan-agent' })).rejects.toThrow(
ApiError,
)
})
it('lists delegated Agent OAuth grants', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(sampleGrantList))
const result = await listAgentOAuthGrants()
expect(result).toEqual(sampleGrantList)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/agent-oauth-grants')
expect(init.method).toBe('GET')
})
it('revokes delegated Agent OAuth grants with DELETE', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await revokeAgentOAuthGrant('grant-1')
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/agent-oauth-grants/grant-1')
expect(init.method).toBe('DELETE')
})
})
describe('listApiKeys', () => {
const sampleKey = {
id: 'key-1',
+33 -1
View File
@@ -6,6 +6,11 @@ import type {
AgentApiKeyCreateInput,
AgentApiKeyList,
AgentApiKeyRotateInput,
AgentOAuthConsentContext,
AgentOAuthConsentResult,
AgentOAuthConsentSubmit,
AgentOAuthGrant,
AgentOAuthGrantList,
AllowedImageMime,
AnnouncementInput,
CloudCreditBalanceResponse,
@@ -103,6 +108,7 @@ import {
adminSiteInvitations,
adminTeams,
agentApiKeysApi,
agentOAuthGrantsApi,
announcementsApi,
authedSharesApi,
authProviders,
@@ -1092,7 +1098,17 @@ export function deleteIhostConfig() {
// Agent Access API keys
export type { AgentApiKey, AgentApiKeyCreated, AgentApiKeyCreateInput, AgentApiKeyList, AgentApiKeyRotateInput }
export type {
AgentApiKey,
AgentApiKeyCreated,
AgentApiKeyCreateInput,
AgentApiKeyList,
AgentApiKeyRotateInput,
AgentOAuthConsentContext,
AgentOAuthConsentResult,
AgentOAuthGrant,
AgentOAuthGrantList,
}
export function listAgentApiKeys(orgId: string, page = 1, pageSize = 50) {
return unwrap<AgentApiKeyList>(
@@ -1124,6 +1140,22 @@ export function revokeAgentApiKey(orgId: string, keyId: string) {
})
}
export function getAgentOAuthConsentContext(oauthQuery: string) {
return unwrap<AgentOAuthConsentContext>(agentOAuthGrantsApi['agent-oauth-consent'].$get({ query: { oauthQuery } }))
}
export function submitAgentOAuthConsent(input: AgentOAuthConsentSubmit) {
return unwrap<AgentOAuthConsentResult>(agentOAuthGrantsApi['agent-oauth-consent'].$post({ json: input }))
}
export function listAgentOAuthGrants() {
return unwrap<AgentOAuthGrantList>(agentOAuthGrantsApi['agent-oauth-grants'].$get())
}
export function revokeAgentOAuthGrant(grantId: string) {
return discard(agentOAuthGrantsApi['agent-oauth-grants'][':grantId'].$delete({ param: { grantId } }))
}
// Image Host API Keys (via better-auth apiKey plugin)
export interface IhostApiKey {
+2
View File
@@ -7,6 +7,7 @@ import type {
AdminStatsRoute,
AdminTeamsRoute,
AgentApiKeysRoute,
AgentOAuthGrantsRoute,
AnnouncementsRoute,
AuthedSharesRoute,
AuthProvidersRoute,
@@ -51,6 +52,7 @@ export const downloadTasksApi = hc<DownloadTasksRoute>('/api/downloads/tasks', o
export const downloaderSelfApi = hc<DownloaderSelfRoute>('/api/downloads/downloaders', opts)
export const trash = hc<TrashRoute>('/api/trash', opts)
export const agentApiKeysApi = hc<AgentApiKeysRoute>('/api/workspaces', opts)
export const agentOAuthGrantsApi = hc<AgentOAuthGrantsRoute>('/api', opts)
export const storages = hc<StoragesRoute>('/api/site/storages', opts)
export const storageUsageApi = hc<StorageUsageRoute>('/api/storage', opts)
export const adminDownloadersApi = hc<DownloadersRoute>('/api/downloads/downloaders', opts)
@@ -1,10 +1,20 @@
import type { AgentApiKey } from '@shared/schemas'
import type { AgentApiKey, AgentOAuthGrant } from '@shared/schemas'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { toast } from 'sonner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '@/lib/api'
import { useListOrganizations } from '@/lib/auth-client'
import {
createAgentApiKey,
getAgentOAuthConsentContext,
listAgentApiKeys,
listAgentOAuthGrants,
revokeAgentApiKey,
revokeAgentOAuthGrant,
rotateAgentApiKey,
submitAgentOAuthConsent,
} from '@/lib/api'
import { setActive, useListOrganizations } from '@/lib/auth-client'
import { redirectExternal } from '@/lib/browser-navigation'
import { AgentAccessSettingsPage } from './agent-access'
import { SettingsLayout } from './route'
@@ -14,6 +24,7 @@ const state = vi.hoisted(() => ({
{ id: 'org-2', name: 'Team Alpha' },
],
keys: [] as AgentApiKey[],
grants: [] as AgentOAuthGrant[],
webdavEnabled: true,
}))
@@ -28,6 +39,20 @@ const translations: Record<string, string> = {
'settings.agentAccess.scope.quotaRead': 'Quota: read workspace quota',
'settings.agentAccess.scope.storageUsageRead': 'Storage usage: read workspace usage',
'settings.agentAccess.managementRequired': 'Owner or admin access is required',
'settings.agentAccess.oauthConsentTitle': 'Authorize ZPan Agent',
'settings.agentAccess.oauthClient': 'Client',
'settings.agentAccess.oauthOrigin': 'ZPan instance',
'settings.agentAccess.oauthReturn': 'Return URL',
'settings.agentAccess.oauthLifetime': 'Grant lifetime',
'settings.agentAccess.oauthLifetimeValue': '30 days',
'settings.agentAccess.oauthScopesTitle': 'Requested scopes',
'settings.agentAccess.oauthApprove': 'Approve Access',
'settings.agentAccess.oauthDeny': 'Deny',
'settings.agentAccess.oauthExpiredTitle': 'OAuth request expired',
'settings.agentAccess.oauthGrantsSection': 'Delegated OAuth Grants',
'settings.agentAccess.oauthNoGrants': 'No delegated OAuth grants yet',
'settings.agentAccess.oauthGrantRevokeTitle': 'Revoke OAuth Grant',
'settings.agentAccess.oauthGrantRevokeSuccess': 'OAuth grant revoked',
}
vi.mock('react-i18next', () => ({
@@ -59,13 +84,22 @@ vi.mock('@/hooks/use-site-config', () => ({
vi.mock('@/lib/auth-client', () => ({
useListOrganizations: vi.fn(),
setActive: vi.fn(),
}))
vi.mock('@/lib/browser-navigation', () => ({
redirectExternal: vi.fn(),
}))
vi.mock('@/lib/api', () => ({
createAgentApiKey: vi.fn(),
getAgentOAuthConsentContext: vi.fn(),
listAgentApiKeys: vi.fn(),
listAgentOAuthGrants: vi.fn(),
revokeAgentApiKey: vi.fn(),
revokeAgentOAuthGrant: vi.fn(),
rotateAgentApiKey: vi.fn(),
submitAgentOAuthConsent: vi.fn(),
}))
const queryClients: QueryClient[] = []
@@ -94,6 +128,7 @@ beforeEach(() => {
disconnect() {}
},
)
Element.prototype.scrollIntoView = vi.fn()
vi.mocked(useListOrganizations).mockReturnValue({ data: state.orgs } as never)
vi.mocked(listAgentApiKeys).mockImplementation(async (orgId: string) => ({
items: state.keys.filter((item) => item.orgId === orgId),
@@ -101,6 +136,9 @@ beforeEach(() => {
page: 1,
pageSize: 50,
}))
vi.mocked(listAgentOAuthGrants).mockImplementation(async () => ({ items: state.grants }))
vi.mocked(setActive).mockResolvedValue({ data: null, error: null } as never)
window.history.replaceState(null, '', '/settings/agent-access')
})
afterEach(() => {
@@ -109,6 +147,7 @@ afterEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
state.keys = []
state.grants = []
state.webdavEnabled = true
})
@@ -118,6 +157,7 @@ describe('Agent Access settings page', () => {
await waitFor(() => expect(listAgentApiKeys).toHaveBeenCalledWith('org-1'))
expect(await screen.findByText('settings.agentAccess.noKeys')).toBeTruthy()
expect(await screen.findByText('No delegated OAuth grants yet')).toBeTruthy()
expect(screen.queryByLabelText('settings.agentAccess.nameLabel')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.create' }))
@@ -296,6 +336,124 @@ describe('Agent Access settings page', () => {
expect(await screen.findByText('Owner or admin access is required')).toBeTruthy()
expect(screen.getByRole('button', { name: 'settings.agentAccess.create' }).hasAttribute('disabled')).toBe(true)
})
it('lists delegated OAuth grants and revokes them server-side', async () => {
state.grants = [
{
id: 'grant-1',
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
userId: 'user-1',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read', 'shares:create'],
createdAt: '2026-07-29T12:00:00.000Z',
lastUsedAt: '2026-07-29T12:10:00.000Z',
status: 'active',
},
]
vi.mocked(revokeAgentOAuthGrant).mockResolvedValue(undefined)
renderWithQuery(<AgentAccessSettingsPage />)
expect(await screen.findByText('Delegated OAuth Grants')).toBeTruthy()
expect(await screen.findByText('ZPan Agent')).toBeTruthy()
expect(screen.getByText('Files: read objects')).toBeTruthy()
expect(screen.getByText('Shares: create shares')).toBeTruthy()
const revokeButtons = screen.getAllByRole('button', { name: 'settings.agentAccess.revoke' })
fireEvent.click(revokeButtons[revokeButtons.length - 1]!)
const dialog = await screen.findByRole('dialog', { name: 'Revoke OAuth Grant' })
fireEvent.click(within(dialog).getByRole('button', { name: 'settings.agentAccess.revoke' }))
await waitFor(() => expect(revokeAgentOAuthGrant).toHaveBeenCalledWith('grant-1'))
expect(toast.success).toHaveBeenCalledWith('OAuth grant revoked')
})
it('renders OAuth consent from server context and submits full approval', async () => {
window.history.replaceState(
null,
'',
'/settings/agent-access?client_id=zpan-agent&redirect_uri=http%3A%2F%2F127.0.0.1%3A8484%2Fcallback&response_type=code&scope=openid%20offline_access%20objects%3Aread%20quota%3Aread',
)
vi.mocked(getAgentOAuthConsentContext).mockResolvedValue({
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
instanceOrigin: 'https://zpan.example.test',
workspace: { id: 'org-1', name: 'Personal' },
scopes: ['objects:read', 'quota:read'],
standardScopes: ['openid', 'offline_access'],
redirectUri: 'http://127.0.0.1:8484/callback',
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
})
vi.mocked(submitAgentOAuthConsent).mockResolvedValue({ url: 'http://127.0.0.1:8484/callback?code=abc' })
renderWithQuery(<AgentAccessSettingsPage />)
expect(await screen.findByRole('heading', { name: 'Authorize ZPan Agent' })).toBeTruthy()
expect(screen.getByText('https://zpan.example.test')).toBeTruthy()
expect(screen.getByText('http://127.0.0.1:8484/callback')).toBeTruthy()
expect(screen.getByText('Files: read objects')).toBeTruthy()
expect(screen.getByText('Quota: read workspace quota')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Approve Access' }))
await waitFor(() =>
expect(submitAgentOAuthConsent).toHaveBeenCalledWith({
accept: true,
oauthQuery: window.location.search.slice(1),
}),
)
expect(redirectExternal).toHaveBeenCalledWith('http://127.0.0.1:8484/callback?code=abc')
})
it('switches active workspace before OAuth consent and supports denial', async () => {
window.history.replaceState(
null,
'',
'/settings/agent-access?client_id=zpan-agent&redirect_uri=http%3A%2F%2F127.0.0.1%3A8484%2Fcallback&response_type=code&scope=objects%3Aread',
)
vi.mocked(getAgentOAuthConsentContext).mockResolvedValue({
clientId: 'zpan-agent',
clientName: 'ZPan Agent',
instanceOrigin: 'https://zpan.example.test',
workspace: { id: 'org-1', name: 'Personal' },
scopes: ['objects:read'],
standardScopes: [],
redirectUri: 'http://127.0.0.1:8484/callback',
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
})
vi.mocked(submitAgentOAuthConsent).mockResolvedValue({ url: 'http://127.0.0.1:8484/callback?error=access_denied' })
renderWithQuery(<AgentAccessSettingsPage />)
await screen.findByRole('heading', { name: 'Authorize ZPan Agent' })
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(await screen.findByRole('option', { name: 'Team Alpha' }))
await waitFor(() => expect(setActive).toHaveBeenCalledWith({ organizationId: 'org-2' }))
fireEvent.click(screen.getByRole('button', { name: 'Deny' }))
await waitFor(() =>
expect(submitAgentOAuthConsent).toHaveBeenCalledWith({
accept: false,
oauthQuery: window.location.search.slice(1),
}),
)
expect(redirectExternal).toHaveBeenCalledWith('http://127.0.0.1:8484/callback?error=access_denied')
})
it('shows an expired OAuth request state when the consent context fails', async () => {
window.history.replaceState(
null,
'',
'/settings/agent-access?client_id=zpan-agent&redirect_uri=http%3A%2F%2F127.0.0.1%3A8484%2Fcallback',
)
vi.mocked(getAgentOAuthConsentContext).mockRejectedValue(new Error('expired'))
renderWithQuery(<AgentAccessSettingsPage />)
expect(await screen.findByRole('heading', { name: 'OAuth request expired' })).toBeTruthy()
})
})
describe('Settings layout tabs', () => {
@@ -1,7 +1,7 @@
import { type AgentGrantableScope, agentApiKeyShortcutOptions, agentScopeLabels } from '@shared/schemas'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Copy, KeyRound, Plus, RotateCw, Trash2 } from 'lucide-react'
import { Check, Copy, KeyRound, Plug, Plus, RotateCw, ShieldAlert, Trash2, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -21,8 +21,20 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { type AgentApiKey, createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '@/lib/api'
import { useListOrganizations } from '@/lib/auth-client'
import {
type AgentApiKey,
type AgentOAuthGrant,
createAgentApiKey,
getAgentOAuthConsentContext,
listAgentApiKeys,
listAgentOAuthGrants,
revokeAgentApiKey,
revokeAgentOAuthGrant,
rotateAgentApiKey,
submitAgentOAuthConsent,
} from '@/lib/api'
import { setActive, useListOrganizations } from '@/lib/auth-client'
import { redirectExternal } from '@/lib/browser-navigation'
export const Route = createFileRoute('/_authenticated/settings/agent-access')({
component: AgentAccessSettingsPage,
@@ -54,6 +66,13 @@ function formatDate(value: string | null) {
return value ? new Date(value).toLocaleString() : null
}
function oauthQueryFromLocation(): string {
if (typeof window === 'undefined') return ''
const query = window.location.search.slice(1)
const params = new URLSearchParams(query)
return params.has('client_id') && params.has('redirect_uri') ? query : ''
}
function CopyButton({ value }: { value: string }) {
const { t } = useTranslation()
return (
@@ -267,11 +286,281 @@ function RevokeAgentKeyDialog({ apiKey, onClose }: { apiKey: AgentApiKey | null;
)
}
function RevokeAgentOAuthGrantDialog({ grant, onClose }: { grant: AgentOAuthGrant | null; onClose: () => void }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const revokeMutation = useMutation({
mutationFn: async () => {
if (!grant) return
await revokeAgentOAuthGrant(grant.id)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-oauth-grants'] })
toast.success(t('settings.agentAccess.oauthGrantRevokeSuccess'))
onClose()
},
onError: (err) => toast.error(err.message),
})
if (!grant) return null
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('settings.agentAccess.oauthGrantRevokeTitle')}</DialogTitle>
<DialogDescription>
{t('settings.agentAccess.oauthGrantRevokeConfirm', {
client: grant.clientName,
workspace: grant.workspaceName ?? grant.orgId,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="destructive"
disabled={revokeMutation.isPending}
onClick={() => revokeMutation.mutate()}
>
<Trash2 className="size-4" />
{revokeMutation.isPending ? t('common.loading') : t('settings.agentAccess.revoke')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function AgentOAuthConsentPanel({ oauthQuery, organizations }: { oauthQuery: string; organizations: Organization[] }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const consentQuery = useQuery({
queryKey: ['agent-oauth-consent', oauthQuery],
queryFn: () => getAgentOAuthConsentContext(oauthQuery),
enabled: !!oauthQuery,
retry: false,
})
const submitMutation = useMutation({
mutationFn: (accept: boolean) => submitAgentOAuthConsent({ accept, oauthQuery }),
onSuccess: (result) => redirectExternal(result.url),
onError: (err) => setSubmitError(err instanceof Error ? err.message : t('settings.agentAccess.oauthConsentFailed')),
})
async function changeWorkspace(nextOrgId: string) {
setSwitchingOrgId(nextOrgId)
setSubmitError(null)
try {
const { error } = await setActive({ organizationId: nextOrgId })
if (error) throw error
await queryClient.invalidateQueries({ queryKey: ['agent-oauth-consent', oauthQuery] })
} catch (err) {
toast.error(err instanceof Error ? err.message : t('settings.agentAccess.oauthWorkspaceFailed'))
} finally {
setSwitchingOrgId(null)
}
}
if (consentQuery.isLoading) {
return (
<div className="max-w-3xl rounded-md border bg-background p-6">
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
</div>
)
}
if (consentQuery.isError || !consentQuery.data) {
return (
<div className="max-w-3xl rounded-md border bg-background p-6">
<div className="flex items-start gap-3">
<ShieldAlert className="mt-0.5 size-5 text-destructive" />
<div className="space-y-2">
<h1 className="text-xl font-semibold">{t('settings.agentAccess.oauthExpiredTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('settings.agentAccess.oauthExpiredDescription')}</p>
</div>
</div>
</div>
)
}
const context = consentQuery.data
const lifetimeDays = Math.round(context.grantLifetime.refreshTokenSeconds / 86400)
return (
<div className="max-w-3xl rounded-md border bg-background p-6 shadow-sm">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Plug className="size-4" />
{t('settings.agentAccess.oauthConsentEyebrow')}
</div>
<h1 className="text-2xl font-semibold">{t('settings.agentAccess.oauthConsentTitle')}</h1>
<p className="text-sm text-muted-foreground">{t('settings.agentAccess.oauthConsentDescription')}</p>
</div>
<dl className="mt-6 grid gap-4 rounded-md border bg-muted/30 p-4 sm:grid-cols-2">
<div>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.agentAccess.oauthClient')}</dt>
<dd className="mt-1 font-medium">{context.clientName}</dd>
</div>
<div>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.agentAccess.oauthOrigin')}</dt>
<dd className="mt-1 break-all font-medium">{context.instanceOrigin}</dd>
</div>
<div>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.agentAccess.oauthReturn')}</dt>
<dd className="mt-1 break-all font-medium">{context.redirectUri}</dd>
</div>
<div>
<dt className="text-xs font-medium text-muted-foreground">{t('settings.agentAccess.oauthLifetime')}</dt>
<dd className="mt-1 font-medium">{t('settings.agentAccess.oauthLifetimeValue', { days: lifetimeDays })}</dd>
</div>
</dl>
<div className="mt-6 space-y-2">
<Label htmlFor="agent-oauth-workspace">{t('settings.agentAccess.workspaceLabel')}</Label>
<Select value={context.workspace.id} onValueChange={changeWorkspace} disabled={!!switchingOrgId}>
<SelectTrigger id="agent-oauth-workspace">
<SelectValue />
</SelectTrigger>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-6 space-y-3">
<h2 className="text-base font-semibold">{t('settings.agentAccess.oauthScopesTitle')}</h2>
<div className="grid gap-2 sm:grid-cols-2">
{context.scopes.map((scope) => (
<div key={scope} className="rounded-md border px-3 py-2 text-sm">
{t(agentScopeLabels[scope])}
</div>
))}
</div>
<p className="text-sm text-muted-foreground">{t('settings.agentAccess.oauthEffects')}</p>
</div>
{submitError ? (
<p role="alert" className="mt-4 text-sm text-destructive">
{submitError}
</p>
) : null}
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
disabled={submitMutation.isPending || !!switchingOrgId}
onClick={() => submitMutation.mutate(false)}
>
<X className="size-4" />
{t('settings.agentAccess.oauthDeny')}
</Button>
<Button
type="button"
disabled={submitMutation.isPending || !!switchingOrgId}
onClick={() => submitMutation.mutate(true)}
>
<Check className="size-4" />
{submitMutation.isPending ? t('common.loading') : t('settings.agentAccess.oauthApprove')}
</Button>
</div>
</div>
)
}
function AgentOAuthGrantsSection() {
const { t } = useTranslation()
const [revokingGrant, setRevokingGrant] = useState<AgentOAuthGrant | null>(null)
const grantsQuery = useQuery({
queryKey: ['agent-oauth-grants'],
queryFn: listAgentOAuthGrants,
})
const grants = grantsQuery.data?.items ?? []
return (
<Card>
<CardHeader>
<CardTitle>{t('settings.agentAccess.oauthGrantsSection')}</CardTitle>
<CardDescription>{t('settings.agentAccess.oauthGrantsDescription')}</CardDescription>
</CardHeader>
<CardContent>
{grantsQuery.isLoading ? (
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
) : grantsQuery.isError ? (
<p className="py-6 text-center text-sm text-destructive">{t('settings.agentAccess.oauthGrantsError')}</p>
) : grants.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">{t('settings.agentAccess.oauthNoGrants')}</p>
) : (
<Table className="min-w-[760px]">
<TableHeader>
<TableRow>
<TableHead>{t('settings.agentAccess.oauthClient')}</TableHead>
<TableHead>{t('settings.agentAccess.colWorkspace')}</TableHead>
<TableHead>{t('settings.agentAccess.colScopes')}</TableHead>
<TableHead>{t('settings.agentAccess.colCreated')}</TableHead>
<TableHead>{t('settings.agentAccess.colLastUsed')}</TableHead>
<TableHead>{t('settings.agentAccess.colStatus')}</TableHead>
<TableHead className="w-20 text-right">{t('settings.agentAccess.colActions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{grants.map((grant) => (
<TableRow key={grant.id}>
<TableCell className="font-medium">{grant.clientName}</TableCell>
<TableCell>{grant.workspaceName ?? grant.orgId}</TableCell>
<TableCell>
<div className="flex max-w-md flex-wrap gap-1">
{grant.scopes.map((scope) => (
<Badge key={scope} variant="secondary">
{t(agentScopeLabels[scope])}
</Badge>
))}
</div>
</TableCell>
<TableCell className="whitespace-nowrap">{formatDate(grant.createdAt)}</TableCell>
<TableCell className="whitespace-nowrap">
{formatDate(grant.lastUsedAt) ?? t('settings.agentAccess.never')}
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge>{t('settings.agentAccess.status.active')}</Badge>
</TableCell>
<TableCell className="text-right whitespace-nowrap">
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t('settings.agentAccess.revoke')}
onClick={() => setRevokingGrant(grant)}
>
<Trash2 className="size-4" />
<span className="sr-only">{t('settings.agentAccess.revoke')}</span>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
<RevokeAgentOAuthGrantDialog grant={revokingGrant} onClose={() => setRevokingGrant(null)} />
</Card>
)
}
export function AgentAccessSettingsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: organizationData } = useListOrganizations()
const organizations = (organizationData ?? []) as Organization[]
const oauthQuery = oauthQueryFromLocation()
const [orgId, setOrgId] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [revealedKey, setRevealedKey] = useState<RevealedKey | null>(null)
@@ -300,8 +589,10 @@ export function AgentAccessSettingsPage() {
}
}
if (oauthQuery) return <AgentOAuthConsentPanel oauthQuery={oauthQuery} organizations={organizations} />
return (
<div className="max-w-6xl">
<div className="max-w-6xl space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('settings.agentAccess.section')}</CardTitle>
@@ -414,6 +705,7 @@ export function AgentAccessSettingsPage() {
<CreateAgentKeyDialog open={createOpen} orgId={orgId} onOpenChange={setCreateOpen} onCreated={setRevealedKey} />
<RevealedKeyDialog revealedKey={revealedKey} onClose={() => setRevealedKey(null)} />
<RevokeAgentKeyDialog apiKey={revoking} onClose={() => setRevoking(null)} />
<AgentOAuthGrantsSection />
</div>
)
}