package web // Sessions and the pending authorisations in flight (novox/hq ADR 0440 §3, issue 1263): both kept in memory // only, never on disk — a restart signs everyone out, accepted (design 59 milestone 1's prepare step). import ( "crypto/rand" "encoding/base64" "sync" "time" ) // SessionCookie is the cookie's own name. const SessionCookie = "mesh_mail_web_session" // SessionLifetime is how long a session lasts after it is made (novox/hq ADR 0440 §3, issue 1263/AC2, AC5). const SessionLifetime = 12 * time.Hour // PendingLifetime is how long a sign-in may take between the redirect to the issuer and its callback, before // the state is no longer honoured. const PendingLifetime = 10 * time.Minute func randomID() (string, error) { var b [32]byte if _, err := rand.Read(b[:]); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b[:]), nil } // Session is one signed-in operator's own session: its identity, when it was made (SessionLifetime decides // when it ends), and the per-session token issue 1284 binds every changing form to. type Session struct { Operator string Made time.Time Token string } // Sessions keeps every session in memory, by its cookie's own id. type Sessions struct { mu sync.Mutex now func() time.Time byID map[string]Session } // NewSessions is an empty Sessions; now is time.Now unless a test gives its own. func NewSessions(now func() time.Time) *Sessions { if now == nil { now = time.Now } return &Sessions{now: now, byID: map[string]Session{}} } // Create starts a new session for operator, answering its id (the cookie's own value) and its own per-session // token (issue 1284). func (s *Sessions) Create(operator string) (id string, err error) { id, err = randomID() if err != nil { return "", err } token, err := randomID() if err != nil { return "", err } s.mu.Lock() defer s.mu.Unlock() s.byID[id] = Session{Operator: operator, Made: s.now(), Token: token} return id, nil } // Get is the session at id, or false when there is none or it ended SessionLifetime ago (1263/AC5): an expired // session reads as no session at all, dropped on the same look (no background sweep is needed: every read // already checks the time). func (s *Sessions) Get(id string) (Session, bool) { if id == "" { return Session{}, false } s.mu.Lock() defer s.mu.Unlock() sess, ok := s.byID[id] if !ok { return Session{}, false } if s.now().Sub(sess.Made) > SessionLifetime { delete(s.byID, id) return Session{}, false } return sess, true } // PendingAuth is one sign-in asked of the issuer, remembered between the redirect and its callback: the PKCE // verifier the challenge was made from, and the page the browser first asked for. type PendingAuth struct { Verifier string Return string Created time.Time } // PendingAuths keeps every sign-in in flight, by its own state, in memory only. type PendingAuths struct { mu sync.Mutex now func() time.Time byState map[string]PendingAuth } // NewPendingAuths is an empty PendingAuths; now is time.Now unless a test gives its own. func NewPendingAuths(now func() time.Time) *PendingAuths { if now == nil { now = time.Now } return &PendingAuths{now: now, byState: map[string]PendingAuth{}} } // Create remembers one sign-in in flight, answering the state it is kept under. func (p *PendingAuths) Create(verifier, returnTo string) (state string, err error) { state, err = randomID() if err != nil { return "", err } p.mu.Lock() defer p.mu.Unlock() p.byState[state] = PendingAuth{Verifier: verifier, Return: returnTo, Created: p.now()} return state, nil } // Take reads back and removes the sign-in kept under state — once; a state never issued, already taken, or // older than PendingLifetime answers false (1263/AC4's "a state it did not issue"). func (p *PendingAuths) Take(state string) (PendingAuth, bool) { if state == "" { return PendingAuth{}, false } p.mu.Lock() defer p.mu.Unlock() pa, ok := p.byState[state] if !ok { return PendingAuth{}, false } delete(p.byState, state) if p.now().Sub(pa.Created) > PendingLifetime { return PendingAuth{}, false } return pa, true }