@@ -2,9 +2,11 @@ package auth
22
33import (
44 "context"
5+ "encoding/json"
56 "log/slog"
67 "net/http"
78 "strings"
9+ "sync"
810 "sync/atomic"
911 "time"
1012
@@ -56,6 +58,171 @@ type Auth struct {
5658 actorTokenSource ActorTokenSource
5759 audienceDeriver AudienceDeriver
5860 log * slog.Logger
61+ Stats * Stats
62+ }
63+
64+ // Stats holds statistics for validation and exchange.
65+ // The current implementation keeps approvals/denials/exchange as counters.
66+ type Stats struct {
67+ mu sync.Mutex // protects the following fields
68+ inboundApprovals map [InboundApprovalReason ]int
69+ inboundDenials map [InboundDenialReason ]int
70+ outboundApprovals map [OutboundApprovalReason ]int
71+ outboundDenials map [OutboundDenialReason ]int
72+ outboundReplaceTokens map [OutboundReplaceTokenReason ]int
73+ }
74+
75+ // InboundDenialReason enumerates the reasons for inbound validation failure.
76+ type InboundDenialReason int
77+
78+ const (
79+ DENY_NO_HEADER InboundDenialReason = iota
80+ DENY_MALFORMED_HEADER
81+ DENY_VALIDATOR_MISSING
82+ DENY_JWT_FAILED
83+ )
84+
85+ // InboundApprovalReason enumerates the rationale for inbound validation success.
86+ type InboundApprovalReason int
87+
88+ const (
89+ APPROVE_PASSTHROUGH InboundApprovalReason = iota
90+ APPROVE_AUTHORIZED
91+ )
92+
93+ // OutboundApprovalReason enumerates the rationale for outbound validation success.
94+ type OutboundApprovalReason int
95+
96+ const (
97+ OUTBOUND_NO_MATCHING_ROUTE OutboundApprovalReason = iota
98+ OUTBOUND_NO_EXCHANGER
99+ OUTBOUND_PASSTHROUGH
100+ OUTBOUND_NO_TOKEN_POLICY
101+ )
102+
103+ // OutboundDenialReason enumerates the reasons for outbound denial.
104+ type OutboundDenialReason int
105+
106+ const (
107+ OUTBOUND_CREDS_REQUESTED_NO_EXCHANGER OutboundDenialReason = iota
108+ OUTBOUND_CREDENTIALS_GRANT_FAILURE
109+ OUTBOUND_NO_TOKEN
110+ OUTBOUND_TOKEN_EXCHANGE_FAILED
111+ )
112+
113+ // OutboundReplaceTokenReason enumerates the reasons for outbound token exchange.
114+ type OutboundReplaceTokenReason int
115+
116+ const (
117+ OUTBOUND_ACTION_REPLACE_TOKEN OutboundReplaceTokenReason = iota
118+ OUTBOUND_ACTION_CACHE_HIT
119+ )
120+
121+ func (r InboundDenialReason ) String () string {
122+ switch r {
123+ case DENY_NO_HEADER :
124+ return "no_header"
125+ case DENY_MALFORMED_HEADER :
126+ return "malformed_header"
127+ case DENY_VALIDATOR_MISSING :
128+ return "validator_missing"
129+ case DENY_JWT_FAILED :
130+ return "jwt_failed"
131+ default :
132+ return "unknown"
133+ }
134+ }
135+
136+ func (r InboundApprovalReason ) String () string {
137+ switch r {
138+ case APPROVE_PASSTHROUGH :
139+ return "passthrough"
140+ case APPROVE_AUTHORIZED :
141+ return "authorized"
142+ default :
143+ return "unknown"
144+ }
145+ }
146+
147+ func (r OutboundApprovalReason ) String () string {
148+ switch r {
149+ case OUTBOUND_NO_MATCHING_ROUTE :
150+ return "no_matching_route"
151+ case OUTBOUND_NO_EXCHANGER :
152+ return "no_exchanger"
153+ case OUTBOUND_PASSTHROUGH :
154+ return "passthrough"
155+ case OUTBOUND_NO_TOKEN_POLICY :
156+ return "no_token_policy"
157+ default :
158+ return "unknown"
159+ }
160+ }
161+
162+ func (r OutboundDenialReason ) String () string {
163+ switch r {
164+ case OUTBOUND_CREDS_REQUESTED_NO_EXCHANGER :
165+ return "creds_requested_no_exchanger"
166+ case OUTBOUND_CREDENTIALS_GRANT_FAILURE :
167+ return "credentials_grant_failure"
168+ case OUTBOUND_NO_TOKEN :
169+ return "no_token"
170+ case OUTBOUND_TOKEN_EXCHANGE_FAILED :
171+ return "token_exchange_failed"
172+ default :
173+ return "unknown"
174+ }
175+ }
176+
177+ func (r OutboundReplaceTokenReason ) String () string {
178+ switch r {
179+ case OUTBOUND_ACTION_REPLACE_TOKEN :
180+ return "replace_token"
181+ case OUTBOUND_ACTION_CACHE_HIT :
182+ return "cache_hit"
183+ default :
184+ return "unknown"
185+ }
186+ }
187+
188+ func (s * Stats ) MarshalJSON () ([]byte , error ) {
189+ s .mu .Lock ()
190+ defer s .mu .Unlock ()
191+
192+ inApprovals := make (map [string ]int , len (s .inboundApprovals ))
193+ for k , v := range s .inboundApprovals {
194+ inApprovals [k .String ()] = v
195+ }
196+ inDenials := make (map [string ]int , len (s .inboundDenials ))
197+ for k , v := range s .inboundDenials {
198+ inDenials [k .String ()] = v
199+ }
200+ outApprovals := make (map [string ]int , len (s .outboundApprovals ))
201+ for k , v := range s .outboundApprovals {
202+ outApprovals [k .String ()] = v
203+ }
204+ outDenials := make (map [string ]int , len (s .outboundDenials ))
205+ for k , v := range s .outboundDenials {
206+ outDenials [k .String ()] = v
207+ }
208+ outReplaceTokens := make (map [string ]int , len (s .outboundReplaceTokens ))
209+ for k , v := range s .outboundReplaceTokens {
210+ outReplaceTokens [k .String ()] = v
211+ }
212+
213+ return json .Marshal (struct {
214+ InboundApprovals map [string ]int `json:"inbound_approvals"`
215+ InboundDenials map [string ]int `json:"inbound_denials"`
216+ OutboundApprovals map [string ]int `json:"outbound_approvals"`
217+ OutboundDenials map [string ]int `json:"outbound_denials"`
218+ OutboundReplaceTokens map [string ]int `json:"outbound_replace_tokens"`
219+ }{
220+ InboundApprovals : inApprovals ,
221+ InboundDenials : inDenials ,
222+ OutboundApprovals : outApprovals ,
223+ OutboundDenials : outDenials ,
224+ OutboundReplaceTokens : outReplaceTokens ,
225+ })
59226}
60227
61228// New creates an Auth instance from resolved configuration.
@@ -74,6 +241,7 @@ func New(cfg Config) *Auth {
74241 actorTokenSource : cfg .ActorTokenSource ,
75242 audienceDeriver : cfg .AudienceDeriver ,
76243 log : logger ,
244+ Stats : NewStats (),
77245 }
78246 id := cfg .Identity
79247 a .identity .Store (& id )
@@ -98,12 +266,14 @@ func (a *Auth) UpdateIdentity(id IdentityConfig, clientAuth exchange.ClientAuth)
98266func (a * Auth ) HandleInbound (ctx context.Context , authHeader , path , audience string ) * InboundResult {
99267 // 1. Bypass check
100268 if a .bypass != nil && a .bypass .Match (path ) {
269+ a .IncInboundApprove (APPROVE_PASSTHROUGH )
101270 a .log .Debug ("bypass path matched" , "path" , path )
102271 return & InboundResult {Action : ActionAllow }
103272 }
104273
105274 // 2. Extract bearer token
106275 if authHeader == "" {
276+ a .IncInboundDeny (DENY_NO_HEADER )
107277 a .log .Debug ("inbound denied: no Authorization header" , "path" , path )
108278 return & InboundResult {
109279 Action : ActionDeny ,
@@ -113,6 +283,7 @@ func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience str
113283 }
114284 token := extractBearer (authHeader )
115285 if token == "" {
286+ a .IncInboundDeny (DENY_MALFORMED_HEADER )
116287 a .log .Debug ("inbound denied: malformed Authorization header" , "path" , path )
117288 return & InboundResult {
118289 Action : ActionDeny ,
@@ -123,6 +294,7 @@ func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience str
123294
124295 // 3. Validate JWT
125296 if a .verifier == nil {
297+ a .IncInboundDeny (DENY_VALIDATOR_MISSING )
126298 return & InboundResult {
127299 Action : ActionDeny ,
128300 DenyStatus : http .StatusUnauthorized ,
@@ -137,6 +309,7 @@ func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience str
137309 if err != nil {
138310 // Log full error at Info; log detailed context at Debug.
139311 // Generic message returned to client to avoid leaking details.
312+ a .IncInboundDeny (DENY_JWT_FAILED )
140313 a .log .Info ("JWT validation failed" , "error" , err )
141314 a .log .Debug ("JWT validation details" ,
142315 "path" , path ,
@@ -151,6 +324,7 @@ func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience str
151324 }
152325
153326 // 4. Allow with claims
327+ a .IncInboundApprove (APPROVE_AUTHORIZED )
154328 a .log .Info ("inbound authorized" ,
155329 "subject" , claims .Subject , "clientID" , claims .ClientID )
156330 a .log .Debug ("inbound authorized details" ,
@@ -170,10 +344,12 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out
170344
171345 // 2. Passthrough
172346 if resolved == nil {
347+ a .IncOutboundApprove (OUTBOUND_NO_MATCHING_ROUTE )
173348 a .log .Info ("outbound passthrough" , "host" , host , "reason" , "no matching route" )
174349 return & OutboundResult {Action : ActionAllow }
175350 }
176351 if resolved .Passthrough {
352+ a .IncOutboundApprove (OUTBOUND_PASSTHROUGH )
177353 a .log .Info ("outbound passthrough" , "host" , host , "reason" , "route action" )
178354 return & OutboundResult {Action : ActionAllow }
179355 }
@@ -205,13 +381,15 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out
205381 // 5. Cache check
206382 if a .cache != nil {
207383 if cached , ok := a .cache .Get (subjectToken , audience ); ok {
384+ a .IncOutboundReplaceToken (OUTBOUND_ACTION_CACHE_HIT )
208385 a .log .Debug ("outbound cache hit" , "host" , host , "audience" , audience )
209386 return & OutboundResult {Action : ActionReplaceToken , Token : cached }
210387 }
211388 }
212389
213390 // 6. Token exchange
214391 if a .exchanger == nil {
392+ a .IncOutboundApprove (OUTBOUND_NO_EXCHANGER )
215393 a .log .Warn ("exchanger not configured, passing through" ,
216394 "host" , host , "audience" , audience )
217395 return & OutboundResult {Action : ActionAllow }
@@ -236,6 +414,7 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out
236414 TokenEndpoint : resolved .TokenEndpoint , // per-route override
237415 })
238416 if err != nil {
417+ a .IncOutboundDeny (OUTBOUND_TOKEN_EXCHANGE_FAILED )
239418 a .log .Info ("token exchange failed" , "host" , host , "error" , err )
240419 a .log .Debug ("token exchange failure details" ,
241420 "host" , host ,
@@ -257,6 +436,7 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out
257436 time .Duration (resp .ExpiresIn )* time .Second )
258437 }
259438
439+ a .IncOutboundReplaceToken (OUTBOUND_ACTION_REPLACE_TOKEN )
260440 a .log .Info ("outbound token exchanged" , "host" , host , "audience" , audience )
261441 a .log .Debug ("outbound exchange details" ,
262442 "host" , host , "audience" , audience , "expiresIn" , resp .ExpiresIn )
@@ -266,11 +446,13 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out
266446func (a * Auth ) handleNoToken (ctx context.Context , audience , scopes string ) * OutboundResult {
267447 switch a .noTokenPolicy {
268448 case NoTokenPolicyAllow :
449+ a .IncOutboundApprove (OUTBOUND_NO_TOKEN_POLICY )
269450 a .log .Debug ("no token, policy=allow" )
270451 return & OutboundResult {Action : ActionAllow }
271452
272453 case NoTokenPolicyClientCredentials :
273454 if a .exchanger == nil {
455+ a .IncOutboundDeny (OUTBOUND_CREDS_REQUESTED_NO_EXCHANGER )
274456 a .log .Debug ("no token, client_credentials requested but exchanger not configured" ,
275457 "audience" , audience )
276458 return & OutboundResult {
@@ -283,6 +465,7 @@ func (a *Auth) handleNoToken(ctx context.Context, audience, scopes string) *Outb
283465 "audience" , audience , "scopes" , scopes )
284466 resp , err := a .exchanger .ClientCredentials (ctx , audience , scopes )
285467 if err != nil {
468+ a .IncOutboundDeny (OUTBOUND_CREDENTIALS_GRANT_FAILURE )
286469 a .log .Info ("client credentials grant failed" , "error" , err )
287470 a .log .Debug ("client credentials failure details" ,
288471 "audience" , audience , "scopes" , scopes , "error" , err )
@@ -292,9 +475,11 @@ func (a *Auth) handleNoToken(ctx context.Context, audience, scopes string) *Outb
292475 DenyReason : "client credentials token acquisition failed" ,
293476 }
294477 }
478+ a .IncOutboundReplaceToken (OUTBOUND_ACTION_REPLACE_TOKEN )
295479 return & OutboundResult {Action : ActionReplaceToken , Token : resp .AccessToken }
296480
297481 default : // NoTokenDeny or unknown
482+ a .IncOutboundDeny (OUTBOUND_NO_TOKEN )
298483 a .log .Debug ("no token, policy denies request" ,
299484 "policy" , a .noTokenPolicy , "audience" , audience )
300485 return & OutboundResult {
@@ -312,3 +497,47 @@ func extractBearer(authHeader string) string {
312497 }
313498 return ""
314499}
500+
501+ func NewStats () * Stats {
502+ return & Stats {
503+ inboundApprovals : make (map [InboundApprovalReason ]int ),
504+ inboundDenials : make (map [InboundDenialReason ]int ),
505+ outboundApprovals : make (map [OutboundApprovalReason ]int ),
506+ outboundDenials : make (map [OutboundDenialReason ]int ),
507+ outboundReplaceTokens : make (map [OutboundReplaceTokenReason ]int ),
508+ }
509+ }
510+
511+ // IncInboundApprove records a new approval (for statistics)
512+ func (a * Auth ) IncInboundApprove (reason InboundApprovalReason ) {
513+ a .Stats .mu .Lock ()
514+ a .Stats .inboundApprovals [reason ]++
515+ a .Stats .mu .Unlock ()
516+ }
517+
518+ // IncInboundDeny records a new denial (for statistics)
519+ func (a * Auth ) IncInboundDeny (reason InboundDenialReason ) {
520+ a .Stats .mu .Lock ()
521+ a .Stats .inboundDenials [reason ]++
522+ a .Stats .mu .Unlock ()
523+ }
524+
525+ // IncOutboundApprove records a new approval (for statistics)
526+ func (a * Auth ) IncOutboundApprove (reason OutboundApprovalReason ) {
527+ a .Stats .mu .Lock ()
528+ a .Stats .outboundApprovals [reason ]++
529+ a .Stats .mu .Unlock ()
530+ }
531+
532+ // IncOutboundDeny records a new denial (for statistics)
533+ func (a * Auth ) IncOutboundDeny (reason OutboundDenialReason ) {
534+ a .Stats .mu .Lock ()
535+ a .Stats .outboundDenials [reason ]++
536+ a .Stats .mu .Unlock ()
537+ }
538+
539+ func (a * Auth ) IncOutboundReplaceToken (reason OutboundReplaceTokenReason ) {
540+ a .Stats .mu .Lock ()
541+ a .Stats .outboundReplaceTokens [reason ]++
542+ a .Stats .mu .Unlock ()
543+ }
0 commit comments