Skip to content

Commit cde23b1

Browse files
committed
Use JWT auth tokens
1 parent 63777a9 commit cde23b1

6 files changed

Lines changed: 157 additions & 99 deletions

File tree

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ CPA v6.10 removed the legacy `/v0/management/usage/{export,import}` HTTP endpoin
1010
- Periodically refreshes auth-files and provider catalogs from CPA management API
1111
- Computes per-model cost from configurable price-per-1M-token settings
1212
- Serves an API + SPA at `/usage/*` (subpath configurable)
13-
- Optional cookie-based password login
13+
- Optional JWT cookie-based password login
1414
- Daily 03:00 retention sweep (drops events older than 30 days, then `VACUUM`)
1515

1616
## Quick start (development)
@@ -75,20 +75,20 @@ All configuration is via environment variables (also see `.env.example`):
7575
| `METADATA_SYNC_INTERVAL` | `30s` | |
7676
| `AUTH_ENABLED` | `false` | When true, `LOGIN_PASSWORD` is required |
7777
| `LOGIN_PASSWORD` || Required if `AUTH_ENABLED=true` |
78-
| `AUTH_SESSION_TTL` | `168h` | |
78+
| `AUTH_SESSION_TTL` | `168h` | JWT cookie lifetime |
7979
| `LOG_LEVEL` | `info` | |
8080
| `LOG_FILE_ENABLED` | `true` | |
8181
| `LOG_DIR` | `./logs` | Resolved against the process working directory |
8282
| `LOG_RETENTION_DAYS` | `7` | Lumberjack max-age + max-backups |
8383

8484
## API surface
8585

86-
All endpoints are mounted under `<APP_BASE_PATH>/api/v1`. Protected endpoints require a valid session cookie when `AUTH_ENABLED=true`.
86+
All endpoints are mounted under `<APP_BASE_PATH>/api/v1`. Protected endpoints require a valid JWT auth cookie when `AUTH_ENABLED=true`.
8787

8888
| Method | Path | Purpose |
8989
|---|---|---|
9090
| GET | `/ping` | liveness + version |
91-
| GET | `/auth/session` | session status |
91+
| GET | `/auth/session` | auth status |
9292
| POST | `/auth/login` | `{ "password": "..." }` |
9393
| POST | `/auth/logout` | clear cookie |
9494
| GET | `/status` | drain status (last pop, errors, totals) |
@@ -141,7 +141,7 @@ cmd/server/ entrypoint (ldflag-injected version)
141141
internal/
142142
api/ gin router + handlers + embedded SPA
143143
app/ composition root + maintenance loop
144-
auth/ in-memory session manager
144+
auth/ password auth + JWT token manager
145145
config/ env loader
146146
cpa/ CPA HTTP client + RESP redis client
147147
drain/ pop/decode/insert + metadata orchestration

internal/api/auth_handler.go

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,19 @@ import (
1111

1212
// AuthDeps wires together the auth handler/middleware dependencies.
1313
type AuthDeps struct {
14-
Enabled bool
15-
Password string
16-
CookieName string
17-
BasePath string
18-
Sessions *auth.SessionManager
14+
Enabled bool
15+
Password string
16+
CookieName string
17+
BasePath string
18+
Tokens *auth.TokenManager
1919
}
2020

2121
// loginRequest is the JSON body of POST /auth/login.
2222
type loginRequest struct {
2323
Password string `json:"password"`
2424
}
2525

26-
// sessionResponse describes the public session shape (no token).
26+
// sessionResponse describes the public auth state (no token).
2727
type sessionResponse struct {
2828
Authenticated bool `json:"authenticated"`
2929
AuthRequired bool `json:"auth_required"`
@@ -33,8 +33,8 @@ func sessionHandler(deps AuthDeps) gin.HandlerFunc {
3333
return func(c *gin.Context) {
3434
ok := true
3535
if deps.Enabled {
36-
token := readSessionToken(c, deps.CookieName)
37-
ok = token != "" && deps.Sessions.Validate(token)
36+
token := readAuthToken(c, deps.CookieName)
37+
ok = token != "" && deps.Tokens.Validate(token)
3838
}
3939
c.JSON(http.StatusOK, sessionResponse{Authenticated: ok, AuthRequired: deps.Enabled})
4040
}
@@ -55,53 +55,49 @@ func loginHandler(deps AuthDeps) gin.HandlerFunc {
5555
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
5656
return
5757
}
58-
token, expires, err := deps.Sessions.Create()
58+
token, expires, err := deps.Tokens.Create()
5959
if err != nil {
60-
c.JSON(http.StatusInternalServerError, gin.H{"error": "session creation failed"})
60+
c.JSON(http.StatusInternalServerError, gin.H{"error": "token creation failed"})
6161
return
6262
}
63-
setSessionCookie(c, deps, token, int(deps.Sessions.TTL().Seconds()))
63+
setAuthCookie(c, deps, token, int(deps.Tokens.TTL().Seconds()))
6464
c.JSON(http.StatusOK, gin.H{"authenticated": true, "expires_at": expires})
6565
}
6666
}
6767

6868
func logoutHandler(deps AuthDeps) gin.HandlerFunc {
6969
return func(c *gin.Context) {
70-
token := readSessionToken(c, deps.CookieName)
71-
if token != "" {
72-
deps.Sessions.Delete(token)
73-
}
74-
setSessionCookie(c, deps, "", -1)
70+
setAuthCookie(c, deps, "", -1)
7571
c.JSON(http.StatusOK, gin.H{"authenticated": false})
7672
}
7773
}
7874

79-
// authMiddleware returns a gin middleware enforcing session presence when auth
75+
// authMiddleware returns a gin middleware enforcing token presence when auth
8076
// is enabled. When auth is disabled the middleware is a no-op.
8177
func authMiddleware(deps AuthDeps) gin.HandlerFunc {
8278
return func(c *gin.Context) {
8379
if !deps.Enabled {
8480
c.Next()
8581
return
8682
}
87-
token := readSessionToken(c, deps.CookieName)
88-
if token == "" || !deps.Sessions.Validate(token) {
83+
token := readAuthToken(c, deps.CookieName)
84+
if token == "" || !deps.Tokens.Validate(token) {
8985
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
9086
return
9187
}
9288
c.Next()
9389
}
9490
}
9591

96-
func readSessionToken(c *gin.Context, cookieName string) string {
92+
func readAuthToken(c *gin.Context, cookieName string) string {
9793
v, err := c.Cookie(cookieName)
9894
if err != nil {
9995
return ""
10096
}
10197
return strings.TrimSpace(v)
10298
}
10399

104-
func setSessionCookie(c *gin.Context, deps AuthDeps, token string, maxAge int) {
100+
func setAuthCookie(c *gin.Context, deps AuthDeps, token string, maxAge int) {
105101
cookiePath := deps.BasePath
106102
if cookiePath == "" {
107103
cookiePath = "/"

internal/app/app.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ func New(cfg *config.Config, build BuildInfo) (*App, error) {
8989
MetadataInterval: cfg.MetadataInterval,
9090
})
9191

92-
sessions := auth.NewSessionManager(cfg.SessionTTL)
92+
tokens := auth.NewTokenManager(cfg.AuthTokenTTL, cfg.LoginPassword)
9393

9494
router := api.New(api.RouterConfig{
9595
BasePath: cfg.AppBasePath,
@@ -104,7 +104,7 @@ func New(cfg *config.Config, build BuildInfo) (*App, error) {
104104
Password: cfg.LoginPassword,
105105
CookieName: cfg.CookieName,
106106
BasePath: cfg.AppBasePath,
107-
Sessions: sessions,
107+
Tokens: tokens,
108108
},
109109
Usage: api.UsageDeps{
110110
Service: usageSvc,

internal/auth/session.go

Lines changed: 70 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,99 @@
1-
// Package auth implements an in-memory session manager for the optional password
2-
// login flow. Sessions are opaque random tokens kept in process memory; the
3-
// process restarting invalidates everyone (acceptable for v1).
1+
// Package auth implements the optional password login flow. Successful logins
2+
// receive stateless JWTs signed from the configured login password, so process
3+
// restarts do not invalidate existing browser cookies.
44
package auth
55

66
import (
7-
"crypto/rand"
7+
"crypto/hmac"
8+
"crypto/sha256"
89
"crypto/subtle"
9-
"encoding/hex"
10-
"sync"
10+
"encoding/base64"
11+
"encoding/json"
12+
"strings"
1113
"time"
1214
)
1315

14-
// SessionManager tracks live sessions and their expiry.
15-
type SessionManager struct {
16-
ttl time.Duration
17-
now func() time.Time
18-
generate func() (string, error)
16+
// TokenManager issues and validates stateless JWTs.
17+
type TokenManager struct {
18+
ttl time.Duration
19+
key []byte
20+
now func() time.Time
21+
}
1922

20-
mu sync.RWMutex
21-
sessions map[string]time.Time
23+
type jwtClaims struct {
24+
Subject string `json:"sub"`
25+
IssuedAt int64 `json:"iat"`
26+
ExpiresAt int64 `json:"exp"`
2227
}
2328

24-
// NewSessionManager builds a SessionManager with the supplied TTL.
25-
func NewSessionManager(ttl time.Duration) *SessionManager {
26-
return &SessionManager{
27-
ttl: ttl,
28-
now: time.Now,
29-
generate: generateToken,
30-
sessions: make(map[string]time.Time),
29+
// NewTokenManager builds a TokenManager with the supplied TTL and signing
30+
// secret. Tokens remain valid across process restarts as long as the secret
31+
// (currently the login password) is unchanged.
32+
func NewTokenManager(ttl time.Duration, secret string) *TokenManager {
33+
key := sha256.Sum256([]byte("cpa-usage jwt signing key\x00" + secret))
34+
return &TokenManager{
35+
ttl: ttl,
36+
key: key[:],
37+
now: time.Now,
3138
}
3239
}
3340

34-
// Create issues a new session and returns the token + absolute expiry.
35-
func (m *SessionManager) Create() (string, time.Time, error) {
36-
token, err := m.generate()
41+
// Create issues a new JWT and returns the token + absolute expiry.
42+
func (m *TokenManager) Create() (string, time.Time, error) {
43+
now := m.now()
44+
expires := now.Add(m.ttl)
45+
46+
header, err := json.Marshal(map[string]string{
47+
"alg": "HS256",
48+
"typ": "JWT",
49+
})
50+
if err != nil {
51+
return "", time.Time{}, err
52+
}
53+
claims, err := json.Marshal(jwtClaims{
54+
Subject: "cpa-usage",
55+
IssuedAt: now.Unix(),
56+
ExpiresAt: expires.Unix(),
57+
})
3758
if err != nil {
3859
return "", time.Time{}, err
3960
}
40-
m.mu.Lock()
41-
defer m.mu.Unlock()
42-
m.cleanupLocked()
43-
expires := m.now().Add(m.ttl)
44-
m.sessions[token] = expires
45-
return token, expires, nil
61+
62+
unsigned := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(claims)
63+
return unsigned + "." + m.sign(unsigned), expires, nil
4664
}
4765

48-
// Validate reports whether token is a known, unexpired session.
49-
func (m *SessionManager) Validate(token string) bool {
66+
// Validate reports whether token is a well-formed, signed, unexpired JWT.
67+
func (m *TokenManager) Validate(token string) bool {
5068
if token == "" {
5169
return false
5270
}
53-
m.mu.RLock()
54-
expires, ok := m.sessions[token]
55-
m.mu.RUnlock()
56-
if !ok {
71+
parts := strings.Split(token, ".")
72+
if len(parts) != 3 {
5773
return false
5874
}
59-
if !expires.After(m.now()) {
60-
m.Delete(token)
75+
76+
unsigned := parts[0] + "." + parts[1]
77+
if !hmac.Equal([]byte(parts[2]), []byte(m.sign(unsigned))) {
6178
return false
6279
}
63-
return true
64-
}
6580

66-
// Delete removes a session by token. No-op if the token is absent.
67-
func (m *SessionManager) Delete(token string) {
68-
if token == "" {
69-
return
81+
claimsRaw, err := base64.RawURLEncoding.DecodeString(parts[1])
82+
if err != nil {
83+
return false
7084
}
71-
m.mu.Lock()
72-
defer m.mu.Unlock()
73-
delete(m.sessions, token)
74-
}
75-
76-
// CleanupExpired drops sessions whose expiry has passed.
77-
func (m *SessionManager) CleanupExpired() {
78-
m.mu.Lock()
79-
defer m.mu.Unlock()
80-
m.cleanupLocked()
85+
var claims jwtClaims
86+
if err := json.Unmarshal(claimsRaw, &claims); err != nil {
87+
return false
88+
}
89+
if claims.Subject != "cpa-usage" || claims.ExpiresAt <= 0 {
90+
return false
91+
}
92+
return time.Unix(claims.ExpiresAt, 0).After(m.now())
8193
}
8294

8395
// TTL returns the configured TTL (used to set cookie max-age).
84-
func (m *SessionManager) TTL() time.Duration { return m.ttl }
96+
func (m *TokenManager) TTL() time.Duration { return m.ttl }
8597

8698
// PasswordMatches performs a constant-time compare on the supplied password.
8799
func PasswordMatches(expected, supplied string) bool {
@@ -91,19 +103,8 @@ func PasswordMatches(expected, supplied string) bool {
91103
return subtle.ConstantTimeCompare([]byte(expected), []byte(supplied)) == 1
92104
}
93105

94-
func (m *SessionManager) cleanupLocked() {
95-
now := m.now()
96-
for tok, exp := range m.sessions {
97-
if !exp.After(now) {
98-
delete(m.sessions, tok)
99-
}
100-
}
101-
}
102-
103-
func generateToken() (string, error) {
104-
buf := make([]byte, 32)
105-
if _, err := rand.Read(buf); err != nil {
106-
return "", err
107-
}
108-
return hex.EncodeToString(buf), nil
106+
func (m *TokenManager) sign(unsigned string) string {
107+
mac := hmac.New(sha256.New, m.key)
108+
_, _ = mac.Write([]byte(unsigned))
109+
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
109110
}

internal/auth/session_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package auth
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestTokenManagerValidatesAcrossInstances(t *testing.T) {
10+
now := time.Date(2026, 6, 12, 10, 0, 0, 0, time.UTC)
11+
m1 := NewTokenManager(time.Hour, "secret")
12+
m1.now = func() time.Time { return now }
13+
14+
token, expires, err := m1.Create()
15+
if err != nil {
16+
t.Fatalf("Create: %v", err)
17+
}
18+
if got, want := strings.Count(token, "."), 2; got != want {
19+
t.Fatalf("token dot count = %d, want %d", got, want)
20+
}
21+
if !expires.Equal(now.Add(time.Hour)) {
22+
t.Fatalf("expires = %s, want %s", expires, now.Add(time.Hour))
23+
}
24+
25+
m2 := NewTokenManager(time.Hour, "secret")
26+
m2.now = func() time.Time { return now.Add(30 * time.Minute) }
27+
if !m2.Validate(token) {
28+
t.Fatal("Validate returned false for token signed by another manager instance")
29+
}
30+
}
31+
32+
func TestTokenManagerRejectsWrongSecret(t *testing.T) {
33+
now := time.Date(2026, 6, 12, 10, 0, 0, 0, time.UTC)
34+
m1 := NewTokenManager(time.Hour, "secret")
35+
m1.now = func() time.Time { return now }
36+
token, _, err := m1.Create()
37+
if err != nil {
38+
t.Fatalf("Create: %v", err)
39+
}
40+
41+
m2 := NewTokenManager(time.Hour, "other-secret")
42+
m2.now = func() time.Time { return now }
43+
if m2.Validate(token) {
44+
t.Fatal("Validate returned true for token signed with a different secret")
45+
}
46+
}
47+
48+
func TestTokenManagerRejectsExpiredToken(t *testing.T) {
49+
now := time.Date(2026, 6, 12, 10, 0, 0, 0, time.UTC)
50+
m := NewTokenManager(time.Hour, "secret")
51+
m.now = func() time.Time { return now }
52+
token, _, err := m.Create()
53+
if err != nil {
54+
t.Fatalf("Create: %v", err)
55+
}
56+
57+
m.now = func() time.Time { return now.Add(time.Hour + time.Second) }
58+
if m.Validate(token) {
59+
t.Fatal("Validate returned true for expired token")
60+
}
61+
}

0 commit comments

Comments
 (0)