-
Notifications
You must be signed in to change notification settings - Fork 38
✨ Add listeners and single binary — four modes, one codebase (Phase 3) #312
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b7f79e4
b2b8a2d
d832977
03573bc
47d24e3
0eaf70c
8cf07a1
faf9a83
398f110
b2ca966
02a7493
9c876c2
957d5bc
00ff761
2bd14c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) { | ||
| c.mu.Lock() | ||
| defer c.mu.Unlock() | ||
| if expiresIn < 60*time.Second { | ||
| expiresIn = 60 * time.Second | ||
| } | ||
| c.expiresAt = time.Now().Add(expiresIn) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,261 @@ | ||
| package auth | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( |
||
|
|
||
| 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 "" | ||
| } | ||
There was a problem hiding this comment.
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.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
IsZerobug was fixed in the PR #311 review round (commit 8cf07a1). Added a TODO comment onSetTTLclarifying it is not yet wired — will be connected when actor token caching is enabled in a future PR.