Files
larksuite-cli/sidecar/server-multi-tenant-demo/handler.go
hGrany ffcf7781b4 feat(sidecar): support multi-client identity isolation in server-demo (#934)
* feat(sidecar): support multi-client identity isolation in server-demo

When multiple CLI sandbox environments share a single sidecar instance,
user tokens (UAT) were not isolated -- the last user to log in would
overwrite previous users' tokens, causing identity cross-contamination.

This change introduces per-client HMAC key isolation:
- Each client gets a unique client-*.key file for data-plane HMAC signing,
  allowing the sidecar to identify request origin.
- A new auth_bridge.go handles management endpoints (login/poll/status)
  with explicit client-to-feishuOpenId binding.
- User token resolution is strictly bound to the matched client -- no
  fallback to other users' tokens when a client has no mapping.
- The shared proxy.key is reused across restarts instead of regenerated,
  fixing a race condition when multiple sidecar instances start together.

Wire protocol (sidecar package) is unchanged; existing single-client
deployments are fully backward compatible.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* fix(sidecar): address review feedback on filesystem and safety

- Replace os.ReadFile/WriteFile/ReadDir with vfs.* equivalents for test
  mockability, consistent with project coding guidelines.
- Limit auth bridge request body to 64KB to prevent memory exhaustion.
- Log errors in saveUserMap instead of silently discarding them.
- Reject client keys that collide with the shared proxy key.
- Reject duplicate client keys instead of silently overwriting.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* refactor(sidecar): remove workspace-specific naming and backward compat

- parseClientID: only accept "client_id" field, remove legacy fallback
- loadClientKeys: scan all *.key (excluding proxy.key), no prefix required
- Remove legacy file migration logic in newAuthBridge
- Update flag description to reflect generic key scanning

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* refactor(sidecar): extract multi-tenant demo and add unit tests

Address review feedback from sang-neo03:

1. Extract multi-client code into sidecar/server-multi-tenant-demo/,
   keeping server-demo as the minimal single-tenant reference.

2. Add unit tests for the isolation guarantee:
   - loadClientKeys: shared-key collision and duplicate keyHex are skipped
   - verifyWithClientKeys: correct client matched, unknown key rejected
   - loadUserMap/saveUserMap: round-trip persistence across restart

3. Cross-link READMEs between server-demo and server-multi-tenant-demo.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* docs(sidecar): rewrite multi-tenant demo README with problem statement and client guide

- Explain the multi-app credential isolation problem (app_secret must
  not be exposed to client environments)
- Document typical deployment topology with multiple sidecar instances
- Add complete client setup guide: env vars, multi-app switching, login
  flow, and end-to-end workflow example
- Document design decisions and management endpoint details

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* fix(sidecar): address CodeRabbit review feedback on tests and docs

- Make TestProxyHandler_AcceptsAllowedAuthHeaders fully offline by using
  httptest.NewTLSServer instead of depending on open.feishu.cn
- Isolate TestRun_RejectsSelfProxy config state with t.Setenv and temp dirs
- Check os.MkdirAll error in test fixture setup
- Add language identifiers to fenced code blocks (MD040)
- Validate user-supplied CLI paths with validate.SafeInputPath

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

---------

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)
2026-05-22 15:25:00 +08:00

373 lines
11 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_demo
package main
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/url"
"path/filepath"
"strings"
"sync"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/sidecar"
)
// proxyHandler handles HTTP requests from sandbox CLI instances.
type proxyHandler struct {
key []byte
cred *credential.CredentialProvider
appID string
brand core.LarkBrand
logger *log.Logger
forwardCl *http.Client
allowedHosts map[string]bool // target host allowlist derived from brand
allowedIDs map[string]bool // identity allowlist derived from strict mode
authBridge *authBridge
// Per-client key isolation: keyHex → clientName.
// Data-plane requests are signed with a client-specific key;
// the matched key determines which client (and thus which user
// token) to use. Protected by ckMu.
ckMu sync.RWMutex
clientKeys map[string]clientKeyEntry
keysDir string // directory to scan for *.key files (excluding proxy.key)
}
type clientKeyEntry struct {
key []byte
clientName string
}
// loadClientKeys scans keysDir for *.key files (excluding the shared
// proxy.key) and populates the clientKeys map. The filename stem (without
// .key) becomes the client identity. No naming convention is enforced.
// Safe to call multiple times (e.g. on cache miss).
func (h *proxyHandler) loadClientKeys() {
if h.keysDir == "" {
return
}
entries, err := vfs.ReadDir(h.keysDir)
if err != nil {
h.logger.Printf("KEYS_SCAN_ERROR dir=%s error=%q", h.keysDir, err.Error())
return
}
sharedKeyHex := string(h.key)
newKeys := make(map[string]clientKeyEntry)
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".key") {
continue
}
clientName := strings.TrimSuffix(name, ".key")
if clientName == "" || clientName == "proxy" {
continue
}
data, err := vfs.ReadFile(filepath.Join(h.keysDir, name))
if err != nil {
continue
}
keyHex := strings.TrimSpace(string(data))
if len(keyHex) != 64 {
h.logger.Printf("KEYS_SCAN_SKIP file=%s reason=\"key length %d, expected 64\"", name, len(keyHex))
continue
}
if keyHex == sharedKeyHex {
h.logger.Printf("KEYS_SCAN_SKIP file=%s reason=\"collides with shared proxy key\"", name)
continue
}
if existing, ok := newKeys[keyHex]; ok {
h.logger.Printf("KEYS_SCAN_SKIP file=%s reason=\"duplicate key, already loaded for client %s\"", name, existing.clientName)
continue
}
newKeys[keyHex] = clientKeyEntry{key: []byte(keyHex), clientName: clientName}
}
h.ckMu.Lock()
h.clientKeys = newKeys
h.ckMu.Unlock()
if len(newKeys) > 0 {
names := make([]string, 0, len(newKeys))
for _, e := range newKeys {
names = append(names, e.clientName)
}
h.logger.Printf("KEYS_LOADED count=%d clients=%v", len(newKeys), names)
}
}
// verifyWithClientKeys tries each client key to verify the HMAC.
// Returns the client name on success, or empty string + error if none match.
func (h *proxyHandler) verifyWithClientKeys(cr sidecar.CanonicalRequest, signature string) (string, error) {
h.ckMu.RLock()
keys := h.clientKeys
h.ckMu.RUnlock()
for _, entry := range keys {
if err := sidecar.Verify(entry.key, cr, signature); err == nil {
return entry.clientName, nil
}
}
// Cache miss: rescan keys directory and retry once
h.loadClientKeys()
h.ckMu.RLock()
keys = h.clientKeys
h.ckMu.RUnlock()
for _, entry := range keys {
if err := sidecar.Verify(entry.key, cr, signature); err == nil {
return entry.clientName, nil
}
}
return "", fmt.Errorf("no client key matched")
}
// allowedAuthHeaders lists the only header names the sidecar will inject real
// tokens into.
var allowedAuthHeaders = map[string]bool{
"Authorization": true,
sidecar.HeaderMCPUAT: true,
sidecar.HeaderMCPTAT: true,
}
func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Route management endpoints to authBridge (different HMAC scheme)
if len(r.URL.Path) > 10 && r.URL.Path[:10] == "/_sidecar/" {
if h.authBridge != nil {
h.authBridge.ServeHTTP(w, r)
} else {
http.Error(w, "auth bridge not configured", http.StatusNotImplemented)
}
return
}
start := time.Now()
// 0. Check protocol version
version := r.Header.Get(sidecar.HeaderProxyVersion)
if version != sidecar.ProtocolV1 {
http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest)
return
}
// 1. Verify timestamp
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
if ts == "" {
http.Error(w, "missing "+sidecar.HeaderProxyTimestamp, http.StatusBadRequest)
return
}
// 2. Read body and verify SHA256
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read request body", http.StatusBadRequest)
return
}
r.Body.Close()
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
if claimedSHA == "" {
http.Error(w, "missing "+sidecar.HeaderBodySHA256, http.StatusBadRequest)
return
}
actualSHA := sidecar.BodySHA256(body)
if claimedSHA != actualSHA {
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
return
}
// 3. Verify HMAC signature
target := r.Header.Get(sidecar.HeaderProxyTarget)
if target == "" {
http.Error(w, "missing "+sidecar.HeaderProxyTarget, http.StatusBadRequest)
return
}
pathAndQuery := r.URL.RequestURI()
targetHost, err := parseTarget(target)
if err != nil {
http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+err.Error(), http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
return
}
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
if identity == "" {
http.Error(w, "missing "+sidecar.HeaderProxyIdentity, http.StatusBadRequest)
return
}
authHeader := r.Header.Get(sidecar.HeaderProxyAuthHeader)
if authHeader == "" {
http.Error(w, "missing "+sidecar.HeaderProxyAuthHeader, http.StatusBadRequest)
return
}
signature := r.Header.Get(sidecar.HeaderProxySignature)
cr := sidecar.CanonicalRequest{
Version: version,
Method: r.Method,
Host: targetHost,
PathAndQuery: pathAndQuery,
BodySHA256: claimedSHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
}
// Try the primary (shared) key first, then per-client keys.
// matchedClient is empty when using the shared key.
var matchedClient string
if err := sidecar.Verify(h.key, cr, signature); err != nil {
client, clientErr := h.verifyWithClientKeys(cr, signature)
if clientErr != nil {
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), "no key matched")
return
}
matchedClient = client
}
// 4. Validate target host against allowlist
if !h.allowedHosts[targetHost] {
http.Error(w, "target host not allowed: "+targetHost, http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=\"target host %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), targetHost)
return
}
// 5. Validate identity
if !h.allowedIDs[identity] {
http.Error(w, "identity not allowed: "+identity, http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=\"identity %s not allowed by strict mode\"", r.Method, sanitizePath(pathAndQuery), identity)
return
}
// 5.5 Validate auth-header
if !allowedAuthHeaders[authHeader] {
http.Error(w, "auth-header not allowed: "+authHeader, http.StatusForbidden)
h.logger.Printf("REJECT method=%s path=%s reason=\"auth-header %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), authHeader)
return
}
// 6. Resolve real token
// UAT (user identity): per-client isolation via matched PROXY_KEY.
// TAT (bot identity): shared credential provider (app-level).
var resolvedToken string
if identity == sidecar.IdentityUser && h.authBridge != nil {
token, err := h.authBridge.resolveUserTokenByClient(matchedClient)
if err != nil {
http.Error(w, "failed to resolve user token: "+err.Error(), http.StatusInternalServerError)
h.logger.Printf("TOKEN_ERROR method=%s path=%s identity=%s client=%s error=%q",
r.Method, sanitizePath(pathAndQuery), identity, matchedClient, sanitizeError(err))
return
}
resolvedToken = token
} else {
tokenResult, err := h.cred.ResolveToken(r.Context(), credential.TokenSpec{
Type: credential.TokenTypeTAT,
AppID: h.appID,
})
if err != nil {
http.Error(w, "failed to resolve token: "+err.Error(), http.StatusInternalServerError)
h.logger.Printf("TOKEN_ERROR method=%s path=%s identity=%s error=%q", r.Method, sanitizePath(pathAndQuery), identity, sanitizeError(err))
return
}
resolvedToken = tokenResult.Token
}
// 7. Build forwarding request
forwardURL := "https://" + targetHost + pathAndQuery
forwardReq, err := http.NewRequestWithContext(r.Context(), r.Method, forwardURL, bytes.NewReader(body))
if err != nil {
http.Error(w, "failed to create forward request", http.StatusInternalServerError)
return
}
for k, vs := range r.Header {
if isProxyHeader(k) {
continue
}
for _, v := range vs {
forwardReq.Header.Add(k, v)
}
}
forwardReq.Header.Del("Authorization")
forwardReq.Header.Del(sidecar.HeaderMCPUAT)
forwardReq.Header.Del(sidecar.HeaderMCPTAT)
// 8. Inject real token
if authHeader == "Authorization" {
forwardReq.Header.Set("Authorization", "Bearer "+resolvedToken)
} else {
forwardReq.Header.Set(authHeader, resolvedToken)
}
// 9. Forward request
resp, err := h.forwardCl.Do(forwardReq)
if err != nil {
http.Error(w, "forward request failed: "+err.Error(), http.StatusBadGateway)
h.logger.Printf("FORWARD_ERROR method=%s path=%s error=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err))
return
}
defer resp.Body.Close()
// 10. Copy response back
for k, vs := range resp.Header {
for _, v := range vs {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
// 11. Audit log
clientTag := ""
if matchedClient != "" {
clientTag = " client=" + matchedClient
}
h.logger.Printf("FORWARD method=%s path=%s identity=%s status=%d duration=%s%s",
r.Method, sanitizePath(pathAndQuery), identity, resp.StatusCode, time.Since(start).Round(time.Millisecond), clientTag)
}
// parseTarget validates X-Lark-Proxy-Target and returns the host portion.
func parseTarget(target string) (host string, err error) {
u, perr := url.Parse(target)
if perr != nil {
return "", fmt.Errorf("parse: %w", perr)
}
if u.Scheme != "https" {
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
}
if u.Host == "" {
return "", fmt.Errorf("missing host")
}
if u.User != nil {
return "", fmt.Errorf("userinfo not allowed")
}
if u.Path != "" && u.Path != "/" {
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
}
if u.RawQuery != "" {
return "", fmt.Errorf("query not allowed")
}
if u.Fragment != "" {
return "", fmt.Errorf("fragment not allowed")
}
return u.Host, nil
}