mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-06-17 07:44:12 +00:00
Custom JWT claims survive token refresh: migration 031 adds the custom-claims column to refresh tokens, the new gateway ClaimsProvider re-resolves claims on refresh, and the serverless invoke path carries them through. Includes refresh-rotation, WS-JWT middleware, and claims-provider test coverage.
179 lines
6.0 KiB
Go
179 lines
6.0 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/DeBrosOfficial/network/pkg/serverless"
|
|
"github.com/DeBrosOfficial/network/pkg/serverless/registry"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Claims-provider hook (bugboard #548/#920).
|
|
//
|
|
// A namespace opts into additive, signed JWT claims by deploying a serverless
|
|
// function with the RESERVED name "auth-claims-provider". At /v1/auth/verify
|
|
// mint time the gateway invokes it (in the namespace's own context, so it can
|
|
// read the namespace's tables) with {"wallet","namespace"} and merges the
|
|
// string→string object it returns into the JWT's custom claims — e.g.
|
|
// {"account_id":"<users.user_id>"} so push devices key on the stable account
|
|
// identity rather than the authenticating wallet.
|
|
//
|
|
// Hard guarantees:
|
|
// - FAIL-OPEN: a missing / slow / erroring / malformed provider yields NO
|
|
// claims; authentication never breaks because a claims function is down.
|
|
// - Reserved claims (sub/iss/aud/iat/nbf/exp/namespace/custom) can never be
|
|
// set by the provider — the gateway controls those.
|
|
// - Bounded: timeout, max claim count, max total size.
|
|
|
|
const (
|
|
// claimsProviderFnName is the reserved function name a namespace deploys to
|
|
// inject additive JWT claims at mint time.
|
|
claimsProviderFnName = "auth-claims-provider"
|
|
// claimsProviderTimeout bounds the provider invocation so a slow/hung
|
|
// function never stalls the auth path past this budget (fail-open after).
|
|
claimsProviderTimeout = 2 * time.Second
|
|
// maxCustomClaims / maxCustomClaimsBytes cap what a provider may inject —
|
|
// JWTs ride in headers, and an unbounded claim blob is a DoS / cost vector.
|
|
maxCustomClaims = 16
|
|
maxCustomClaimsBytes = 4096
|
|
// claimsProviderWarnInterval rate-limits the fail-open WARN so a broken
|
|
// provider doesn't flood the log on every login.
|
|
claimsProviderWarnInterval = 30 * time.Second
|
|
)
|
|
|
|
// reservedClaimKeys can never be injected by a namespace claims provider; the
|
|
// gateway owns these. A provider that returns any of them has them dropped.
|
|
var reservedClaimKeys = map[string]struct{}{
|
|
"sub": {}, "iss": {}, "aud": {}, "iat": {},
|
|
"nbf": {}, "exp": {}, "namespace": {}, "custom": {},
|
|
}
|
|
|
|
// jwtClaimsProvider implements auth.ClaimsResolver by invoking the namespace's
|
|
// reserved auth-claims-provider function.
|
|
type jwtClaimsProvider struct {
|
|
invoker *serverless.Invoker
|
|
logger *zap.Logger
|
|
|
|
mu sync.Mutex
|
|
lastWarnUTC time.Time
|
|
}
|
|
|
|
// newJWTClaimsProvider builds the resolver. A nil invoker disables the hook
|
|
// (ResolveClaims returns nil).
|
|
func newJWTClaimsProvider(invoker *serverless.Invoker, logger *zap.Logger) *jwtClaimsProvider {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &jwtClaimsProvider{invoker: invoker, logger: logger.Named("claims-provider")}
|
|
}
|
|
|
|
// ResolveClaims invokes the namespace's auth-claims-provider and returns the
|
|
// sanitized additive claims, or nil. Never errors (fail-open contract).
|
|
func (p *jwtClaimsProvider) ResolveClaims(ctx context.Context, wallet, namespace string) map[string]string {
|
|
if p.invoker == nil || wallet == "" || namespace == "" {
|
|
return nil
|
|
}
|
|
|
|
input, err := json.Marshal(map[string]string{"wallet": wallet, "namespace": namespace})
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
callCtx, cancel := context.WithTimeout(ctx, claimsProviderTimeout)
|
|
defer cancel()
|
|
|
|
resp, err := p.invoker.Invoke(callCtx, &serverless.InvokeRequest{
|
|
Namespace: namespace,
|
|
FunctionName: claimsProviderFnName,
|
|
Input: input,
|
|
// Gateway-initiated, no end-user caller → system trigger skips the
|
|
// per-caller authorization check.
|
|
TriggerType: serverless.TriggerTypeInternal,
|
|
})
|
|
if err != nil || resp == nil {
|
|
// The namespace simply hasn't deployed the function (registry miss) is
|
|
// the normal no-claims case for most namespaces — stay silent. Any
|
|
// other failure is a real problem worth a rate-limited WARN.
|
|
if !errors.Is(err, registry.ErrFunctionNotFound) {
|
|
p.warnRateLimited("claims provider invoke failed (minting without custom claims)",
|
|
namespace, err)
|
|
}
|
|
return nil
|
|
}
|
|
if resp.Status != serverless.InvocationStatusSuccess {
|
|
p.warnRateLimited("claims provider returned non-success (minting without custom claims)",
|
|
namespace, nil)
|
|
return nil
|
|
}
|
|
|
|
return sanitizeProviderClaims(resp.Output)
|
|
}
|
|
|
|
// sanitizeProviderClaims parses the provider's RAW stdout as a bare JSON object
|
|
// of additive claims (NOT an {ok,result} Ack envelope — per the #976 contract)
|
|
// and returns a safe string→string subset: string values only, reserved keys
|
|
// dropped, bounded count and total size. Any parse failure → nil (fail-open).
|
|
func sanitizeProviderClaims(raw []byte) map[string]string {
|
|
if len(raw) == 0 || len(raw) > maxCustomClaimsBytes {
|
|
return nil
|
|
}
|
|
var obj map[string]any
|
|
if err := json.Unmarshal(raw, &obj); err != nil || len(obj) == 0 {
|
|
return nil
|
|
}
|
|
// Iterate in sorted key order so an over-budget provider payload truncates
|
|
// DETERMINISTICALLY (Go map iteration is randomized) — the same output must
|
|
// always yield the same claims, never a per-login-varying subset.
|
|
keys := make([]string, 0, len(obj))
|
|
for k := range obj {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
out := make(map[string]string, len(obj))
|
|
total := 0
|
|
for _, k := range keys {
|
|
if len(out) >= maxCustomClaims {
|
|
break
|
|
}
|
|
if _, reserved := reservedClaimKeys[k]; reserved {
|
|
continue
|
|
}
|
|
s, ok := obj[k].(string) // string→string contract; non-string values dropped
|
|
if !ok {
|
|
continue
|
|
}
|
|
total += len(k) + len(s)
|
|
if total > maxCustomClaimsBytes {
|
|
break
|
|
}
|
|
out[k] = s
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *jwtClaimsProvider) warnRateLimited(msg, namespace string, err error) {
|
|
p.mu.Lock()
|
|
now := time.Now()
|
|
if now.Sub(p.lastWarnUTC) < claimsProviderWarnInterval {
|
|
p.mu.Unlock()
|
|
return
|
|
}
|
|
p.lastWarnUTC = now
|
|
p.mu.Unlock()
|
|
|
|
fields := []zap.Field{zap.String("namespace", namespace), zap.String("function", claimsProviderFnName)}
|
|
if err != nil {
|
|
fields = append(fields, zap.Error(err))
|
|
}
|
|
p.logger.Warn(msg, fields...)
|
|
}
|