mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Canonical prefixes: pk- publishable key (frontend, read-only) sk- secret key (backend, full access) hk- hanzo key (IAM user key, legacy) hi- hanzo insights (analytics events) ha- hanzo analytics (lightweight web analytics) hz- hanzo widget (restricted chat/embed) Added IsAnalyticsKey() and IsWidgetKey() helpers. Removed pk_/sk_ underscore variants — one way only.
342 lines
9.1 KiB
Go
342 lines
9.1 KiB
Go
package platform
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// IAMUser represents an authenticated user from Hanzo IAM.
|
|
type IAMUser struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
OrgIDs []string `json:"orgIds"`
|
|
}
|
|
|
|
// tokenCacheEntry holds a cached IAM validation result.
|
|
type tokenCacheEntry struct {
|
|
user *IAMUser
|
|
expires time.Time
|
|
}
|
|
|
|
const tokenCacheTTL = 5 * time.Minute
|
|
|
|
// ValidateIAMToken validates a bearer token against the IAM userinfo endpoint
|
|
// at config.IAMEndpoint/api/userinfo.
|
|
//
|
|
// This is a convenience function that creates a one-off HTTP request. For
|
|
// production use with caching, use the IAMClient returned by NewIAMClient.
|
|
func ValidateIAMToken(token string, config PlatformConfig) (*IAMUser, error) {
|
|
endpoint := config.IAMEndpoint
|
|
if endpoint == "" {
|
|
endpoint = "https://hanzo.id"
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", endpoint+"/api/userinfo", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("iam: create request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("iam: request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return nil, fmt.Errorf("iam: userinfo returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var user IAMUser
|
|
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
|
return nil, fmt.Errorf("iam: decode userinfo: %w", err)
|
|
}
|
|
if user.ID == "" {
|
|
return nil, fmt.Errorf("iam: userinfo response missing user id")
|
|
}
|
|
|
|
return &user, nil
|
|
}
|
|
|
|
// ExchangeOAuth2Token exchanges an authorization code for tokens using the
|
|
// IAM OAuth2 token endpoint.
|
|
func ExchangeOAuth2Token(code, redirectURI string, config PlatformConfig) (accessToken, refreshToken string, err error) {
|
|
endpoint := config.IAMEndpoint
|
|
if endpoint == "" {
|
|
endpoint = "https://hanzo.id"
|
|
}
|
|
|
|
data := url.Values{
|
|
"grant_type": {"authorization_code"},
|
|
"code": {code},
|
|
"redirect_uri": {redirectURI},
|
|
"client_id": {config.IAMClientID},
|
|
"client_secret": {config.IAMClientSecret},
|
|
}
|
|
|
|
resp, err := http.PostForm(endpoint+"/oauth/token", data)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("iam: token exchange request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return "", "", fmt.Errorf("iam: token exchange returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", "", fmt.Errorf("iam: decode token response: %w", err)
|
|
}
|
|
|
|
return result.AccessToken, result.RefreshToken, nil
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// IAMClient with caching
|
|
// --------------------------------------------------------------------------
|
|
|
|
// IAMClient handles authentication against Hanzo IAM with token caching.
|
|
type IAMClient struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
|
|
mu sync.RWMutex
|
|
cache map[string]*tokenCacheEntry
|
|
}
|
|
|
|
// NewIAMClient creates a new IAM client pointed at the given base URL.
|
|
func NewIAMClient(baseURL string) *IAMClient {
|
|
if baseURL == "" {
|
|
baseURL = "https://hanzo.id"
|
|
}
|
|
// Trim trailing slash.
|
|
baseURL = strings.TrimRight(baseURL, "/")
|
|
|
|
return &IAMClient{
|
|
baseURL: baseURL,
|
|
httpClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
cache: make(map[string]*tokenCacheEntry),
|
|
}
|
|
}
|
|
|
|
// ValidateToken validates a Bearer token against IAM userinfo. Results are
|
|
// cached for 5 minutes.
|
|
func (c *IAMClient) ValidateToken(token string) (*IAMUser, error) {
|
|
c.mu.RLock()
|
|
entry, ok := c.cache[token]
|
|
c.mu.RUnlock()
|
|
|
|
if ok && time.Now().Before(entry.expires) {
|
|
return entry.user, nil
|
|
}
|
|
|
|
user, err := c.fetchUserInfo(token)
|
|
if err != nil {
|
|
c.mu.Lock()
|
|
delete(c.cache, token)
|
|
c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
|
|
c.mu.Lock()
|
|
c.cache[token] = &tokenCacheEntry{
|
|
user: user,
|
|
expires: time.Now().Add(tokenCacheTTL),
|
|
}
|
|
if len(c.cache) > 1000 {
|
|
c.evictExpiredLocked()
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (c *IAMClient) fetchUserInfo(token string) (*IAMUser, error) {
|
|
req, err := http.NewRequest("GET", c.baseURL+"/api/userinfo", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("iam: create request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("iam: request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return nil, fmt.Errorf("iam: userinfo returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var user IAMUser
|
|
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
|
return nil, fmt.Errorf("iam: decode userinfo: %w", err)
|
|
}
|
|
if user.ID == "" {
|
|
return nil, fmt.Errorf("iam: userinfo response missing user id")
|
|
}
|
|
|
|
return &user, nil
|
|
}
|
|
|
|
func (c *IAMClient) evictExpiredLocked() {
|
|
now := time.Now()
|
|
for k, v := range c.cache {
|
|
if now.After(v.expires) {
|
|
delete(c.cache, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// InvalidateToken removes a token from the cache.
|
|
func (c *IAMClient) InvalidateToken(token string) {
|
|
c.mu.Lock()
|
|
delete(c.cache, token)
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// ── API Key Resolution (pk-/sk-/hk- keys managed by IAM) ────────────────
|
|
|
|
// IAMKey represents an API key from IAM's Key table.
|
|
type IAMKey struct {
|
|
Owner string `json:"owner"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"` // Organization, Application, User
|
|
Org string `json:"organization"`
|
|
Application string `json:"application"`
|
|
User string `json:"user"`
|
|
AccessKey string `json:"accessKey"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
// Hanzo key prefix standard (always hyphen, never underscore):
|
|
//
|
|
// pk- publishable key (frontend-safe, read-only API access)
|
|
// sk- secret key (backend-only, full API access)
|
|
// hk- hanzo key (IAM user API key, legacy)
|
|
// hi- hanzo insights (analytics event ingestion)
|
|
// ha- hanzo analytics (lightweight web analytics)
|
|
// hz- hanzo widget (restricted chat/embed key)
|
|
//
|
|
// All managed by IAM. One key store. One prefix convention.
|
|
|
|
// IsPublishableKey returns true if the token has a publishable key prefix.
|
|
func IsPublishableKey(token string) bool {
|
|
return strings.HasPrefix(token, "pk-")
|
|
}
|
|
|
|
// IsSecretKey returns true if the token has a secret key prefix.
|
|
func IsSecretKey(token string) bool {
|
|
return strings.HasPrefix(token, "sk-")
|
|
}
|
|
|
|
// IsAPIKey returns true if the token is any type of IAM API key.
|
|
func IsAPIKey(token string) bool {
|
|
return strings.HasPrefix(token, "hk-") ||
|
|
strings.HasPrefix(token, "pk-") ||
|
|
strings.HasPrefix(token, "sk-")
|
|
}
|
|
|
|
// IsAnalyticsKey returns true if the token is an insights or analytics key.
|
|
func IsAnalyticsKey(token string) bool {
|
|
return strings.HasPrefix(token, "hi-") ||
|
|
strings.HasPrefix(token, "ha-")
|
|
}
|
|
|
|
// IsWidgetKey returns true if the token is a widget embed key.
|
|
func IsWidgetKey(token string) bool {
|
|
return strings.HasPrefix(token, "hz-")
|
|
}
|
|
|
|
// ResolveAPIKey resolves an IAM API key (hk-/pk-/sk-) to user + org context.
|
|
// Uses IAM's GET /api/get-user?accessKey= endpoint. Results cached 5 minutes.
|
|
func (c *IAMClient) ResolveAPIKey(accessKey string) (*IAMUser, error) {
|
|
// Check cache (same cache as JWT tokens)
|
|
c.mu.RLock()
|
|
entry, ok := c.cache[accessKey]
|
|
c.mu.RUnlock()
|
|
|
|
if ok && time.Now().Before(entry.expires) {
|
|
return entry.user, nil
|
|
}
|
|
|
|
user, err := c.fetchUserByKey(accessKey)
|
|
if err != nil {
|
|
c.mu.Lock()
|
|
delete(c.cache, accessKey)
|
|
c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
|
|
c.mu.Lock()
|
|
c.cache[accessKey] = &tokenCacheEntry{
|
|
user: user,
|
|
expires: time.Now().Add(tokenCacheTTL),
|
|
}
|
|
if len(c.cache) > 1000 {
|
|
c.evictExpiredLocked()
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (c *IAMClient) fetchUserByKey(accessKey string) (*IAMUser, error) {
|
|
u := c.baseURL + "/api/get-user?accessKey=" + url.QueryEscape(accessKey)
|
|
|
|
req, err := http.NewRequest("GET", u, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("iam: create key request: %w", err)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("iam: key request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return nil, fmt.Errorf("iam: get-user returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
Data struct {
|
|
Owner string `json:"owner"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
ID string `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("iam: decode key user: %w", err)
|
|
}
|
|
|
|
if result.Data.Name == "" {
|
|
return nil, fmt.Errorf("iam: key resolved to empty user")
|
|
}
|
|
|
|
return &IAMUser{
|
|
ID: result.Data.ID,
|
|
Name: result.Data.Name,
|
|
Email: result.Data.Email,
|
|
OrgIDs: []string{result.Data.Owner},
|
|
}, nil
|
|
}
|