package auth import ( "errors" "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/geo-platform/tenant-api/internal/shared/response" ) const authRequestLogContextKey = "auth_request_log_context" type RequestLogContext struct { HeaderPresent bool Scheme string TokenFingerprint string FailureStage string FailureReason string ParseError string TokenSubject string TokenExpiresAt string } func Middleware(jwtMgr *Manager, sessions *SessionStore) gin.HandlerFunc { return func(c *gin.Context) { header := c.GetHeader("Authorization") updateRequestLogContext(c, func(meta *RequestLogContext) { meta.HeaderPresent = strings.TrimSpace(header) != "" meta.Scheme = authorizationScheme(header) }) raw, err := bearerToken(header) if err != nil { updateRequestLogContext(c, func(meta *RequestLogContext) { meta.FailureStage = "read_bearer_token" meta.FailureReason = bearerHeaderFailureReason(header) }) response.Error(c, response.ErrUnauthorized(40101, "missing_bearer_token", "Authorization header is required")) c.Abort() return } updateRequestLogContext(c, func(meta *RequestLogContext) { meta.TokenFingerprint = tokenFingerprint(raw) }) claims, err := jwtMgr.Parse(raw) if err != nil { updateRequestLogContext(c, func(meta *RequestLogContext) { meta.FailureStage = "parse_access_token" meta.FailureReason = accessTokenFailureReason(err) meta.ParseError = err.Error() }) response.Error(c, response.ErrUnauthorized(40102, "invalid_access_token", "token is invalid or expired")) c.Abort() return } updateRequestLogContext(c, func(meta *RequestLogContext) { meta.TokenSubject = strings.TrimSpace(claims.Subject) if claims.ExpiresAt != nil { meta.TokenExpiresAt = claims.ExpiresAt.Time.UTC().Format(time.RFC3339) } }) if claims.Subject != "access" { updateRequestLogContext(c, func(meta *RequestLogContext) { meta.FailureStage = "validate_access_subject" meta.FailureReason = "wrong_subject" }) response.Error(c, response.ErrUnauthorized(40102, "invalid_access_token", "token is invalid or expired")) c.Abort() return } revoked, _ := sessions.IsBlacklisted(c.Request.Context(), claims.ID) if revoked { updateRequestLogContext(c, func(meta *RequestLogContext) { meta.FailureStage = "check_blacklist" meta.FailureReason = "blacklisted" }) response.Error(c, response.ErrUnauthorized(40103, "token_revoked", "token has been logged out")) c.Abort() return } actor := Actor{ UserID: claims.UserID, TenantID: claims.TenantID, PrimaryWorkspaceID: claims.PrimaryWorkspaceID, Role: claims.Role, } c.Request = c.Request.WithContext(WithActor(c.Request.Context(), actor)) c.Next() } } func bearerToken(header string) (string, error) { parts := strings.SplitN(header, " ", 2) if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { return "", response.ErrUnauthorized(40101, "missing_bearer_token", "Authorization header is required") } return parts[1], nil } func RequestLogContextFromGin(c *gin.Context) (RequestLogContext, bool) { if c == nil { return RequestLogContext{}, false } value, ok := c.Get(authRequestLogContextKey) if !ok || value == nil { return RequestLogContext{}, false } meta, ok := value.(*RequestLogContext) if !ok || meta == nil { return RequestLogContext{}, false } return *meta, true } func updateRequestLogContext(c *gin.Context, apply func(*RequestLogContext)) { if c == nil || apply == nil { return } value, ok := c.Get(authRequestLogContextKey) if ok { if meta, ok := value.(*RequestLogContext); ok && meta != nil { apply(meta) return } } meta := &RequestLogContext{} apply(meta) c.Set(authRequestLogContextKey, meta) } func authorizationScheme(header string) string { parts := strings.Fields(strings.TrimSpace(header)) if len(parts) == 0 { return "" } return parts[0] } func bearerHeaderFailureReason(header string) string { trimmed := strings.TrimSpace(header) if trimmed == "" { return "missing_authorization_header" } parts := strings.Fields(trimmed) if len(parts) == 0 { return "missing_authorization_header" } if !strings.EqualFold(parts[0], "bearer") { return "invalid_authorization_scheme" } if len(parts) == 1 { return "missing_token_after_bearer" } return "malformed_authorization_header" } func accessTokenFailureReason(err error) string { switch { case err == nil: return "" case errors.Is(err, jwt.ErrTokenExpired): return "expired" case errors.Is(err, jwt.ErrTokenNotValidYet): return "not_valid_yet" case errors.Is(err, jwt.ErrTokenMalformed): return "malformed" case errors.Is(err, jwt.ErrTokenSignatureInvalid): return "bad_signature" case errors.Is(err, jwt.ErrTokenUnverifiable): return "unverifiable" case errors.Is(err, jwt.ErrTokenInvalidClaims): return "invalid_claims" default: return "parse_failed" } } func tokenFingerprint(raw string) string { if strings.TrimSpace(raw) == "" { return "" } hashed := HashToken(raw) if len(hashed) > 16 { return hashed[:16] } return hashed }