301 lines
8.9 KiB
Go
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
|
|
}
|