Files
MealQuest/internal/auth/cookie.go
T
dev_agentandClaude Opus 5.5 0dccf87b06
CI / ci (push) Successful in 9m33s
Make login state single-use on the server
A saved copy of the signed login cookie verified until it expired, so the
callback accepted it again with a second valid authorization code. The
login now records its state as pending and the callback consumes it,
refusing a replay with the same 400 as a state mismatch.

The pending-state set moves from gcal into auth as PendingStates, so login
and the Google Calendar connect share one mechanism. It is in memory, so a
flow started before a restart fails closed after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:31:24 -04:00

217 lines
6.6 KiB
Go

package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
loginCookieName = "__Host-mealquest_login"
loginCookieTTL = 10 * time.Minute
// loginCookieKeyLabel separates the login cookie's signing key from other
// keys derived from the same server secret.
loginCookieKeyLabel = "mealquest auth login cookie v1"
)
// loginState travels from login to callback in the signed login cookie.
type loginState struct {
State string `json:"s"`
Nonce string `json:"n"`
Verifier string `json:"v"`
Return string `json:"r"`
FlowExpiry
}
// loginCookie is the FlowCookie carrying loginState.
type loginCookie struct {
flow FlowCookie
}
func newLoginCookie(secretKey []byte) loginCookie {
return loginCookie{flow: NewFlowCookie(loginCookieName, loginCookieKeyLabel, loginCookieTTL, secretKey)}
}
func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error {
return c.flow.Set(w, &s, now)
}
func (c loginCookie) clear(w http.ResponseWriter) { c.flow.Clear(w) }
func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) {
var s loginState
if err := c.flow.Read(r, &s, now); err != nil {
return loginState{}, err
}
return s, nil
}
// FlowExpiry is embedded in every value a FlowCookie carries; Set fills it
// and Read enforces it.
type FlowExpiry struct {
Expires int64 `json:"e"` // Unix seconds
}
func (e *FlowExpiry) flowExpiry() *FlowExpiry { return e }
// flowValue is a pointer to a struct that embeds FlowExpiry.
type flowValue interface {
flowExpiry() *FlowExpiry
}
// FlowCookie carries a redirect flow's state — the OAuth state, PKCE
// verifier and the like — from the request that sends the browser to a
// provider to the callback it returns to. The value is JSON, signed with
// HMAC-SHA256 under a key derived from the server secret and the cookie's
// own label, and expires after a fixed time. It is not encrypted: it must
// not hold anything the browser may not see.
type FlowCookie struct {
name string
ttl time.Duration
key []byte
}
// NewFlowCookie returns the flow cookie name, valid for ttl, signed under a
// key derived from secretKey and keyLabel. Each flow uses its own keyLabel so
// one flow's cookie never verifies as another's. name must carry the __Host-
// prefix: the cookie is Secure, host-only and Path=/.
func NewFlowCookie(name, keyLabel string, ttl time.Duration, secretKey []byte) FlowCookie {
mac := hmac.New(sha256.New, secretKey)
mac.Write([]byte(keyLabel))
return FlowCookie{name: name, ttl: ttl, key: mac.Sum(nil)}
}
func (c FlowCookie) sign(payload string) string {
mac := hmac.New(sha256.New, c.key)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// Set stores v, which expires ttl after now, in the cookie.
func (c FlowCookie) Set(w http.ResponseWriter, v flowValue, now time.Time) error {
v.flowExpiry().Expires = now.Add(c.ttl).Unix()
data, err := json.Marshal(v)
if err != nil {
return err
}
payload := base64.RawURLEncoding.EncodeToString(data)
http.SetCookie(w, &http.Cookie{
Name: c.name,
Value: payload + "." + c.sign(payload),
Path: "/",
MaxAge: int(c.ttl / time.Second),
Secure: true,
HttpOnly: true,
// Lax so the cookie rides the provider's top-level redirect back.
SameSite: http.SameSiteLaxMode,
})
return nil
}
// Clear asks the browser to discard the cookie. A copy of it still verifies
// until it expires, so a flow that must be single-use also consumes its
// state on the server.
func (c FlowCookie) Clear(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: c.name,
Path: "/",
MaxAge: -1,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
// Read verifies the cookie r carries and decodes it into v. It fails when
// the cookie is missing, its signature is invalid or it has expired at now.
func (c FlowCookie) Read(r *http.Request, v flowValue, now time.Time) error {
cookie, err := r.Cookie(c.name)
if err != nil {
return fmt.Errorf("%s cookie missing", c.name)
}
payload, sig, ok := strings.Cut(cookie.Value, ".")
if !ok || !hmac.Equal([]byte(sig), []byte(c.sign(payload))) {
return fmt.Errorf("%s cookie signature invalid", c.name)
}
data, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return fmt.Errorf("%s cookie encoding: %w", c.name, err)
}
if err := json.Unmarshal(data, v); err != nil {
return fmt.Errorf("%s cookie payload: %w", c.name, err)
}
if now.Unix() >= v.flowExpiry().Expires {
return fmt.Errorf("%s cookie expired", c.name)
}
return nil
}
// PendingStates holds the state of every redirect flow started and not yet
// completed, until its flow cookie expires. A copy of a flow cookie verifies
// until it expires, so a flow is single-use only because its callback
// consumes the state here. The states live in memory — MealQuest runs as one
// process — so a flow started before a restart fails closed after it.
type PendingStates struct {
ttl time.Duration
mu sync.Mutex
states map[string]time.Time
}
// NewPendingStates returns an empty set whose states are pending for ttl,
// the flow cookie's lifetime.
func NewPendingStates(ttl time.Duration) *PendingStates {
return &PendingStates{ttl: ttl, states: map[string]time.Time{}}
}
// Add records state, started at now, as pending and forgets expired ones.
func (p *PendingStates) Add(state string, now time.Time) {
p.mu.Lock()
defer p.mu.Unlock()
for k, expires := range p.states {
if !now.Before(expires) {
delete(p.states, k)
}
}
p.states[state] = now.Add(p.ttl)
}
// Consume removes state from the pending states, reporting whether it was
// there.
func (p *PendingStates) Consume(state string) bool {
p.mu.Lock()
defer p.mu.Unlock()
_, ok := p.states[state]
delete(p.states, state)
return ok
}
// returnPath validates the optional post-login return path. Empty means "/".
// Only same-origin absolute paths are allowed: no scheme, host, protocol-
// relative "//" prefix, backslashes or control characters.
func returnPath(raw string) (string, error) {
if raw == "" {
return "/", nil
}
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsRune(raw, '\\') {
return "", errors.New("return path must be a same-origin path")
}
for _, c := range raw {
if c < 0x20 || c == 0x7f {
return "", errors.New("return path contains a control character")
}
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil {
return "", errors.New("return path must be a same-origin path")
}
return raw, nil
}