package auth import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "strings" "time" ) // Cookie names. The login-tx cookie is scoped to /api/auth so it is only // sent to the endpoints that need it. const ( CookieSession = "fs_session" CookieLoginTx = "fs_auth_tx" ) // Session is the signed-in identity kept in the session cookie. It is a // self-contained, HMAC-protected claim set — not a bearer token: it carries // no issuer credentials and only opens this API. type Session struct { Sub string Name string Email string Roles []string ExpiresAt time.Time } // Valid reports whether the session is still within its TTL. func (s Session) Valid() bool { return time.Now().Before(s.ExpiresAt) } // ErrTampered is returned when a cookie value fails its HMAC check or is // malformed — treat it exactly like a missing cookie. var ErrTampered = errors.New("auth cookie failed integrity check") // Signer seals values into "." // cookie payloads and opens them back. Construction is cheap; keep one // per server and share it. type Signer struct{ key []byte } // NewSigner returns a Signer keyed by secret. func NewSigner(secret string) *Signer { return &Signer{key: []byte(secret)} } // Seal encodes v into a signed cookie value. func (s *Signer) Seal(v any) (string, error) { payload, err := json.Marshal(v) if err != nil { return "", fmt.Errorf("marshal cookie payload: %w", err) } return base64.RawURLEncoding.EncodeToString(payload) + "." + s.mac(payload), nil } // Open verifies a sealed value and decodes it into v. func (s *Signer) Open(sealed string, v any) error { value, mac, ok := strings.Cut(sealed, ".") if !ok { return ErrTampered } payload, err := base64.RawURLEncoding.DecodeString(value) if err != nil { return ErrTampered } sig, err := base64.RawURLEncoding.DecodeString(mac) if err != nil { return ErrTampered } if !hmac.Equal(sig, s.macBytes(payload)) { return ErrTampered } if err := json.Unmarshal(payload, v); err != nil { return ErrTampered } return nil } func (s *Signer) mac(payload []byte) string { return base64.RawURLEncoding.EncodeToString(s.macBytes(payload)) } func (s *Signer) macBytes(payload []byte) []byte { h := hmac.New(sha256.New, s.key) h.Write(payload) return h.Sum(nil) } // NewSessionCookie returns the Set-Cookie for a session valid for ttl. func (s *Signer) NewSessionCookie(sess Session, ttl time.Duration, secure bool) (*http.Cookie, error) { sess.ExpiresAt = time.Now().Add(ttl) value, err := s.Seal(sess) if err != nil { return nil, err } return &http.Cookie{ Name: CookieSession, Value: value, Path: "/", MaxAge: int(ttl.Seconds()), HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }, nil } // NewLoginTxCookie returns the short-lived Set-Cookie carrying tx between // the login redirect and the callback. func (s *Signer) NewLoginTxCookie(tx LoginTx, secure bool) (*http.Cookie, error) { value, err := s.Seal(tx) if err != nil { return nil, err } return &http.Cookie{ Name: CookieLoginTx, Value: value, Path: "/api/auth", MaxAge: int(txTTL.Seconds()), HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }, nil } // Expired returns a Set-Cookie that deletes the named cookie. func Expired(name string, secure bool) *http.Cookie { return &http.Cookie{ Name: name, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, } }