mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 04:14:56 +00:00
136 lines
4.9 KiB
Go
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
|
|
}
|