Files
SYNITY 641d4f8756 feat(bitrix24): ship per-user OAuth re-authorization flow (#1417)
feat(bitrix24): ship per-user OAuth re-authorization flow (#1417)
2026-07-10 10:56:45 +07:00

136 lines
4.9 KiB
Go

package bitrix24
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
)
// oauthStateTTL bounds how long a signed authorize-URL state stays valid.
// Long enough for a user to open the DM and click through Bitrix's approve
// page; short enough that a leaked link (log line, browser history) has a
// tight blast radius. Chosen during design brainstorm (design.md §9 Q2).
const oauthStateTTL = 10 * time.Minute
// oauthStatePayload is the identity + routing context carried through the
// Bitrix OAuth redirect round-trip. Stateless by design: no DB row to
// insert/delete/janitor — the state itself is self-verifying (HMAC + embedded
// expiry), so decodeOAuthState can reject garbage before any DB or Bitrix
// network call.
type oauthStatePayload struct {
UserID string `json:"u"`
TenantID string `json:"t"`
// BotID routes the callback back to the right Channel instance via
// Router.DispatcherByBotID — Bitrix's redirect carries no bot_id of its
// own, so it must be embedded here at BuildUserAuthorizeURL time.
BotID int `json:"b"`
ChannelName string `json:"c"` // logging only — BotID is the actual routing key
Domain string `json:"d"`
DialogID string `json:"dlg"`
ExpiresAt int64 `json:"exp"` // unix seconds
}
// encodeOAuthState serializes payload to base64url(json) + "." + hex(HMAC-SHA256).
// key comes from crypto.DeriveKey(GOCLAW_ENCRYPTION_KEY) — same key already used
// for AES-256-GCM elsewhere in this codebase (no second secret introduced).
func encodeOAuthState(p oauthStatePayload, key []byte) (string, error) {
body, err := json.Marshal(p)
if err != nil {
return "", fmt.Errorf("bitrix24 oauth state: marshal payload: %w", err)
}
encoded := base64.RawURLEncoding.EncodeToString(body)
sig := signOAuthStatePart(encoded, key)
return encoded + "." + hex.EncodeToString(sig), nil
}
// decodeOAuthState verifies the HMAC signature (constant-time) and expiry
// BEFORE returning the payload, so a tampered or expired state never reaches
// a caller that might act on it (e.g. attempt a Bitrix token exchange).
func decodeOAuthState(state string, key []byte) (*oauthStatePayload, error) {
idx := strings.LastIndex(state, ".")
if idx < 0 {
return nil, errors.New("bitrix24 oauth state: malformed (missing signature)")
}
encoded, sigHex := state[:idx], state[idx+1:]
gotSig, err := hex.DecodeString(sigHex)
if err != nil {
return nil, errors.New("bitrix24 oauth state: malformed signature encoding")
}
wantSig := signOAuthStatePart(encoded, key)
if !hmac.Equal(gotSig, wantSig) {
return nil, errors.New("bitrix24 oauth state: signature mismatch")
}
body, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("bitrix24 oauth state: decode payload: %w", err)
}
var p oauthStatePayload
if err := json.Unmarshal(body, &p); err != nil {
return nil, fmt.Errorf("bitrix24 oauth state: unmarshal payload: %w", err)
}
if time.Now().Unix() > p.ExpiresAt {
return nil, errors.New("bitrix24 oauth state: expired")
}
return &p, nil
}
// signOAuthStatePart computes HMAC-SHA256(encodedPayload, key).
func signOAuthStatePart(encodedPayload string, key []byte) []byte {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(encodedPayload))
return mac.Sum(nil)
}
// BuildUserAuthorizeURL builds the Bitrix `/oauth/authorize/` link the DM
// invite (handle.go, sendOAuthInvite) points at.
//
// No `scope` parameter — the portal always grants the app's registered scope
// in full (confirmed against official Bitrix OAuth docs, design.md §9 — no
// way to request a narrower scope per call).
//
// No `redirect_uri` parameter either — Local Apps ignore it. Confirmed
// against live behavior: Bitrix always redirects back to the app's
// registered "Application URL" (handlerPath, /bitrix24/handler,
// webhook.go handleAppPage) regardless of what's passed here. Passing it
// anyway would be dead weight (design.md §12 changelog documents the
// correction) — omitted so a future reader doesn't assume it's honored.
func (c *Channel) BuildUserAuthorizeURL(userID, dialogID string) (string, error) {
portal := c.Portal()
if portal == nil {
return "", errors.New("bitrix24 oauth: portal not available")
}
keyBytes, err := crypto.DeriveKey(c.encKey)
if err != nil {
return "", fmt.Errorf("bitrix24 oauth: derive state key: %w", err)
}
state, err := encodeOAuthState(oauthStatePayload{
UserID: userID,
TenantID: c.TenantID().String(),
BotID: c.BotID(),
ChannelName: c.Name(),
Domain: portal.Domain(),
DialogID: dialogID,
ExpiresAt: time.Now().Add(oauthStateTTL).Unix(),
}, keyBytes)
if err != nil {
return "", err
}
q := url.Values{}
q.Set("client_id", portal.creds.ClientID)
q.Set("state", state)
return "https://" + portal.Domain() + "/oauth/authorize/?" + q.Encode(), nil
}