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]