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 .
44package auth
55
66import (
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.
8799func 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}
0 commit comments