• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
Build has been canceled!

alexferl / zerohttp / 23093061092

14 Mar 2026 05:45PM UTC coverage: 93.164% (+0.5%) from 92.697%
23093061092

push

github

web-flow
feat: add AcceptsJSON and RenderAuto for content negotiation (#105)

* feat: add AcceptsJSON and RenderAuto for content negotiation

- Add AcceptsJSON function to detect JSON-capable clients
- Add RenderAuto method that returns JSON or plain text based on Accept header
- Add tests for both AcceptsJSON and RenderAuto
- Update middleware to use RenderAuto instead of Render

Signed-off-by: alexferl <me@alexferl.com>

* increase coverage

Signed-off-by: alexferl <me@alexferl.com>

---------

Signed-off-by: alexferl <me@alexferl.com>

64 of 67 new or added lines in 17 files covered. (95.52%)

7 existing lines in 2 files now uncovered.

8899 of 9552 relevant lines covered (93.16%)

76.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.29
/middleware/jwt_auth.go
1
// Package middleware provides HTTP middleware for zerohttp.
2
//
3
// # JWT Authentication Middleware
4
//
5
// The JWTAuth middleware provides pluggable JWT authentication. Users bring their own
6
// JWT library by implementing the TokenStore interface.
7
//
8
// Basic usage:
9
//
10
//        app.Use(middleware.JWTAuth(config.JWTAuthConfig{
11
//            TokenStore: myTokenStore,
12
//            RequiredClaims: []string{"sub"},
13
//        }))
14
//
15
// The middleware supports:
16
//   - Custom token extraction (Bearer header, cookies, custom headers)
17
//   - Required claims validation
18
//   - Exempt paths and methods
19
//   - Custom error handling
20
//   - Token refresh handling
21
//
22
// For a zero-dependency option, use the built-in HS256 implementation:
23
//
24
//        cfg := config.JWTAuthConfig{
25
//            TokenStore: middleware.NewHS256TokenStore(secret, opts),
26
//        }
27
//
28
// Security Note: The built-in HS256 implementation uses HMAC-SHA256 symmetric signing.
29
// For production systems requiring asymmetric keys (RS256, ES256, EdDSA), use a
30
// proper JWT library like golang-jwt/jwt or lestrrat-go/jwx.
31
package middleware
32

33
import (
34
        "context"
35
        "encoding/json"
36
        "errors"
37
        "net/http"
38
        "reflect"
39
        "slices"
40
        "strings"
41
        "time"
42

43
        "github.com/alexferl/zerohttp/config"
44
        zconfig "github.com/alexferl/zerohttp/internal/config"
45
        "github.com/alexferl/zerohttp/internal/problem"
46
        "github.com/alexferl/zerohttp/metrics"
47
)
48

49
// JWTAuthContextKey is the context key type for JWT auth
50
type JWTAuthContextKey string
51

52
const (
53
        // JWTClaimsContextKey holds the validated JWT claims
54
        JWTClaimsContextKey JWTAuthContextKey = "jwt_claims"
55
        // JWTErrorContextKey holds the JWT validation error
56
        JWTErrorContextKey JWTAuthContextKey = "jwt_error"
57
        // JWTTokenContextKey holds the raw token string
58
        JWTTokenContextKey JWTAuthContextKey = "jwt_token"
59
)
60

61
// JWTAuthError represents a JWT authentication error with RFC 9457 Problem Details
62
type JWTAuthError struct {
63
        Type   string `json:"type"`
64
        Title  string `json:"title"`
65
        Status int    `json:"status"`
66
        Detail string `json:"detail"`
67
}
68

69
// Error implements the error interface
70
func (e *JWTAuthError) Error() string {
1✔
71
        return e.Detail
1✔
72
}
1✔
73

74
// Common JWT auth errors
75
var (
76
        errMissingToken = &JWTAuthError{
77
                Title:  "Missing Authorization Token",
78
                Status: http.StatusUnauthorized,
79
                Detail: "Request is missing the Authorization header with Bearer token",
80
        }
81
        errInvalidToken = &JWTAuthError{
82
                Title:  "Invalid Token",
83
                Status: http.StatusUnauthorized,
84
                Detail: "The provided token is invalid or has expired",
85
        }
86
        errMissingRequiredClaim = &JWTAuthError{
87
                Title:  "Missing Required Claim",
88
                Status: http.StatusForbidden,
89
                Detail: "Token is missing a required claim",
90
        }
91
        errTokenGeneratorNotConfigured = &JWTAuthError{
92
                Title:  "Token Generator Not Configured",
93
                Status: http.StatusInternalServerError,
94
                Detail: "Token generation is not configured",
95
        }
96
        errTokenStoreNotConfigured = &JWTAuthError{
97
                Title:  "Token Store Not Configured",
98
                Status: http.StatusUnauthorized,
99
                Detail: "JWT authentication is not properly configured",
100
        }
101
)
102

103
// JWTAuth creates JWT authentication middleware
104
func JWTAuth(cfg ...config.JWTAuthConfig) func(http.Handler) http.Handler {
12✔
105
        c := config.DefaultJWTAuthConfig
12✔
106
        if len(cfg) > 0 {
24✔
107
                zconfig.Merge(&c, cfg[0])
12✔
108
        }
12✔
109

110
        if c.TokenExtractor == nil {
12✔
111
                c.TokenExtractor = extractBearerToken
×
112
        }
×
113

114
        errorHandler := c.ErrorHandler
12✔
115
        if errorHandler == nil {
23✔
116
                errorHandler = defaultJWTErrorHandler
11✔
117
        }
11✔
118

119
        onSuccess := c.OnSuccess
12✔
120

12✔
121
        return func(next http.Handler) http.Handler {
24✔
122
                return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34✔
123
                        reg := metrics.SafeRegistry(metrics.GetRegistry(r.Context()))
22✔
124

22✔
125
                        if r.Method == http.MethodOptions {
23✔
126
                                next.ServeHTTP(w, r)
1✔
127
                                return
1✔
128
                        }
1✔
129

130
                        if slices.Contains(c.ExemptMethods, r.Method) {
22✔
131
                                next.ServeHTTP(w, r)
1✔
132
                                return
1✔
133
                        }
1✔
134

135
                        for _, exemptPath := range c.ExemptPaths {
22✔
136
                                if pathMatches(r.URL.Path, exemptPath) {
3✔
137
                                        next.ServeHTTP(w, r)
1✔
138
                                        return
1✔
139
                                }
1✔
140
                        }
141

142
                        if c.TokenStore == nil {
22✔
143
                                reg.Counter("jwt_auth_requests_total", "result").WithLabelValues("not_configured").Inc()
3✔
144
                                handleJWTError(w, r, errTokenStoreNotConfigured, errorHandler)
3✔
145
                                return
3✔
146
                        }
3✔
147

148
                        tokenString := c.TokenExtractor(r)
16✔
149
                        if tokenString == "" {
19✔
150
                                reg.Counter("jwt_auth_requests_total", "result").WithLabelValues("missing").Inc()
3✔
151
                                handleJWTError(w, r, errMissingToken, errorHandler)
3✔
152
                                return
3✔
153
                        }
3✔
154

155
                        claims, err := c.TokenStore.Validate(r.Context(), tokenString)
13✔
156
                        if err != nil {
15✔
157
                                reg.Counter("jwt_auth_requests_total", "result").WithLabelValues("invalid").Inc()
2✔
158
                                handleJWTError(w, r, errInvalidToken, errorHandler)
2✔
159
                                return
2✔
160
                        }
2✔
161

162
                        revoked, err := c.TokenStore.IsRevoked(r.Context(), claims)
11✔
163
                        if err != nil {
13✔
164
                                reg.Counter("jwt_auth_requests_total", "result").WithLabelValues("invalid").Inc()
2✔
165
                                handleJWTError(w, r, &JWTAuthError{
2✔
166
                                        Title:  "Token Revocation Check Failed",
2✔
167
                                        Status: http.StatusInternalServerError,
2✔
168
                                        Detail: err.Error(),
2✔
169
                                }, errorHandler)
2✔
170
                                return
2✔
171
                        }
2✔
172
                        if revoked {
11✔
173
                                reg.Counter("jwt_auth_requests_total", "result").WithLabelValues("invalid").Inc()
2✔
174
                                handleJWTError(w, r, &JWTAuthError{
2✔
175
                                        Title:  "Token Revoked",
2✔
176
                                        Status: http.StatusUnauthorized,
2✔
177
                                        Detail: "token has been revoked",
2✔
178
                                }, errorHandler)
2✔
179
                                return
2✔
180
                        }
2✔
181

182
                        if tokenType := getStringClaim(claims, config.JWTClaimType); tokenType == config.TokenTypeRefresh {
9✔
183
                                reg.Counter("jwt_auth_requests_total", "result").WithLabelValues("invalid").Inc()
2✔
184
                                handleJWTError(w, r, &JWTAuthError{
2✔
185
                                        Title:  "Invalid Token Type",
2✔
186
                                        Status: http.StatusUnauthorized,
2✔
187
                                        Detail: "refresh token cannot be used for authentication",
2✔
188
                                }, errorHandler)
2✔
189
                                return
2✔
190
                        }
2✔
191

192
                        for _, claim := range c.RequiredClaims {
9✔
193
                                if !hasClaim(claims, claim) {
6✔
194
                                        reg.Counter("jwt_auth_requests_total", "result").WithLabelValues("invalid").Inc()
2✔
195
                                        handleJWTError(w, r, &JWTAuthError{
2✔
196
                                                Type:   errMissingRequiredClaim.Type,
2✔
197
                                                Title:  errMissingRequiredClaim.Title,
2✔
198
                                                Status: errMissingRequiredClaim.Status,
2✔
199
                                                Detail: "Token is missing required claim: " + claim,
2✔
200
                                        }, errorHandler)
2✔
201
                                        return
2✔
202
                                }
2✔
203
                        }
204

205
                        reg.Counter("jwt_auth_requests_total", "result").WithLabelValues("valid").Inc()
3✔
206

3✔
207
                        if onSuccess != nil {
4✔
208
                                onSuccess(r, claims)
1✔
209
                        }
1✔
210

211
                        ctx := r.Context()
3✔
212
                        ctx = context.WithValue(ctx, JWTClaimsContextKey, claims)
3✔
213
                        ctx = context.WithValue(ctx, JWTTokenContextKey, tokenString)
3✔
214
                        next.ServeHTTP(w, r.WithContext(ctx))
3✔
215
                })
216
        }
217
}
218

219
// GetJWTToken retrieves the raw JWT token string from the request context.
220
func GetJWTToken(r *http.Request) string {
3✔
221
        if token, ok := r.Context().Value(JWTTokenContextKey).(string); ok {
5✔
222
                return token
2✔
223
        }
2✔
224
        return ""
1✔
225
}
226

227
// GetJWTError retrieves the JWT authentication error from the request context.
228
func GetJWTError(r *http.Request) *JWTAuthError {
19✔
229
        if err, ok := r.Context().Value(JWTErrorContextKey).(error); ok {
36✔
230
                var jwtErr *JWTAuthError
17✔
231
                if errors.As(err, &jwtErr) {
33✔
232
                        return jwtErr
16✔
233
                }
16✔
234
        }
235
        return nil
3✔
236
}
237

238
// JWTClaims wraps JWTClaims to provide convenient accessor methods.
239
// Use GetJWTClaims(r) to get claims from a request.
240
//
241
// Example:
242
//
243
//        jwt := middleware.GetJWTClaims(r)
244
//        subject := jwt.Subject()
245
//        scopes := jwt.Scopes()
246
type JWTClaims struct {
247
        claims config.JWTClaims
248
}
249

250
// GetJWTClaims retrieves JWT claims from the request and returns a JWTClaims wrapper.
251
// This is the primary way to access JWT claims in handlers.
252
//
253
// Example:
254
//
255
//        jwt := middleware.GetJWTClaims(r)
256
//        subject := jwt.Subject()
257
//        if jwt.HasScope("admin") { ... }
258
func GetJWTClaims(r *http.Request) JWTClaims {
41✔
259
        if claims, ok := r.Context().Value(JWTClaimsContextKey).(config.JWTClaims); ok {
72✔
260
                return JWTClaims{claims: claims}
31✔
261
        }
31✔
262
        return JWTClaims{}
10✔
263
}
264

265
// asMap normalizes claims to map[string]any for consistent access.
266
// Handles both map[string]any and HS256Claims types.
267
func (j JWTClaims) asMap() (map[string]any, bool) {
42✔
268
        return normalizeClaims(j.claims)
42✔
269
}
42✔
270

271
// Subject returns the 'sub' claim.
272
func (j JWTClaims) Subject() string {
17✔
273
        return getStringClaim(j.claims, config.JWTClaimSubject)
17✔
274
}
17✔
275

276
// Issuer returns the 'iss' claim.
277
func (j JWTClaims) Issuer() string {
5✔
278
        return getStringClaim(j.claims, config.JWTClaimIssuer)
5✔
279
}
5✔
280

281
// Audience returns the 'aud' claim as a string slice.
282
// Returns all audiences if 'aud' is an array, or a single-element slice if it's a string.
283
func (j JWTClaims) Audience() []string {
11✔
284
        m, ok := j.asMap()
11✔
285
        if !ok {
13✔
286
                return nil
2✔
287
        }
2✔
288

289
        if aud, ok := m[config.JWTClaimAudience]; ok {
17✔
290
                switch v := aud.(type) {
8✔
291
                case string:
2✔
292
                        return []string{v}
2✔
293
                case []string:
5✔
294
                        return v
5✔
295
                case []any:
1✔
296
                        audiences := make([]string, 0, len(v))
1✔
297
                        for _, a := range v {
3✔
298
                                if s, ok := a.(string); ok {
4✔
299
                                        audiences = append(audiences, s)
2✔
300
                                }
2✔
301
                        }
302
                        return audiences
1✔
303
                }
304
        }
305
        return nil
1✔
306
}
307

308
// HasAudience checks if the token has a specific audience.
309
func (j JWTClaims) HasAudience(audience string) bool {
3✔
310
        return slices.Contains(j.Audience(), audience)
3✔
311
}
3✔
312

313
// JTI returns the 'jti' claim (JWT ID).
314
func (j JWTClaims) JTI() string {
6✔
315
        return getStringClaim(j.claims, config.JWTClaimJWTID)
6✔
316
}
6✔
317

318
// Expiration returns the 'exp' claim as time.Time.
319
func (j JWTClaims) Expiration() time.Time {
10✔
320
        m, ok := j.asMap()
10✔
321
        if !ok {
13✔
322
                return time.Time{}
3✔
323
        }
3✔
324

325
        if exp, ok := m[config.JWTClaimExpiration]; ok {
12✔
326
                switch v := exp.(type) {
5✔
327
                case float64:
2✔
328
                        return time.Unix(int64(v), 0)
2✔
329
                case int64:
3✔
330
                        return time.Unix(v, 0)
3✔
331
                }
332
        }
333
        return time.Time{}
2✔
334
}
335

336
// Scopes returns the 'scope' claim as a string slice.
337
func (j JWTClaims) Scopes() []string {
21✔
338
        m, ok := j.asMap()
21✔
339
        if !ok {
24✔
340
                return nil
3✔
341
        }
3✔
342

343
        if scope, ok := m[config.JWTClaimScope]; ok {
34✔
344
                switch v := scope.(type) {
16✔
345
                case string:
12✔
346
                        return strings.Fields(v)
12✔
347
                case []string:
2✔
348
                        return v
2✔
349
                case []any:
2✔
350
                        scopes := make([]string, 0, len(v))
2✔
351
                        for _, s := range v {
6✔
352
                                if str, ok := s.(string); ok {
8✔
353
                                        scopes = append(scopes, str)
4✔
354
                                }
4✔
355
                        }
356
                        return scopes
2✔
357
                }
358
        }
359
        return nil
2✔
360
}
361

362
// HasScope checks if the token has a specific scope.
363
func (j JWTClaims) HasScope(scope string) bool {
10✔
364
        return slices.Contains(j.Scopes(), scope)
10✔
365
}
10✔
366

367
// Raw returns the underlying claims.
368
// Use this for type assertion with third-party JWT libraries.
369
//
370
// Example with lestrrat-go/jwx:
371
//
372
//        token := middleware.GetJWTClaims(r).Raw().(jwt.Token)
373
//        subject := token.Subject()
374
func (j JWTClaims) Raw() config.JWTClaims {
4✔
375
        return j.claims
4✔
376
}
4✔
377

378
// GenerateAccessToken generates a new access token for the given claims.
379
// Automatically sets 'exp' claim based on AccessTokenTTL.
380
// Requires TokenStore to be configured.
381
func GenerateAccessToken(r *http.Request, claims config.JWTClaims, cfg config.JWTAuthConfig) (string, error) {
8✔
382
        if cfg.TokenStore == nil {
9✔
383
                return "", errTokenGeneratorNotConfigured
1✔
384
        }
1✔
385

386
        ttl := cfg.AccessTokenTTL
7✔
387
        if ttl == 0 {
10✔
388
                ttl = config.DefaultJWTAuthConfig.AccessTokenTTL
3✔
389
        }
3✔
390

391
        claims = addExpirationToClaims(claims, ttl)
7✔
392

7✔
393
        return cfg.TokenStore.Generate(r.Context(), claims, config.AccessToken)
7✔
394
}
395

396
// GenerateRefreshToken generates a new refresh token for the given claims.
397
// Automatically sets 'exp' claim based on RefreshTokenTTL and 'type': 'refresh'.
398
// Requires TokenStore to be configured.
399
func GenerateRefreshToken(r *http.Request, claims config.JWTClaims, cfg config.JWTAuthConfig) (string, error) {
6✔
400
        if cfg.TokenStore == nil {
7✔
401
                return "", errTokenGeneratorNotConfigured
1✔
402
        }
1✔
403

404
        ttl := cfg.RefreshTokenTTL
5✔
405
        if ttl == 0 {
7✔
406
                ttl = config.DefaultJWTAuthConfig.RefreshTokenTTL
2✔
407
        }
2✔
408

409
        // Add expiration and type if claims is a map
410
        claims = addExpirationToClaims(claims, ttl)
5✔
411
        claims = addTypeToClaims(claims, config.TokenTypeRefresh)
5✔
412

5✔
413
        return cfg.TokenStore.Generate(r.Context(), claims, config.RefreshToken)
5✔
414
}
415

416
// writeJWTError writes a JWTAuthError response
417
func writeJWTError(w http.ResponseWriter, r *http.Request, jwtErr *JWTAuthError) {
15✔
418
        detail := problem.NewDetail(jwtErr.Status, jwtErr.Detail)
15✔
419
        detail.Type = jwtErr.Type
15✔
420
        detail.Title = jwtErr.Title
15✔
421
        _ = detail.RenderAuto(w, r)
15✔
422
}
15✔
423

424
// tokenHandlerRequest parses and validates the refresh token from the request body.
425
// Returns the claims if validation succeeds, or an error response if it fails.
426
func tokenHandlerRequest(w http.ResponseWriter, r *http.Request, cfg config.JWTAuthConfig) (config.JWTClaims, bool) {
24✔
427
        if r.Method != http.MethodPost {
30✔
428
                detail := problem.NewDetail(http.StatusMethodNotAllowed, "Method not allowed")
6✔
429
                _ = detail.RenderAuto(w, r)
6✔
430
                return nil, false
6✔
431
        }
6✔
432

433
        if cfg.TokenStore == nil {
19✔
434
                writeJWTError(w, r, errTokenStoreNotConfigured)
1✔
435
                return nil, false
1✔
436
        }
1✔
437

438
        var req struct {
17✔
439
                RefreshToken string `json:"refresh_token"`
17✔
440
        }
17✔
441

17✔
442
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
17✔
NEW
443
                writeJWTError(w, r, &JWTAuthError{
×
444
                        Title:  "Invalid Request",
×
445
                        Status: http.StatusBadRequest,
×
446
                        Detail: "Request body must contain refresh_token",
×
447
                })
×
448
                return nil, false
×
449
        }
×
450

451
        if req.RefreshToken == "" {
19✔
452
                writeJWTError(w, r, &JWTAuthError{
2✔
453
                        Title:  "Missing Refresh Token",
2✔
454
                        Status: http.StatusUnprocessableEntity,
2✔
455
                        Detail: "refresh_token is required",
2✔
456
                })
2✔
457
                return nil, false
2✔
458
        }
2✔
459

460
        claims, err := cfg.TokenStore.Validate(r.Context(), req.RefreshToken)
15✔
461
        if err != nil {
17✔
462
                writeJWTError(w, r, errInvalidToken)
2✔
463
                return nil, false
2✔
464
        }
2✔
465

466
        if tokenType := getStringClaim(claims, config.JWTClaimType); tokenType != config.TokenTypeRefresh {
15✔
467
                writeJWTError(w, r, &JWTAuthError{
2✔
468
                        Title:  "Invalid Token Type",
2✔
469
                        Status: http.StatusUnprocessableEntity,
2✔
470
                        Detail: "Provided token is not a refresh token",
2✔
471
                })
2✔
472
                return nil, false
2✔
473
        }
2✔
474

475
        revoked, err := cfg.TokenStore.IsRevoked(r.Context(), claims)
11✔
476
        if err != nil {
12✔
477
                writeJWTError(w, r, &JWTAuthError{
1✔
478
                        Title:  "Token Revocation Check Failed",
1✔
479
                        Status: http.StatusInternalServerError,
1✔
480
                        Detail: err.Error(),
1✔
481
                })
1✔
482
                return nil, false
1✔
483
        }
1✔
484
        if revoked {
13✔
485
                writeJWTError(w, r, &JWTAuthError{
3✔
486
                        Title:  "Token Revoked",
3✔
487
                        Status: http.StatusUnauthorized,
3✔
488
                        Detail: "token has been revoked",
3✔
489
                })
3✔
490
                return nil, false
3✔
491
        }
3✔
492

493
        if err := cfg.TokenStore.Revoke(r.Context(), claims); err != nil {
9✔
494
                writeJWTError(w, r, &JWTAuthError{
2✔
495
                        Title:  "Token Revocation Failed",
2✔
496
                        Status: http.StatusInternalServerError,
2✔
497
                        Detail: err.Error(),
2✔
498
                })
2✔
499
                return nil, false
2✔
500
        }
2✔
501

502
        return claims, true
5✔
503
}
504

505
// RefreshTokenHandler returns an http.HandlerFunc that handles token refresh.
506
// Accepts: { "refresh_token": "..." }
507
// Returns: { "access_token": "...", "refresh_token": "...", "token_type": "Bearer", "expires_in": 900 }
508
// Users mount this at their chosen path: app.Post("/auth/refresh", middleware.RefreshTokenHandler(cfg))
509
func RefreshTokenHandler(cfg config.JWTAuthConfig) http.HandlerFunc {
10✔
510
        return func(w http.ResponseWriter, r *http.Request) {
23✔
511
                claims, ok := tokenHandlerRequest(w, r, cfg)
13✔
512
                if !ok {
22✔
513
                        return
9✔
514
                }
9✔
515

516
                accessToken, err := GenerateAccessToken(r, claims, cfg)
4✔
517
                if err != nil {
5✔
518
                        writeJWTError(w, r, errTokenGeneratorNotConfigured)
1✔
519
                        return
1✔
520
                }
1✔
521

522
                refreshToken, err := GenerateRefreshToken(r, claims, cfg)
3✔
523
                if err != nil {
4✔
524
                        writeJWTError(w, r, errTokenGeneratorNotConfigured)
1✔
525
                        return
1✔
526
                }
1✔
527

528
                expiresIn := cfg.AccessTokenTTL
2✔
529
                if expiresIn == 0 {
2✔
530
                        expiresIn = config.DefaultJWTAuthConfig.AccessTokenTTL
×
531
                }
×
532

533
                w.Header().Set("Content-Type", "application/json")
2✔
534
                w.WriteHeader(http.StatusOK)
2✔
535
                _ = json.NewEncoder(w).Encode(map[string]any{
2✔
536
                        "access_token":  accessToken,
2✔
537
                        "refresh_token": refreshToken,
2✔
538
                        "token_type":    "Bearer",
2✔
539
                        "expires_in":    int(expiresIn.Seconds()),
2✔
540
                })
2✔
541
        }
542
}
543

544
// LogoutTokenHandler returns an http.HandlerFunc that handles token revocation (logout).
545
// Accepts: { "refresh_token": "..." }
546
// Returns: { "message": "logged out successfully" }
547
// Users mount this at their chosen path: app.Post("/auth/logout", middleware.LogoutTokenHandler(cfg))
548
// Requires TokenStore to be configured in JWTAuthConfig.
549
func LogoutTokenHandler(cfg config.JWTAuthConfig) http.HandlerFunc {
8✔
550
        return func(w http.ResponseWriter, r *http.Request) {
19✔
551
                _, ok := tokenHandlerRequest(w, r, cfg)
11✔
552
                if !ok {
21✔
553
                        return
10✔
554
                }
10✔
555

556
                w.Header().Set("Content-Type", "application/json")
1✔
557
                w.WriteHeader(http.StatusOK)
1✔
558
                _ = json.NewEncoder(w).Encode(map[string]any{
1✔
559
                        "message": "logged out successfully",
1✔
560
                })
1✔
561
        }
562
}
563

564
// extractBearerToken extracts the JWT token from the Authorization header
565
func extractBearerToken(r *http.Request) string {
2✔
566
        auth := r.Header.Get("Authorization")
2✔
567
        if auth == "" {
3✔
568
                return ""
1✔
569
        }
1✔
570

571
        const prefix = "Bearer "
1✔
572
        if !strings.HasPrefix(auth, prefix) {
2✔
573
                return ""
1✔
574
        }
1✔
575

576
        return strings.TrimSpace(auth[len(prefix):])
×
577
}
578

579
// handleJWTError sends an error response
580
func handleJWTError(w http.ResponseWriter, r *http.Request, jwtErr *JWTAuthError, handler http.HandlerFunc) {
16✔
581
        // Add error to context so custom handlers can access it
16✔
582
        ctx := context.WithValue(r.Context(), JWTErrorContextKey, jwtErr)
16✔
583
        r = r.WithContext(ctx)
16✔
584

16✔
585
        if handler != nil {
32✔
586
                handler(w, r)
16✔
587
                return
16✔
588
        }
16✔
589
        defaultJWTErrorHandler(w, r)
×
590
}
591

592
// defaultJWTErrorHandler is the default error handler
593
func defaultJWTErrorHandler(w http.ResponseWriter, r *http.Request) {
16✔
594
        jwtErr := GetJWTError(r)
16✔
595
        if jwtErr == nil {
17✔
596
                jwtErr = errInvalidToken
1✔
597
        }
1✔
598

599
        detail := problem.NewDetail(jwtErr.Status, jwtErr.Detail)
16✔
600
        detail.Type = jwtErr.Type
16✔
601
        detail.Title = jwtErr.Title
16✔
602
        _ = detail.RenderAuto(w, r)
16✔
603
}
604

605
// hasClaim checks if a claim exists in the claims
606
func hasClaim(claims config.JWTClaims, key string) bool {
13✔
607
        switch c := claims.(type) {
13✔
608
        case map[string]any:
7✔
609
                _, ok := c[key]
7✔
610
                return ok
7✔
611
        case HS256Claims:
3✔
612
                _, ok := c[key]
3✔
613
                return ok
3✔
614
        default:
3✔
615
                // Handle other map types (e.g., jwt.MapClaims from golang-jwt)
3✔
616
                return getMapClaim(c, key) != nil
3✔
617
        }
618
}
619

620
// normalizeClaims converts claims to map[string]any for consistent access.
621
// Handles map[string]any, HS256Claims, and other map types via reflection.
622
func normalizeClaims(claims config.JWTClaims) (map[string]any, bool) {
99✔
623
        if claims == nil {
111✔
624
                return nil, false
12✔
625
        }
12✔
626
        switch c := claims.(type) {
87✔
627
        case map[string]any:
72✔
628
                return c, true
72✔
629
        case HS256Claims:
6✔
630
                return c, true
6✔
631
        default:
9✔
632
                // For other map types, use reflection
9✔
633
                return nil, false
9✔
634
        }
635
}
636

637
// getStringClaim extracts a string claim from claims
638
func getStringClaim(claims config.JWTClaims, key string) string {
57✔
639
        if m, ok := normalizeClaims(claims); ok {
101✔
640
                if v, ok := m[key]; ok {
76✔
641
                        switch s := v.(type) {
32✔
642
                        case string:
28✔
643
                                return s
28✔
644
                        case []string:
1✔
645
                                if len(s) > 0 {
2✔
646
                                        return s[0]
1✔
647
                                }
1✔
648
                        case []any:
3✔
649
                                if len(s) > 0 {
5✔
650
                                        if str, ok := s[0].(string); ok {
3✔
651
                                                return str
1✔
652
                                        }
1✔
653
                                }
654
                        }
655
                }
656
                return ""
14✔
657
        }
658

659
        // Handle other map types (e.g., jwt.MapClaims from golang-jwt) via reflection
660
        if v := getMapClaim(claims, key); v != nil {
14✔
661
                return extractStringValue(v)
1✔
662
        }
1✔
663
        return ""
12✔
664
}
665

666
// getMapClaim extracts a value from any map-like type using reflection
667
func getMapClaim(claims config.JWTClaims, key string) any {
21✔
668
        if claims == nil {
29✔
669
                return nil
8✔
670
        }
8✔
671
        switch m := claims.(type) {
13✔
672
        case map[string]any:
2✔
673
                return m[key]
2✔
674
        default:
11✔
675
                v := reflect.ValueOf(claims)
11✔
676
                if v.Kind() == reflect.Map && v.Type().Key().Kind() == reflect.String {
17✔
677
                        val := v.MapIndex(reflect.ValueOf(key))
6✔
678
                        if val.IsValid() {
10✔
679
                                return val.Interface()
4✔
680
                        }
4✔
681
                }
682
                return nil
7✔
683
        }
684
}
685

686
// extractStringValue converts a value to string
687
func extractStringValue(v any) string {
7✔
688
        if v == nil {
8✔
689
                return ""
1✔
690
        }
1✔
691
        switch s := v.(type) {
6✔
692
        case string:
2✔
693
                return s
2✔
694
        case []string:
2✔
695
                if len(s) > 0 {
3✔
696
                        return s[0]
1✔
697
                }
1✔
698
        case []any:
1✔
699
                if len(s) > 0 {
2✔
700
                        if str, ok := s[0].(string); ok {
2✔
701
                                return str
1✔
702
                        }
1✔
703
                }
704
        }
705
        return ""
2✔
706
}
707

708
// deepCopyMap creates a deep copy of a map[string]any
709
func deepCopyMap(m map[string]any) map[string]any {
25✔
710
        if m == nil {
26✔
711
                return nil
1✔
712
        }
1✔
713
        newMap := make(map[string]any, len(m))
24✔
714
        for k, v := range m {
67✔
715
                switch val := v.(type) {
43✔
716
                case map[string]any:
1✔
717
                        newMap[k] = deepCopyMap(val)
1✔
718
                case []any:
1✔
719
                        newMap[k] = deepCopySlice(val)
1✔
720
                default:
41✔
721
                        newMap[k] = v
41✔
722
                }
723
        }
724
        return newMap
24✔
725
}
726

727
// deepCopySlice creates a deep copy of a []any
728
func deepCopySlice(s []any) []any {
4✔
729
        if s == nil {
5✔
730
                return nil
1✔
731
        }
1✔
732
        newSlice := make([]any, len(s))
3✔
733
        for i, v := range s {
10✔
734
                switch val := v.(type) {
7✔
735
                case map[string]any:
1✔
736
                        newSlice[i] = deepCopyMap(val)
1✔
737
                case []any:
1✔
738
                        newSlice[i] = deepCopySlice(val)
1✔
739
                default:
5✔
740
                        newSlice[i] = v
5✔
741
                }
742
        }
743
        return newSlice
3✔
744
}
745

746
// addExpirationToClaims adds exp claim to map claims
747
func addExpirationToClaims(claims config.JWTClaims, ttl time.Duration) config.JWTClaims {
15✔
748
        switch c := claims.(type) {
15✔
749
        case map[string]any:
13✔
750
                newClaims := deepCopyMap(c)
13✔
751
                newClaims[config.JWTClaimExpiration] = time.Now().Add(ttl).Unix()
13✔
752
                return newClaims
13✔
753
        case HS256Claims:
1✔
754
                newClaims := deepCopyMap(c)
1✔
755
                newClaims[config.JWTClaimExpiration] = time.Now().Add(ttl).Unix()
1✔
756
                return HS256Claims(newClaims)
1✔
757
        default:
1✔
758
                return claims
1✔
759
        }
760
}
761

762
// addTypeToClaims adds type claim to map claims
763
func addTypeToClaims(claims config.JWTClaims, tokenType string) config.JWTClaims {
8✔
764
        switch c := claims.(type) {
8✔
765
        case map[string]any:
6✔
766
                newClaims := deepCopyMap(c)
6✔
767
                newClaims[config.JWTClaimType] = tokenType
6✔
768
                return newClaims
6✔
769
        case HS256Claims:
1✔
770
                newClaims := deepCopyMap(c)
1✔
771
                newClaims[config.JWTClaimType] = tokenType
1✔
772
                return HS256Claims(newClaims)
1✔
773
        default:
1✔
774
                return claims
1✔
775
        }
776
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc