Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions authbridge/authlib/auth/actor_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package auth

import (
"context"
"sync"
"time"
)

// CachedActorTokenSource wraps an ActorTokenSource with a single-entry cache.
// The cached token is refreshed 30 seconds before expiry, matching the
// old go-processor's actorTokenCache behavior.
type CachedActorTokenSource struct {
source ActorTokenSource
mu sync.RWMutex
token string
expiresAt time.Time
ttlBuffer time.Duration
}

// NewCachedActorTokenSource wraps the given source with caching.
func NewCachedActorTokenSource(source ActorTokenSource) *CachedActorTokenSource {
return &CachedActorTokenSource{
source: source,
ttlBuffer: 30 * time.Second,
}
}

// FetchToken returns a cached actor token, refreshing if expired or near expiry.
func (c *CachedActorTokenSource) FetchToken(ctx context.Context) (string, error) {
c.mu.RLock()
if c.token != "" && time.Now().Add(c.ttlBuffer).Before(c.expiresAt) {
token := c.token
c.mu.RUnlock()
return token, nil
}
c.mu.RUnlock()

c.mu.Lock()
defer c.mu.Unlock()

// Double-check after acquiring write lock
if c.token != "" && time.Now().Add(c.ttlBuffer).Before(c.expiresAt) {
return c.token, nil
}

token, err := c.source(ctx)
if err != nil {
return "", err
}
c.token = token
// Always reset expiry on refresh. Default TTL of 5 minutes;
// SetTTL can override with the actual expires_in from the grant.
c.expiresAt = time.Now().Add(5 * time.Minute)
return token, nil
}

// SetTTL updates the cache expiry based on the token's expires_in value.
// TODO: Wire from client-credentials response in HandleOutbound when actor token
// caching is enabled. Currently unused — the 5-minute default applies.
func (c *CachedActorTokenSource) SetTTL(expiresIn time.Duration) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: SetTTL() is defined but never called anywhere in this PR. The comment on line 58 says "The actual TTL is set via SetTTL after the client-credentials grant" but no caller does this. The cache will use the hardcoded 5-minute default forever. If this is wired in a future PR, a brief note (e.g., // TODO(phase4): wire SetTTL from client-credentials response) would clarify intent. If it's already dead code, consider removing it until needed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IsZero bug was fixed in the PR #311 review round (commit 8cf07a1). Added a TODO comment on SetTTL clarifying it is not yet wired — will be connected when actor token caching is enabled in a future PR.

c.mu.Lock()
defer c.mu.Unlock()
if expiresIn < 60*time.Second {
expiresIn = 60 * time.Second
}
c.expiresAt = time.Now().Add(expiresIn)
}
261 changes: 261 additions & 0 deletions authbridge/authlib/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
package auth

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Excellent architecture throughout this PR. The clean separation between a pure library (authlib — no gRPC/Envoy deps) and thin protocol translators (listeners at 50-175 lines each) is exactly right. Fail-closed default (nil verifier → deny), 1MB response body limit, case-insensitive bearer extraction (RFC 7235), and 73 tests with good edge case coverage. Well done. 👏


import (
"context"
"log/slog"
"net/http"
"strings"
"time"

"github.com/kagenti/kagenti-extensions/authbridge/authlib/bypass"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/cache"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/exchange"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/routing"
"github.com/kagenti/kagenti-extensions/authbridge/authlib/validation"
)

// IdentityConfig holds the agent's identity for audience validation and token exchange.
type IdentityConfig struct {
ClientID string // agent's OAuth client ID
Audience string // expected inbound JWT audience (usually same as ClientID)
}

// ActorTokenSource provides actor tokens for RFC 8693 Section 4.1 act claim chaining.
// Returns ("", nil) when no actor token is available.
type ActorTokenSource func(ctx context.Context) (string, error)

// AudienceDeriver derives a target audience from a request host.
// Used by waypoint mode to auto-derive audience from the destination service name.
// Returns "" if no derivation is possible (falls back to route config).
type AudienceDeriver func(host string) string

// Config holds the resolved dependencies for the auth layer.
type Config struct {
Verifier validation.Verifier
Exchanger *exchange.Client
Cache *cache.Cache
Bypass *bypass.Matcher
Router *routing.Router
Identity IdentityConfig
NoTokenPolicy string // NoTokenClientCredentials, NoTokenAllow, or NoTokenDeny
ActorTokenSource ActorTokenSource // optional, for act claim chaining
AudienceDeriver AudienceDeriver // optional, derives audience from host (waypoint mode)
Logger *slog.Logger
}

// Auth composes authlib building blocks into inbound validation and outbound exchange.
type Auth struct {
verifier validation.Verifier
exchanger *exchange.Client
cache *cache.Cache
bypass *bypass.Matcher
router *routing.Router
identity IdentityConfig
noTokenPolicy string
actorTokenSource ActorTokenSource
audienceDeriver AudienceDeriver
log *slog.Logger
}

// New creates an Auth instance from resolved configuration.
func New(cfg Config) *Auth {
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &Auth{
verifier: cfg.Verifier,
exchanger: cfg.Exchanger,
cache: cfg.Cache,
bypass: cfg.Bypass,
router: cfg.Router,
identity: cfg.Identity,
noTokenPolicy: cfg.NoTokenPolicy,
actorTokenSource: cfg.ActorTokenSource,
audienceDeriver: cfg.AudienceDeriver,
log: logger,
}
}

// HandleInbound validates an inbound request's JWT token.
// audience overrides the default expected audience when non-empty. This supports
// waypoint mode where audience is derived per-request from the destination host.
// For envoy-sidecar and proxy-sidecar modes, pass "" to use the configured default.
func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience string) *InboundResult {
// 1. Bypass check
if a.bypass != nil && a.bypass.Match(path) {
a.log.Debug("bypass path matched", "path", path)
return &InboundResult{Action: ActionAllow}
}

// 2. Extract bearer token
if authHeader == "" {
return &InboundResult{
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "missing Authorization header",
}
}
token := extractBearer(authHeader)
if token == "" {
return &InboundResult{
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "invalid Authorization header format",
}
}

// 3. Validate JWT
if a.verifier == nil {
return &InboundResult{
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "inbound validation not configured",
}
}
if audience == "" {
audience = a.identity.Audience
}
claims, err := a.verifier.Verify(ctx, token, audience)
if err != nil {
// Log full error server-side; return generic message to client
// to avoid leaking issuer URL, audience, or algorithm details.
a.log.Info("JWT validation failed", "error", err)
return &InboundResult{
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "token validation failed",
}
}

// 4. Allow with claims
a.log.Debug("JWT validated", "subject", claims.Subject, "client_id", claims.ClientID)
return &InboundResult{Action: ActionAllow, Claims: claims}
}

// HandleOutbound processes an outbound request, performing token exchange if needed.
func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *OutboundResult {
// 1. Resolve route
var resolved *routing.ResolvedRoute
if a.router != nil {
resolved = a.router.Resolve(host)
}

// 2. Passthrough
if resolved == nil {
return &OutboundResult{Action: ActionAllow}
}
if resolved.Passthrough {
a.log.Debug("passthrough route", "host", host)
return &OutboundResult{Action: ActionAllow}
}

// 3. Determine audience/scopes
audience := resolved.Audience
scopes := resolved.Scopes

// If no audience from route and deriver is set, derive from host (waypoint pattern)
if audience == "" && a.audienceDeriver != nil {
audience = a.audienceDeriver(host)
}

// 4. Extract bearer token
subjectToken := extractBearer(authHeader)

if subjectToken == "" {
// No token — apply no-token policy
return a.handleNoToken(ctx, audience, scopes)
}

// 5. Cache check
if a.cache != nil {
if cached, ok := a.cache.Get(subjectToken, audience); ok {
a.log.Debug("cache hit", "host", host, "audience", audience)
return &OutboundResult{Action: ActionReplaceToken, Token: cached}
}
}

// 6. Token exchange
if a.exchanger == nil {
a.log.Warn("exchanger not configured, passing through")
return &OutboundResult{Action: ActionAllow}
}

// Obtain actor token for act claim chaining (RFC 8693 Section 4.1)
var actorToken string
if a.actorTokenSource != nil {
var err error
actorToken, err = a.actorTokenSource(ctx)
if err != nil {
a.log.Warn("failed to obtain actor token, proceeding without", "error", err)
}
}

resp, err := a.exchanger.Exchange(ctx, &exchange.ExchangeRequest{
SubjectToken: subjectToken,
Audience: audience,
Scopes: scopes,
ActorToken: actorToken,
TokenEndpoint: resolved.TokenEndpoint, // per-route override
})
if err != nil {
a.log.Info("token exchange failed", "host", host, "error", err)
return &OutboundResult{
Action: ActionDeny,
DenyStatus: http.StatusServiceUnavailable,
DenyReason: "token exchange failed",
}
}

// 7. Cache result
if a.cache != nil && resp.ExpiresIn > 0 {
a.cache.Set(subjectToken, audience, resp.AccessToken,
time.Duration(resp.ExpiresIn)*time.Second)
}

a.log.Debug("token exchanged", "host", host, "audience", audience)
return &OutboundResult{Action: ActionReplaceToken, Token: resp.AccessToken}
}

func (a *Auth) handleNoToken(ctx context.Context, audience, scopes string) *OutboundResult {
switch a.noTokenPolicy {
case NoTokenPolicyAllow:
a.log.Debug("no token, policy=allow")
return &OutboundResult{Action: ActionAllow}

case NoTokenPolicyClientCredentials:
if a.exchanger == nil {
return &OutboundResult{
Action: ActionDeny,
DenyStatus: http.StatusServiceUnavailable,
DenyReason: "exchanger not configured for client credentials",
}
}
a.log.Debug("no token, falling back to client_credentials")
resp, err := a.exchanger.ClientCredentials(ctx, audience, scopes)
if err != nil {
a.log.Info("client credentials grant failed", "error", err)
return &OutboundResult{
Action: ActionDeny,
DenyStatus: http.StatusServiceUnavailable,
DenyReason: "client credentials token acquisition failed",
}
}
return &OutboundResult{Action: ActionReplaceToken, Token: resp.AccessToken}

default: // NoTokenDeny or unknown
return &OutboundResult{
Action: ActionDeny,
DenyStatus: http.StatusUnauthorized,
DenyReason: "missing Authorization header",
}
}
}

func extractBearer(authHeader string) string {
// RFC 7235: auth scheme is case-insensitive
if len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "bearer ") {
return authHeader[7:]
}
return ""
}
Loading
Loading