This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


The following commit(s) were added to refs/heads/master by this push:
     new 9701247  feat(connections): connect an app by signing in instead of 
pasting a token
9701247 is described below

commit 970124704474273986d939d059cf3be02d1292d7
Author: Yang Luo <[email protected]>
AuthorDate: Sat Sep 5 17:35:12 2026 +0800

    feat(connections): connect an app by signing in instead of pasting a token
---
 connector/catalog.go                   |  36 ++++
 connector/connector.go                 |  55 ++++-
 connector/connectors/github-oauth.json |  36 ++++
 connector/render.go                    |   5 +
 controllers/connector.go               |  88 ++++++++
 object/connection.go                   |   9 +-
 object/connection_install.go           |   7 +
 object/connection_oauth.go             | 372 +++++++++++++++++++++++++++++++++
 routers/router.go                      |   6 +
 web/src/backend/ConnectorBackend.ts    |  17 ++
 web/src/locales/en/data.json           |   9 +
 web/src/locales/zh/data.json           |   9 +
 web/src/pages/ConnectionsPage.tsx      | 111 +++++++++-
 web/src/types.ts                       |   5 +
 14 files changed, 754 insertions(+), 11 deletions(-)

diff --git a/connector/catalog.go b/connector/catalog.go
index 2db1c23..12c6f2e 100644
--- a/connector/catalog.go
+++ b/connector/catalog.go
@@ -109,14 +109,34 @@ func validate(c Connector) error {
                if c.Auth.AuthorizeUrl == "" || c.Auth.TokenUrl == "" {
                        return fmt.Errorf("%s: an oauth2 connector needs 
authorizeUrl and tokenUrl", c.Id)
                }
+               switch c.Auth.TokenAuth {
+               case "", TokenAuthBody, TokenAuthBasic:
+               default:
+                       return fmt.Errorf("%s: unknown tokenAuth %q", c.Id, 
c.Auth.TokenAuth)
+               }
+               // The client application is the operator's, so the form has to 
ask for
+               // it; a connector that forgets would send an authorization 
request with
+               // no client at all.
+               for _, key := range []string{KeyClientId, KeyClientSecret} {
+                       if !hasField(c, key) {
+                               return fmt.Errorf("%s: an oauth2 connector 
needs a %q field", c.Id, key)
+                       }
+               }
        default:
                return fmt.Errorf("%s: unknown auth kind %q", c.Id, c.Auth.Kind)
        }
 
+       reserved := map[string]bool{}
+       for _, key := range ReservedKeys {
+               reserved[key] = true
+       }
        for _, field := range c.Auth.Fields {
                if strings.TrimSpace(field.Key) == "" {
                        return fmt.Errorf("%s: a field needs a key", c.Id)
                }
+               if reserved[field.Key] {
+                       return fmt.Errorf("%s: %q is filled in by Gateway and 
cannot be a field", c.Id, field.Key)
+               }
        }
 
        switch c.Server.Transport {
@@ -138,6 +158,13 @@ func validate(c Connector) error {
        for _, field := range c.Auth.Fields {
                declared[field.Key] = true
        }
+       // What an authorization produces is not a field anybody fills in, but a
+       // template may refer to it.
+       if c.Auth.Kind == AuthOauth2 {
+               for _, key := range ReservedKeys {
+                       declared[key] = true
+               }
+       }
        for _, reference := range placeholders(c.Server) {
                if !declared[reference] {
                        return fmt.Errorf("%s: server uses ${%s}, which no 
field supplies", c.Id, reference)
@@ -146,6 +173,15 @@ func validate(c Connector) error {
        return nil
 }
 
+func hasField(c Connector, key string) bool {
+       for _, field := range c.Auth.Fields {
+               if field.Key == key {
+                       return true
+               }
+       }
+       return false
+}
+
 // List is the whole catalog, by id.
 func List() []Connector {
        found := make([]Connector, len(catalog))
diff --git a/connector/connector.go b/connector/connector.go
index 4212628..69adbdd 100644
--- a/connector/connector.go
+++ b/connector/connector.go
@@ -17,6 +17,8 @@
 // service, how it is authenticated, and the MCP server that reaches it.
 package connector
 
+import "strings"
+
 // How a connection is authenticated.
 const (
        // AuthNone is a server that needs no credential at all.
@@ -70,6 +72,36 @@ type Connector struct {
        Server ServerSpec `json:"server"`
 }
 
+// The credential keys Gateway itself fills in once an authorization has been
+// granted. They are reserved: a connector's own fields may not use these 
names,
+// and a server template refers to them like any other value.
+const (
+       KeyAccessToken  = "accessToken"
+       KeyRefreshToken = "refreshToken"
+       // KeyExpiresAt is RFC3339, empty when the grant does not expire.
+       KeyExpiresAt = "expiresAt"
+)
+
+// The client application an oauth2 connector is authorized through. The
+// operator registers it on the vendor's own platform and fills these in, which
+// is what keeps the grant inside their tenant and no secret of ours in this
+// binary.
+const (
+       KeyClientId     = "clientId"
+       KeyClientSecret = "clientSecret"
+)
+
+// ReservedKeys is every credential Gateway writes rather than the operator.
+var ReservedKeys = []string{KeyAccessToken, KeyRefreshToken, KeyExpiresAt}
+
+// How the client credentials are presented when a code is exchanged. Most
+// services take them in the form body; some want HTTP Basic instead, and one
+// spelled the wrong way is refused with an error that names neither.
+const (
+       TokenAuthBody  = "body"
+       TokenAuthBasic = "basic"
+)
+
 // Auth is how one connector proves who it is.
 type Auth struct {
        Kind string `json:"kind"`
@@ -82,6 +114,12 @@ type Auth struct {
        TokenUrl     string            `json:"tokenUrl,omitempty"`
        Scopes       []string          `json:"scopes,omitempty"`
        ExtraParams  map[string]string `json:"extraParams,omitempty"`
+       // TokenAuth is how the client credentials reach the token endpoint, 
empty
+       // for the usual form body.
+       TokenAuth string `json:"tokenAuth,omitempty"`
+       // ScopeSeparator is what joins the scopes, empty for the space the
+       // specification asks for. A few services only accept commas.
+       ScopeSeparator string `json:"scopeSeparator,omitempty"`
        // RegisterUrl is where the operator creates the application these
        // credentials come from, linked from the connect dialog.
        RegisterUrl string `json:"registerUrl,omitempty"`
@@ -114,8 +152,9 @@ type ServerSpec struct {
 // NeedsAuth reports whether connecting this one asks for anything.
 func (c Connector) NeedsAuth() bool { return c.Auth.Kind != AuthNone }
 
-// SecretKeys is every field of this connector whose value is never returned
-// once stored.
+// SecretKeys is every credential of this connector that is never returned once
+// stored: the secret fields the operator filled in, and every token Gateway
+// obtained on their behalf.
 func (c Connector) SecretKeys() []string {
        keys := []string{}
        for _, field := range c.Auth.Fields {
@@ -123,5 +162,17 @@ func (c Connector) SecretKeys() []string {
                        keys = append(keys, field.Key)
                }
        }
+       if c.Auth.Kind == AuthOauth2 {
+               keys = append(keys, KeyAccessToken, KeyRefreshToken)
+       }
        return keys
 }
+
+// ScopeList is the scope parameter this connector asks for.
+func (c Connector) ScopeList() string {
+       separator := c.Auth.ScopeSeparator
+       if separator == "" {
+               separator = " "
+       }
+       return strings.Join(c.Auth.Scopes, separator)
+}
diff --git a/connector/connectors/github-oauth.json 
b/connector/connectors/github-oauth.json
new file mode 100644
index 0000000..a386999
--- /dev/null
+++ b/connector/connectors/github-oauth.json
@@ -0,0 +1,36 @@
+{
+  "id": "github-oauth",
+  "displayName": "GitHub (OAuth app)",
+  "category": "dev",
+  "icon": "github.com",
+  "description": "The same GitHub access, granted by signing in instead of 
pasting a token. The application is one you register yourself, so the grant and 
its audit trail stay in your own organisation.",
+  "docsUrl": "https://docs.github.com/en/apps/oauth-apps/building-oauth-apps";,
+  "auth": {
+    "kind": "oauth2",
+    "fields": [
+      {
+        "key": "clientId",
+        "label": "Client ID",
+        "help": "From the OAuth app you register. Set its callback URL to the 
address shown above.",
+        "required": true
+      },
+      {
+        "key": "clientSecret",
+        "label": "Client secret",
+        "secret": true,
+        "required": true
+      }
+    ],
+    "authorizeUrl": "https://github.com/login/oauth/authorize";,
+    "tokenUrl": "https://github.com/login/oauth/access_token";,
+    "scopes": ["repo", "read:org"],
+    "scopeSeparator": ",",
+    "registerUrl": "https://github.com/settings/developers";
+  },
+  "server": {
+    "name": "github-oauth",
+    "transport": "http",
+    "url": "https://api.githubcopilot.com/mcp/";,
+    "headers": {"Authorization": "Bearer ${accessToken}"}
+  }
+}
diff --git a/connector/render.go b/connector/render.go
index d4e60b0..12ba8ce 100644
--- a/connector/render.go
+++ b/connector/render.go
@@ -75,6 +75,11 @@ func (c Connector) Render(credentials map[string]string) 
(Rendered, error) {
                        return Rendered{}, fmt.Errorf("%s needs a value", 
field.Label)
                }
        }
+       // Saying what is missing is worth a case of its own: "no value for
+       // ${accessToken}" names a template, not the thing the operator has to 
do.
+       if c.Auth.Kind == AuthOauth2 && 
strings.TrimSpace(credentials[KeyAccessToken]) == "" {
+               return Rendered{}, fmt.Errorf("this connection has not been 
authorized yet")
+       }
 
        fill := func(value string) (string, error) {
                var missing string
diff --git a/controllers/connector.go b/controllers/connector.go
index dc496d4..0e344d3 100644
--- a/controllers/connector.go
+++ b/controllers/connector.go
@@ -16,6 +16,8 @@ package controllers
 
 import (
        "encoding/json"
+       "fmt"
+       "html"
        "slices"
        "sort"
        "strings"
@@ -35,6 +37,9 @@ type ConnectorEntry struct {
        connector.Connector
        Connected bool     `json:"connected"`
        Agents    []string `json:"agents"`
+       // Authorized reports that an oauth2 connector has a grant, which is 
what
+       // the dialog needs to tell "fill in the application" from "go and 
approve".
+       Authorized bool `json:"authorized"`
 }
 
 // ConnectorTarget is one agent on this machine a connection can be installed
@@ -77,6 +82,7 @@ func (c *ApiController) GetConnectors() {
                if connection, ok := state[found.Id]; ok {
                        entry.Connected = true
                        entry.Agents = connection.Agents
+                       entry.Authorized = 
connection.Credentials[connector.KeyAccessToken] != ""
                }
                entries = append(entries, entry)
        }
@@ -258,3 +264,85 @@ func (c *ApiController) ResolveConnection() {
        }
        c.ResponseOk(rendered)
 }
+
+// StartConnectorAuth stores the client application the operator registered and
+// answers with the address to send them to. The browser goes there; the vendor
+// sends them back to ConnectorAuthCallback.
+func (c *ApiController) StartConnectorAuth() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var form struct {
+               Owner       string            `json:"owner"`
+               Name        string            `json:"name"`
+               Credentials map[string]string `json:"credentials"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       authorizeUrl, err := object.StartConnectorAuth(form.Owner, form.Name, 
form.Credentials)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(authorizeUrl)
+}
+
+// ConnectorAuthCallback is where the vendor sends the operator back. It is
+// opened by their browser rather than called by the UI, so it answers with a
+// page rather than JSON: a tab that says what happened and closes itself.
+func (c *ApiController) ConnectorAuthCallback() {
+       code := c.GetString("code")
+       state := c.GetString("state")
+       if failure := c.GetString("error"); failure != "" {
+               c.authCallbackPage(false, failure+" 
"+c.GetString("error_description"))
+               return
+       }
+       if code == "" || state == "" {
+               c.authCallbackPage(false, "the vendor sent no authorization 
code")
+               return
+       }
+
+       connection, err := object.CompleteConnectorAuth(state, code)
+       if err != nil {
+               c.authCallbackPage(false, err.Error())
+               return
+       }
+       c.authCallbackPage(true, connection.Name)
+}
+
+// authCallbackPage answers the browser tab the vendor redirected. It is plain
+// text in a minimal document on purpose: this tab is closed a moment later, 
and
+// what matters is that the operator can see which way it went.
+func (c *ApiController) authCallbackPage(ok bool, detail string) {
+       title := "Authorized"
+       if !ok {
+               title = "Authorization failed"
+       }
+
+       c.Ctx.Output.Header("Content-Type", "text/html; charset=utf-8")
+       body := fmt.Sprintf(`<!doctype html><meta 
charset="utf-8"><title>%s</title>
+<body style="font:14px system-ui;padding:3rem;text-align:center">
+<h2>%s</h2><p style="color:#666">%s</p>
+<p style="color:#999">You can close this tab and go back to Casbin 
Gateway.</p>`,
+               html.EscapeString(title), html.EscapeString(title), 
html.EscapeString(detail))
+       _ = c.Ctx.Output.Body([]byte(body))
+}
+
+// GetConnectorRedirectUri is the address the operator registers their
+// application with, shown in the dialog before they go and create one.
+func (c *ApiController) GetConnectorRedirectUri() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       redirect, err := object.RedirectUri()
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(redirect)
+}
diff --git a/object/connection.go b/object/connection.go
index 2d3034e..f657dde 100644
--- a/object/connection.go
+++ b/object/connection.go
@@ -209,7 +209,14 @@ func SaveConnection(connection *Connection) error {
                _, err = ormer.Engine.Insert(connection)
                return err
        }
-       _, err = ormer.Engine.ID(core.PK{connection.Owner, 
connection.Name}).AllCols().Update(connection)
+       return updateConnection(connection)
+}
+
+// updateConnection writes every column of an existing row. AllCols is what
+// makes a credential that was cleared actually clear, rather than xorm 
skipping
+// the empty value and leaving the old one in place.
+func updateConnection(connection *Connection) error {
+       _, err := ormer.Engine.ID(core.PK{connection.Owner, 
connection.Name}).AllCols().Update(connection)
        return err
 }
 
diff --git a/object/connection_install.go b/object/connection_install.go
index 8c7c654..d181366 100644
--- a/object/connection_install.go
+++ b/object/connection_install.go
@@ -158,6 +158,13 @@ func ResolveConnection(owner string, name string) 
(*connector.Rendered, error) {
                return nil, fmt.Errorf("no connector named %q", name)
        }
 
+       // A session is about to start on this credential, so this is the 
moment a
+       // grant near its end is worth renewing. Doing it here rather than on a 
timer
+       // means a connection nobody uses costs nothing.
+       if err := refreshIfNeeded(connection, found); err != nil {
+               return nil, err
+       }
+
        rendered, err := found.Render(connection.Credentials)
        if err != nil {
                return nil, err
diff --git a/object/connection_oauth.go b/object/connection_oauth.go
new file mode 100644
index 0000000..11ba16c
--- /dev/null
+++ b/object/connection_oauth.go
@@ -0,0 +1,372 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+       "crypto/rand"
+       "crypto/sha256"
+       "encoding/base64"
+       "encoding/json"
+       "fmt"
+       "io"
+       "net/http"
+       "net/url"
+       "strings"
+       "sync"
+       "time"
+
+       "github.com/apache/casbin-gateway/agentpatch"
+       "github.com/apache/casbin-gateway/connector"
+       "github.com/apache/casbin-gateway/proxy"
+       "github.com/apache/casbin-gateway/util"
+)
+
+// CallbackPath is where a vendor sends the operator back after they approve.
+// It is loopback, so the application they register on the vendor's own 
platform
+// names this machine and nothing of ours is in the middle.
+const CallbackPath = "/api/connector-auth-callback"
+
+const (
+       // pendingTtl is how long an operator has to finish approving before the
+       // request they started is forgotten.
+       pendingTtl = 10 * time.Minute
+       // refreshMargin renews a token that is about to expire rather than one 
that
+       // already has, so a session does not start on a credential that dies 
during
+       // its first call.
+       refreshMargin   = 2 * time.Minute
+       exchangeTimeout = 30 * time.Second
+)
+
+// pendingAuth is one authorization the operator has been sent off to approve.
+// It lives in memory only: a request nobody finished is nothing to keep, and a
+// verifier written to disk would outlive the exchange it protects.
+type pendingAuth struct {
+       Owner    string
+       Name     string
+       Verifier string
+       Started  time.Time
+       Redirect string
+}
+
+var pending = struct {
+       sync.Mutex
+       byState map[string]pendingAuth
+}{byState: map[string]pendingAuth{}}
+
+// RedirectUri is what the operator registers on the vendor's platform. It is
+// shown in the connect dialog, because an application registered with a
+// different one fails at the end of the flow rather than the start.
+func RedirectUri() (string, error) {
+       base, err := agentpatch.GatewayBaseUrl()
+       if err != nil {
+               return "", err
+       }
+       return base + CallbackPath, nil
+}
+
+// StartConnectorAuth stores the client application the operator registered and
+// returns the address to send them to. Nothing is authorized yet: the
+// connection exists with its client credentials and no token.
+func StartConnectorAuth(owner string, name string, credentials 
map[string]string) (string, error) {
+       found, ok := connector.Get(name)
+       if !ok {
+               return "", fmt.Errorf("no connector named %q", name)
+       }
+       if found.Auth.Kind != connector.AuthOauth2 {
+               return "", fmt.Errorf("%s is not authorized this way", name)
+       }
+
+       connection, err := GetConnection(owner, name)
+       if err != nil {
+               return "", err
+       }
+       if connection == nil {
+               connection = &Connection{Owner: owner, Name: name}
+       }
+       connection.Credentials = mergeCredentials(connection.Credentials, 
credentials)
+       if strings.TrimSpace(connection.Credentials[connector.KeyClientId]) == 
"" {
+               return "", fmt.Errorf("the client application's %s is needed 
first", connector.KeyClientId)
+       }
+       // Saved before the operator leaves for the vendor, so the callback has 
the
+       // client secret to exchange the code with when they come back.
+       if err := saveWithoutRendering(connection); err != nil {
+               return "", err
+       }
+
+       redirect, err := RedirectUri()
+       if err != nil {
+               return "", err
+       }
+       state, err := randomString()
+       if err != nil {
+               return "", err
+       }
+       verifier, err := randomString()
+       if err != nil {
+               return "", err
+       }
+
+       pending.Lock()
+       forgetExpiredLocked()
+       pending.byState[state] = pendingAuth{
+               Owner: owner, Name: name, Verifier: verifier, Started: 
time.Now(), Redirect: redirect,
+       }
+       pending.Unlock()
+
+       return authorizeUrl(found, 
connection.Credentials[connector.KeyClientId], redirect, state, verifier)
+}
+
+func authorizeUrl(found connector.Connector, clientId string, redirect string, 
state string, verifier string) (string, error) {
+       parsed, err := url.Parse(found.Auth.AuthorizeUrl)
+       if err != nil {
+               return "", err
+       }
+
+       query := parsed.Query()
+       query.Set("response_type", "code")
+       query.Set("client_id", clientId)
+       query.Set("redirect_uri", redirect)
+       query.Set("state", state)
+       if scopes := found.ScopeList(); scopes != "" {
+               query.Set("scope", scopes)
+       }
+       // PKCE costs nothing where it is ignored and is what stops a code seen 
on
+       // this machine from being redeemed by anything else.
+       query.Set("code_challenge", challengeOf(verifier))
+       query.Set("code_challenge_method", "S256")
+       for name, value := range found.Auth.ExtraParams {
+               query.Set(name, value)
+       }
+       parsed.RawQuery = query.Encode()
+       return parsed.String(), nil
+}
+
+// CompleteConnectorAuth exchanges the code the vendor sent back and stores the
+// tokens. It returns the connection the grant belongs to so the caller can say
+// which one just became usable.
+func CompleteConnectorAuth(state string, code string) (*Connection, error) {
+       pending.Lock()
+       forgetExpiredLocked()
+       request, found := pending.byState[state]
+       delete(pending.byState, state)
+       pending.Unlock()
+
+       if !found {
+               return nil, fmt.Errorf("this authorization was not started 
here, or took too long")
+       }
+
+       connection, err := GetConnection(request.Owner, request.Name)
+       if err != nil {
+               return nil, err
+       }
+       if connection == nil {
+               return nil, fmt.Errorf("%q is no longer connected", 
request.Name)
+       }
+       entry, ok := connector.Get(request.Name)
+       if !ok {
+               return nil, fmt.Errorf("no connector named %q", request.Name)
+       }
+
+       form := url.Values{}
+       form.Set("grant_type", "authorization_code")
+       form.Set("code", code)
+       form.Set("redirect_uri", request.Redirect)
+       form.Set("code_verifier", request.Verifier)
+
+       granted, err := exchange(entry, connection.Credentials, form)
+       if err != nil {
+               return nil, err
+       }
+       connection.Credentials = mergeCredentials(connection.Credentials, 
granted)
+       if err := saveWithoutRendering(connection); err != nil {
+               return nil, err
+       }
+       return connection, nil
+}
+
+// refreshIfNeeded renews an access token that has expired or is about to. It 
is
+// called where the credential is handed out rather than on a timer, so a token
+// is only renewed when something is actually about to use it.
+func refreshIfNeeded(connection *Connection, found connector.Connector) error {
+       if found.Auth.Kind != connector.AuthOauth2 {
+               return nil
+       }
+       refreshToken := 
strings.TrimSpace(connection.Credentials[connector.KeyRefreshToken])
+       if refreshToken == "" {
+               // A grant that never expires, or one the service will not 
renew. Either
+               // way there is nothing to do until it stops working.
+               return nil
+       }
+       expiry := 
strings.TrimSpace(connection.Credentials[connector.KeyExpiresAt])
+       if expiry == "" {
+               return nil
+       }
+       at, err := time.Parse(time.RFC3339, expiry)
+       if err != nil || time.Until(at) > refreshMargin {
+               return nil
+       }
+
+       form := url.Values{}
+       form.Set("grant_type", "refresh_token")
+       form.Set("refresh_token", refreshToken)
+
+       granted, err := exchange(found, connection.Credentials, form)
+       if err != nil {
+               return fmt.Errorf("renewing the authorization of %s failed: 
%w", connection.Name, err)
+       }
+       connection.Credentials = mergeCredentials(connection.Credentials, 
granted)
+       return saveWithoutRendering(connection)
+}
+
+// exchange posts to the token endpoint and reads what came back. It is shared
+// by the first exchange and every renewal, which differ only in the grant.
+func exchange(found connector.Connector, credentials map[string]string, form 
url.Values) (map[string]string, error) {
+       clientId := credentials[connector.KeyClientId]
+       clientSecret := credentials[connector.KeyClientSecret]
+
+       if found.Auth.TokenAuth != connector.TokenAuthBasic {
+               form.Set("client_id", clientId)
+               if clientSecret != "" {
+                       form.Set("client_secret", clientSecret)
+               }
+       }
+
+       request, err := http.NewRequest(http.MethodPost, found.Auth.TokenUrl, 
strings.NewReader(form.Encode()))
+       if err != nil {
+               return nil, err
+       }
+       request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+       // Some token endpoints answer in form encoding unless asked otherwise.
+       request.Header.Set("Accept", "application/json")
+       if found.Auth.TokenAuth == connector.TokenAuthBasic {
+               request.SetBasicAuth(clientId, clientSecret)
+       }
+
+       client := &http.Client{Timeout: exchangeTimeout, Transport: 
proxy.Transport()}
+       response, err := client.Do(request)
+       if err != nil {
+               return nil, err
+       }
+       defer response.Body.Close()
+
+       body, err := io.ReadAll(io.LimitReader(response.Body, 1024*1024))
+       if err != nil {
+               return nil, err
+       }
+       if response.StatusCode >= 400 {
+               return nil, fmt.Errorf("the token endpoint answered %s: %s", 
response.Status, summarize(body))
+       }
+
+       var granted struct {
+               AccessToken  string `json:"access_token"`
+               RefreshToken string `json:"refresh_token"`
+               ExpiresIn    int64  `json:"expires_in"`
+               Error        string `json:"error"`
+               ErrorText    string `json:"error_description"`
+       }
+       if err := json.Unmarshal(body, &granted); err != nil {
+               return nil, fmt.Errorf("the token endpoint did not answer with 
JSON: %s", summarize(body))
+       }
+       // An error here comes back with 200 more often than it should.
+       if granted.Error != "" {
+               return nil, fmt.Errorf("%s: %s", granted.Error, 
granted.ErrorText)
+       }
+       if granted.AccessToken == "" {
+               return nil, fmt.Errorf("the token endpoint returned no access 
token")
+       }
+
+       result := map[string]string{connector.KeyAccessToken: 
granted.AccessToken}
+       // A renewal that returns no new refresh token keeps the one it was 
given,
+       // which is what merging leaves alone.
+       if granted.RefreshToken != "" {
+               result[connector.KeyRefreshToken] = granted.RefreshToken
+       }
+       if granted.ExpiresIn > 0 {
+               result[connector.KeyExpiresAt] = 
time.Now().Add(time.Duration(granted.ExpiresIn) * 
time.Second).Format(time.RFC3339)
+       }
+       return result, nil
+}
+
+// saveWithoutRendering stores a connection that is not usable yet. 
SaveConnection
+// refuses one whose credentials do not render, which is right when the 
operator
+// is finishing a form and wrong halfway through an authorization.
+func saveWithoutRendering(connection *Connection) error {
+       connection.UpdatedTime = util.GetCurrentTime()
+       if connection.Credentials == nil {
+               connection.Credentials = map[string]string{}
+       }
+
+       existing, err := GetConnection(connection.Owner, connection.Name)
+       if err != nil {
+               return err
+       }
+       if err := encryptCredentials(connection); err != nil {
+               return err
+       }
+       if existing == nil {
+               connection.CreatedTime = util.GetCurrentTime()
+               _, err = ormer.Engine.Insert(connection)
+               return err
+       }
+       return updateConnection(connection)
+}
+
+// mergeCredentials layers new values over old, ignoring a masked value and an
+// empty one: an edit that only resends the client id must not erase the token
+// beside it.
+func mergeCredentials(existing map[string]string, incoming map[string]string) 
map[string]string {
+       merged := map[string]string{}
+       for key, value := range existing {
+               merged[key] = value
+       }
+       for key, value := range incoming {
+               if value == "" || value == ApiKeyMask {
+                       continue
+               }
+               merged[key] = value
+       }
+       return merged
+}
+
+func forgetExpiredLocked() {
+       for state, request := range pending.byState {
+               if time.Since(request.Started) > pendingTtl {
+                       delete(pending.byState, state)
+               }
+       }
+}
+
+func randomString() (string, error) {
+       buffer := make([]byte, 32)
+       if _, err := rand.Read(buffer); err != nil {
+               return "", err
+       }
+       return base64.RawURLEncoding.EncodeToString(buffer), nil
+}
+
+func challengeOf(verifier string) string {
+       sum := sha256.Sum256([]byte(verifier))
+       return base64.RawURLEncoding.EncodeToString(sum[:])
+}
+
+// summarize keeps an endpoint's error readable in a message without pasting a
+// whole page into it.
+func summarize(body []byte) string {
+       text := strings.TrimSpace(string(body))
+       if len(text) > 300 {
+               return text[:300] + "..."
+       }
+       return text
+}
diff --git a/routers/router.go b/routers/router.go
index f49d7b9..6503bc4 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -113,6 +113,12 @@ func initAPI() {
        beego.Router("/api/connect", &controllers.ApiController{}, 
"POST:Connect")
        beego.Router("/api/disconnect", &controllers.ApiController{}, 
"POST:Disconnect")
        beego.Router("/api/resolve-connection", &controllers.ApiController{}, 
"POST:ResolveConnection")
+       beego.Router("/api/get-connector-redirect-uri", 
&controllers.ApiController{}, "GET:GetConnectorRedirectUri")
+       beego.Router("/api/start-connector-auth", &controllers.ApiController{}, 
"POST:StartConnectorAuth")
+       // Opened by the vendor's redirect rather than by the UI, so it is 
reached
+       // without a session: what proves the caller is the unguessable state it
+       // carries, which is the whole point of that parameter.
+       beego.Router("/api/connector-auth-callback", 
&controllers.ApiController{}, "GET:ConnectorAuthCallback")
 
        // The desktop tray, which holds the local token instead of a session.
        beego.Router("/api/get-tray-menu", &controllers.ApiController{}, 
"GET:GetTrayMenu")
diff --git a/web/src/backend/ConnectorBackend.ts 
b/web/src/backend/ConnectorBackend.ts
index 164b45d..fbe1af0 100644
--- a/web/src/backend/ConnectorBackend.ts
+++ b/web/src/backend/ConnectorBackend.ts
@@ -40,3 +40,20 @@ export function connect(body: ConnectRequest) {
 export function disconnect(owner: string, name: string) {
   return request<AgentConfigPlanItem[]>("/api/disconnect", "POST", {owner: 
owner, name: name});
 }
+
+/** The callback address the operator registers their application with. */
+export function getConnectorRedirectUri() {
+  return request<string>("/api/get-connector-redirect-uri");
+}
+
+/**
+ * Stores the client application and answers with the vendor address to send 
the
+ * operator to. Nothing is authorized until they come back through the 
callback.
+ */
+export function startConnectorAuth(owner: string, name: string, credentials: 
Record<string, string>) {
+  return request<string>("/api/start-connector-auth", "POST", {
+    owner: owner,
+    name: name,
+    credentials: credentials,
+  });
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 649ceba..df3804f 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -807,7 +807,14 @@
     "Amap": "Amap",
     "App ID": "App ID",
     "App secret": "App secret",
+    "Authorization": "Authorization",
+    "Authorization hint": "Register the application first, then approve it in 
the tab that opens.",
+    "Authorize": "Authorize",
+    "Authorize again": "Authorize again",
+    "Authorized": "Authorized",
     "Billed by vendor": "Billed by vendor",
+    "Callback URL": "Callback URL",
+    "Callback URL hint": "Register this as the application's callback URL, 
exactly as shown.",
     "Connect": "Connect",
     "Connected": "Connected",
     "Connected in {count}": "In {count} agents",
@@ -835,6 +842,7 @@
     "Mail": "Mail",
     "Manage": "Manage",
     "No agent found": "No agent on this machine takes MCP servers Gateway 
knows how to write.",
+    "Not authorized": "Not authorized yet",
     "Office": "Office",
     "Personal access token": "Personal access token",
     "Pick at least one agent": "Pick at least one agent.",
@@ -846,6 +854,7 @@
     "The agent can read and write this directory and everything under it.": 
"The agent can read and write this directory and everything under it.",
     "Unverified": "Unverified",
     "Up-to-date documentation and code samples for third-party libraries, so 
the model stops writing API calls from memory.": "Up-to-date documentation and 
code samples for third-party libraries, so the model stops writing API calls 
from memory.",
+    "Waiting for approval": "Waiting for approval...",
     "Web service API key": "Web service API key",
     "Where these come from": "Where these come from",
     "Work with Lark from inside an agent: messages and groups, documents, 
spreadsheets, calendars and contacts, through your own company's app.": "Work 
with Lark from inside an agent: messages and groups, documents, spreadsheets, 
calendars and contacts, through your own company's app."
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 95a1537..6180c66 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -879,7 +879,14 @@
     "Amap": "高德地图",
     "App ID": "App ID",
     "App secret": "App Secret",
+    "Authorization": "授权",
+    "Authorization hint": "先在厂商平台注册应用并填好上面的信息,再在弹出的标签页里完成授权。",
+    "Authorize": "去授权",
+    "Authorize again": "重新授权",
+    "Authorized": "已授权",
     "Billed by vendor": "第三方收费",
+    "Callback URL": "回调地址",
+    "Callback URL hint": "把这个地址原样填进应用的回调地址,多一个字符都不行。",
     "Connect": "连接",
     "Connected": "已连接",
     "Connected in {count}": "已装入{count}个Agent",
@@ -907,6 +914,7 @@
     "Mail": "邮件服务",
     "Manage": "管理",
     "No agent found": "这台机器上没有Gateway能写入MCP配置的Agent。",
+    "Not authorized": "尚未授权",
     "Office": "办公协同",
     "Personal access token": "个人访问令牌",
     "Pick at least one agent": "至少勾选一个Agent。",
@@ -918,6 +926,7 @@
     "The agent can read and write this directory and everything under it.": 
"Agent只能读写这个目录及其子目录。",
     "Unverified": "未验证",
     "Up-to-date documentation and code samples for third-party libraries, so 
the model stops writing API calls from memory.": 
"为Agent提供第三方库的最新文档和代码示例,避免模型凭记忆写出过时的API调用。",
+    "Waiting for approval": "等待授权完成...",
     "Web service API key": "Web服务API Key",
     "Where these come from": "去哪里获取这些凭据",
     "Work with Lark from inside an agent: messages and groups, documents, 
spreadsheets, calendars and contacts, through your own company's app.": 
"在Agent里直接操作飞书:消息与群组、云文档、多维表格、日历和通讯录,凭据来自你自己企业的自建应用。"
diff --git a/web/src/pages/ConnectionsPage.tsx 
b/web/src/pages/ConnectionsPage.tsx
index cbe57cf..cafe7d9 100644
--- a/web/src/pages/ConnectionsPage.tsx
+++ b/web/src/pages/ConnectionsPage.tsx
@@ -20,7 +20,7 @@ import * as Setting from "@/Setting";
 import * as ConnectorBackend from "@/backend/ConnectorBackend";
 import {AgentIcon} from "@/components/AgentIcon";
 import {Field, FormDialog} from "@/components/shared/form-dialog";
-import {UnauthorizedResult} from "@/components/shared/misc";
+import {CopyButton, UnauthorizedResult} from "@/components/shared/misc";
 import {PageContainer, PageHeader} from "@/components/shared/page-header";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
@@ -212,23 +212,84 @@ function ConnectDialog({
   onDone: () => void;
 }) {
   const fields = entry.auth.fields ?? [];
+  const isOauth = entry.auth.kind === "oauth2";
   const [credentials, setCredentials] = React.useState<Record<string, 
string>>({});
   const [agents, setAgents] = React.useState<string[]>(entry.agents);
   const [submitting, setSubmitting] = React.useState(false);
+  const [authorized, setAuthorized] = React.useState(entry.authorized);
+  const [awaiting, setAwaiting] = React.useState(false);
+  const [redirectUri, setRedirectUri] = React.useState("");
+
+  const load = React.useCallback(
+    () =>
+      ConnectorBackend.getConnection(account.name, entry.id).then(response => {
+        if (response.status === "ok" && response.data) {
+          setCredentials(response.data.credentials ?? {});
+          setAgents(response.data.agents ?? []);
+          return (response.data.credentials ?? {})["accessToken"] !== 
undefined;
+        }
+        return false;
+      }),
+    [account.name, entry.id],
+  );
+
+  React.useEffect(() => {
+    if (entry.connected) {
+      load();
+    }
+  }, [entry.connected, load]);
 
   React.useEffect(() => {
-    if (!entry.connected) {
+    if (!isOauth) {
       return;
     }
-    ConnectorBackend.getConnection(account.name, entry.id).then(response => {
-      if (response.status === "ok" && response.data) {
-        setCredentials(response.data.credentials ?? {});
-        setAgents(response.data.agents ?? []);
+    ConnectorBackend.getConnectorRedirectUri().then(response => {
+      if (response.status === "ok") {
+        setRedirectUri(response.data ?? "");
       }
     });
-  }, [account.name, entry.connected, entry.id]);
+  }, [isOauth]);
+
+  // Approving happens in another tab, and the vendor sends the operator back 
to
+  // Gateway rather than to this dialog, so the only way this form learns it
+  // worked is to keep asking until the grant is there.
+  React.useEffect(() => {
+    if (!awaiting) {
+      return;
+    }
+    const timer = window.setInterval(() => {
+      load().then(granted => {
+        if (granted) {
+          setAuthorized(true);
+          setAwaiting(false);
+          Setting.showMessage("success", i18next.t("connector:Authorized"));
+        }
+      });
+    }, 2000);
+    const giveUp = window.setTimeout(() => setAwaiting(false), 3 * 60 * 1000);
+    return () => {
+      window.clearInterval(timer);
+      window.clearTimeout(giveUp);
+    };
+  }, [awaiting, load]);
+
+  const authorize = () => {
+    setSubmitting(true);
+    ConnectorBackend.startConnectorAuth(account.name, entry.id, credentials)
+      .then(response => {
+        if (response.status === "ok" && response.data) {
+          window.open(response.data, "_blank", "noopener");
+          setAwaiting(true);
+        } else {
+          Setting.showMessage("error", response.msg ?? "");
+        }
+      })
+      .catch(error => Setting.showMessage("error", error.message))
+      .finally(() => setSubmitting(false));
+  };
 
-  const missing = fields.some(field => field.required && 
(credentials[field.key] ?? "").trim() === "");
+  const missing =
+    fields.some(field => field.required && (credentials[field.key] ?? 
"").trim() === "") || (isOauth && !authorized);
 
   const submit = () => {
     setSubmitting(true);
@@ -290,6 +351,15 @@ function ConnectDialog({
         </a>
       ) : null}
 
+      {isOauth && redirectUri !== "" ? (
+        <Field label={i18next.t("connector:Callback URL")} 
hint={i18next.t("connector:Callback URL hint")}>
+          <div className="flex items-center gap-2">
+            <code className="bg-muted min-w-0 flex-1 truncate rounded px-2 
py-1.5 text-xs">{redirectUri}</code>
+            <CopyButton value={redirectUri} />
+          </div>
+        </Field>
+      ) : null}
+
       {fields.map(field => (
         <Field key={field.key} label={text(field.label)} 
required={field.required} hint={text(field.help)}>
           <Input
@@ -309,6 +379,31 @@ function ConnectDialog({
         </Field>
       ))}
 
+      {isOauth ? (
+        <Field
+          label={i18next.t("connector:Authorization")}
+          hint={authorized ? undefined : i18next.t("connector:Authorization 
hint")}
+        >
+          <div className="flex items-center gap-3">
+            <Button
+              type="button"
+              variant={authorized ? "outline" : "default"}
+              onClick={authorize}
+              disabled={submitting || (credentials["clientId"] ?? "").trim() 
=== ""}
+            >
+              {authorized ? i18next.t("connector:Authorize again") : 
i18next.t("connector:Authorize")}
+            </Button>
+            <span className="text-muted-foreground text-sm">
+              {awaiting
+                ? i18next.t("connector:Waiting for approval")
+                : authorized
+                  ? i18next.t("connector:Authorized")
+                  : i18next.t("connector:Not authorized")}
+            </span>
+          </div>
+        </Field>
+      ) : null}
+
       <Field
         label={i18next.t("connector:Install into")}
         hint={i18next.t("connector:Install into hint")}
diff --git a/web/src/types.ts b/web/src/types.ts
index 18c2d1d..dc63a89 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -1505,6 +1505,9 @@ export interface ConnectorAuth {
   authorizeUrl?: string;
   tokenUrl?: string;
   scopes?: string[];
+  scopeSeparator?: string;
+  /** How the client credentials reach the token endpoint. */
+  tokenAuth?: "body" | "basic";
   /** Where the operator creates the application these credentials come from. 
*/
   registerUrl?: string;
 }
@@ -1535,6 +1538,8 @@ export interface ConnectorEntry {
   server: ConnectorServerSpec;
   connected: boolean;
   agents: string[];
+  /** An oauth2 connector that has a grant, rather than only a client app. */
+  authorized: boolean;
 }
 
 /** An agent on this machine a connection can be installed into. */


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to