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":""} 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...) }