Files

301 lines
8.9 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package runtimecred provides an in-memory credential broker for event bus
// processes. Runtime credentials are never persisted by this package.
package runtimecred
import (
"context"
"errors"
"fmt"
"strings"
"sync"
)
// DefaultMaxTokenBytes bounds credentials accepted over the local IPC
// channel. Access tokens are normally only a few KiB; the larger limit leaves
// ample room for future token formats while avoiding accidental large secret
// allocations.
const DefaultMaxTokenBytes = 64 << 10
var (
ErrEmptyToken = errors.New("runtime credential: token is empty")
ErrTokenTooLarge = errors.New("runtime credential: token exceeds size limit")
ErrCredentialUnavailable = errors.New("runtime credential: no credential resolver is available")
ErrLocalRefreshUnavailable = errors.New("runtime credential: no local refresh callback is available")
ErrRuntimeTokenRejected = &RuntimeTokenRejectedError{}
)
// RuntimeTokenRejectedError means the currently installed runtime token was
// rejected and no newer runtime token is available. It deliberately carries
// no token or server response data so it is safe to surface to users and logs.
type RuntimeTokenRejectedError struct{}
func (*RuntimeTokenRejectedError) Error() string {
return "event runtime token was rejected; retry with a fresh host credential"
}
func (*RuntimeTokenRejectedError) Is(target error) bool {
_, ok := target.(*RuntimeTokenRejectedError)
return ok
}
// GenerationConflictError reports a failed compare-and-swap update.
type GenerationConflictError struct {
Expected uint64
Actual uint64
}
func (e *GenerationConflictError) Error() string {
return fmt.Sprintf("runtime credential: generation conflict (expected %d, actual %d)", e.Expected, e.Actual)
}
// ResolveFunc resolves the existing local OAuth credential when no runtime
// credential has been installed.
type ResolveFunc func(context.Context) (string, error)
// RefreshFunc refreshes a rejected local OAuth credential. It is never called
// after a runtime credential has been installed.
type RefreshFunc func(context.Context, string) (string, error)
type Config struct {
LocalResolve ResolveFunc
LocalRefresh RefreshFunc
RequireSeed bool
// RequireActivation keeps the first installed runtime credential pending
// until Activate is called. Detached buses use it to register the consumer
// before ticket acquisition can emit or fail.
RequireActivation bool
MaxTokenBytes int
}
// Broker holds at most one runtime credential. All state, including the
// credential generation, is process-local and concurrency-safe.
type Broker struct {
localResolve ResolveFunc
localRefresh RefreshFunc
requireSeed bool
requireActivation bool
maxBytes int
mu sync.Mutex
token string
generation uint64
active bool
changed chan struct{}
}
func New(cfg Config) *Broker {
maxBytes := cfg.MaxTokenBytes
if maxBytes <= 0 {
maxBytes = DefaultMaxTokenBytes
}
return &Broker{
localResolve: cfg.LocalResolve,
localRefresh: cfg.LocalRefresh,
requireSeed: cfg.RequireSeed,
requireActivation: cfg.RequireActivation,
maxBytes: maxBytes,
active: !cfg.RequireActivation,
changed: make(chan struct{}),
}
}
// Generation returns the current runtime credential generation. Generation 0
// means that no runtime credential has been installed yet.
func (b *Broker) Generation() uint64 {
if b == nil {
return 0
}
b.mu.Lock()
defer b.mu.Unlock()
return b.generation
}
// Update atomically installs token when expectedGeneration matches the
// current generation. Reinstalling the same token is idempotent, including
// when another concurrent writer already installed it.
func (b *Broker) Update(expectedGeneration uint64, token string) (uint64, error) {
if b == nil {
return 0, ErrCredentialUnavailable
}
normalized, err := b.validate(token)
if err != nil {
return b.Generation(), err
}
b.mu.Lock()
defer b.mu.Unlock()
if b.token == normalized {
return b.generation, nil
}
if expectedGeneration != b.generation {
return b.generation, &GenerationConflictError{Expected: expectedGeneration, Actual: b.generation}
}
b.token = normalized
b.generation++
if b.active {
b.signalChangedLocked()
}
return b.generation, nil
}
// Activate publishes a pending first runtime credential after the bus has
// registered the initiating consumer. It is an idempotent generation-checked
// no-op for brokers that do not require activation.
func (b *Broker) Activate(expectedGeneration uint64) (uint64, error) {
if b == nil {
return 0, ErrCredentialUnavailable
}
b.mu.Lock()
defer b.mu.Unlock()
if expectedGeneration != b.generation {
return b.generation, &GenerationConflictError{Expected: expectedGeneration, Actual: b.generation}
}
if b.token == "" {
return b.generation, ErrCredentialUnavailable
}
if b.active {
return b.generation, nil
}
b.active = true
b.signalChangedLocked()
return b.generation, nil
}
// Resolve returns the runtime credential when installed. In RequireSeed mode
// it waits until Update installs one; otherwise it preserves the existing
// local resolver behavior until a runtime credential arrives.
func (b *Broker) Resolve(ctx context.Context) (string, error) {
if b == nil {
return "", ErrCredentialUnavailable
}
for {
token, wait, requireSeed, active := b.snapshot()
if token != "" && active {
return token, nil
}
if requireSeed || (token != "" && !active) {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-wait:
continue
}
}
if b.localResolve == nil {
return "", ErrCredentialUnavailable
}
local, err := b.localResolve(ctx)
if err != nil {
return "", err
}
if runtime, _, _, runtimeActive := b.snapshot(); runtime != "" && runtimeActive {
return runtime, nil
}
return validateResolved(local)
}
}
// RefreshRejected returns a newer runtime token if one was installed after
// rejectedToken was used. If the installed runtime token itself was rejected,
// it returns RuntimeTokenRejectedError and never invokes local OAuth refresh.
func (b *Broker) RefreshRejected(ctx context.Context, rejectedToken string) (string, error) {
if b == nil {
return "", ErrCredentialUnavailable
}
for {
token, wait, requireSeed, active := b.snapshot()
if token != "" && active {
if token != rejectedToken {
return token, nil
}
return "", &RuntimeTokenRejectedError{}
}
if requireSeed || (token != "" && !active) {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-wait:
continue
}
}
if b.localRefresh == nil {
return "", ErrLocalRefreshUnavailable
}
refreshed, err := b.localRefresh(ctx, rejectedToken)
if err != nil {
return "", err
}
if runtime, _, _, runtimeActive := b.snapshot(); runtime != "" && runtimeActive {
if runtime != rejectedToken {
return runtime, nil
}
return "", &RuntimeTokenRejectedError{}
}
return validateResolved(refreshed)
}
}
// ClassifyRejectedAfterRetry is called when a token returned by
// RefreshRejected was itself rejected. When a still newer runtime credential
// is already installed, superseded is true so the source may reconnect and
// resolve that generation without a second in-attempt retry. When the rejected
// token is still current, the fixed typed rejection is returned. With no
// runtime credential installed it preserves local OAuth behavior by returning
// (false, nil).
func (b *Broker) ClassifyRejectedAfterRetry(rejectedToken string) (superseded bool, err error) {
if b == nil {
return false, ErrCredentialUnavailable
}
token, _, _, active := b.snapshot()
if token == "" || !active {
return false, nil
}
if token != strings.TrimSpace(rejectedToken) {
return true, nil
}
return false, &RuntimeTokenRejectedError{}
}
func (b *Broker) snapshot() (string, <-chan struct{}, bool, bool) {
b.mu.Lock()
defer b.mu.Unlock()
return b.token, b.changed, b.requireSeed, b.active
}
func (b *Broker) signalChangedLocked() {
close(b.changed)
b.changed = make(chan struct{})
}
func (b *Broker) validate(token string) (string, error) {
normalized := strings.TrimSpace(token)
if normalized == "" {
return "", ErrEmptyToken
}
if len(normalized) > b.maxBytes {
return "", ErrTokenTooLarge
}
return normalized, nil
}
func validateResolved(token string) (string, error) {
token = strings.TrimSpace(token)
if token == "" {
return "", ErrCredentialUnavailable
}
return token, nil
}