1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "crypto/rand"
8 "crypto/sha256"
9 "encoding/base64"
10 "errors"
11 "fmt"
12 "sync"
13 "time"
14
15 "github.com/bnema/zerowrap"
16 "github.com/golang-jwt/jwt/v5"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// Web login (ADR 0018). The owner asks for a one-time link with the API, opens
23// it in a browser and confirms; the confirmation trades the link for a signed
24// session token that the web keeps in an HttpOnly cookie.
25const (
26 // LoginLinkTTL is how long a login link works.
27 LoginLinkTTL = 60 * time.Second
28 // MaxPendingLoginLinks bounds the links that wait to be used, so that a
29 // loop of CreateLoginLink cannot grow the memory.
30 MaxPendingLoginLinks = 16
31 // SessionTTL is the lifetime of a session.
32 SessionTTL = 12 * time.Hour
33
34 // loginNonceBytes is the size of the secret in a link: 256 bits.
35 loginNonceBytes = 32
36 // sessionSubject is the "sub" claim of every session: there is one owner.
37 sessionSubject = "owner"
38 // LoginPath is the path prefix of login links on the public URL.
39 LoginPath = "/-/login/"
40)
41
42// LoginLink is a login link as the owner receives it.
43type LoginLink struct {
44 // URL holds the secret: show it once, never log it.
45 URL string
46 ExpiresAt time.Time
47}
48
49// Sessions are the use cases of the owner's browser sessions: login links,
50// session tokens and signing out everywhere. Login links live in memory only,
51// so a restart voids them; the signing key and the epoch are in the store.
52type Sessions struct {
53 store ports.SessionStore
54 clock ports.Clock
55 publicURL string
56 key []byte
57
58 mu sync.Mutex
59 links map[[sha256.Size]byte]pendingLink // by hash of the nonce
60}
61
62// pendingLink is a login link that waits to be used.
63type pendingLink struct {
64 expires time.Time
65 // sessionCap is the latest moment the session it opens may last until: the
66 // expiry of the token that created the link. Zero means no cap.
67 sessionCap time.Time
68}
69
70// NewSessions builds the session use cases and loads the signing key, which
71// the store generates on first start. publicURL is the base URL the login
72// links are built on.
73func NewSessions(ctx context.Context, store ports.SessionStore, clock ports.Clock, publicURL string) (*Sessions, error) {
74 key, err := store.Key(ctx)
75 if err != nil {
76 return nil, fmt.Errorf("load session key: %w", err)
77 }
78 return &Sessions{store: store, clock: clock, publicURL: publicURL, key: key, links: map[[sha256.Size]byte]pendingLink{}}, nil
79}
80
81// requireOwner is the access rule of the session API: root, or a stored token
82// with every scope and no repository list.
83func requireOwner(p domain.Principal) error {
84 switch {
85 case p.IsAnonymous():
86 return domain.ErrUnauthenticated
87 case !p.HasOwnerRights():
88 return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope and no repository list", domain.ErrForbidden)
89 }
90 return nil
91}
92
93// CreateLoginLink makes a link valid for LoginLinkTTL and usable once. Only
94// the owner may. The session the link opens never outlives the token that
95// created it: it is capped at that token's expiry (no cap for root, or a token
96// that never expires). It gives domain.ErrResourceExhausted when MaxPendingLoginLinks
97// links wait. The nonce is never logged.
98func (s *Sessions) CreateLoginLink(ctx context.Context, p domain.Principal) (LoginLink, error) {
99 if err := requireOwner(p); err != nil {
100 return LoginLink{}, err
101 }
102 var raw [loginNonceBytes]byte
103 if _, err := rand.Read(raw[:]); err != nil {
104 return LoginLink{}, fmt.Errorf("generate login link: %w", err)
105 }
106 nonce := base64.RawURLEncoding.EncodeToString(raw[:])
107 now := s.clock.Now()
108 exp := now.Add(LoginLinkTTL)
109
110 s.mu.Lock()
111 for h, l := range s.links {
112 if !now.Before(l.expires) {
113 delete(s.links, h)
114 }
115 }
116 if len(s.links) >= MaxPendingLoginLinks {
117 s.mu.Unlock()
118 return LoginLink{}, fmt.Errorf("%w: %d login links are already pending, use one or wait a minute", domain.ErrResourceExhausted, MaxPendingLoginLinks)
119 }
120 s.links[sha256.Sum256([]byte(nonce))] = pendingLink{expires: exp, sessionCap: p.ExpiresAt()}
121 s.mu.Unlock()
122
123 log := zerowrap.FromCtx(ctx)
124 log.Info().Str("created_by", p.Label()).Str("created_by_token", p.TokenID()).Time("expires_at", exp).Msg("login link created")
125 return LoginLink{URL: s.publicURL + LoginPath + nonce, ExpiresAt: exp.UTC()}, nil
126}
127
128// LoginLinkValid reports whether the nonce is a pending link, without using
129// it: opening a link shows a confirmation, only confirming consumes it.
130func (s *Sessions) LoginLinkValid(nonce string) bool {
131 h := sha256.Sum256([]byte(nonce))
132 s.mu.Lock()
133 defer s.mu.Unlock()
134 l, ok := s.links[h]
135 return ok && s.clock.Now().Before(l.expires)
136}
137
138// Login consumes the link and opens a session: it returns the signed session
139// token, for the cookie, and when it expires. A link that is unknown, expired
140// or already used gives domain.ErrUnauthenticated, whatever the reason. The
141// nonce and the token are never logged. The session lasts SessionTTL, or until
142// the expiry of the token that created the link when that comes first; a link
143// whose token has expired since gives domain.ErrUnauthenticated.
144func (s *Sessions) Login(ctx context.Context, nonce string) (token string, expiresAt time.Time, err error) {
145 h := sha256.Sum256([]byte(nonce))
146 s.mu.Lock()
147 l, ok := s.links[h]
148 delete(s.links, h) // single use, also when expired
149 s.mu.Unlock()
150 if !ok || !s.clock.Now().Before(l.expires) {
151 return "", time.Time{}, domain.ErrUnauthenticated
152 }
153 token, expiresAt, err = s.issue(ctx, l.sessionCap)
154 if err != nil {
155 return "", time.Time{}, err
156 }
157 log := zerowrap.FromCtx(ctx)
158 log.Info().Time("expires_at", expiresAt).Msg("owner signed in on the web")
159 return token, expiresAt, nil
160}
161
162// sessionClaims are the claims of a session token. The epoch is a pointer so
163// that a token without one is not taken for epoch 0.
164type sessionClaims struct {
165 Epoch *uint64 `json:"epoch"`
166 jwt.RegisteredClaims
167}
168
169// issue signs a session for SessionTTL, cut at capAt unless that is zero.
170func (s *Sessions) issue(ctx context.Context, capAt time.Time) (string, time.Time, error) {
171 epoch, err := s.store.Epoch(ctx)
172 if err != nil {
173 return "", time.Time{}, fmt.Errorf("read session epoch: %w", err)
174 }
175 now := s.clock.Now().UTC()
176 exp := now.Add(SessionTTL)
177 if !capAt.IsZero() && capAt.Before(exp) {
178 exp = capAt.UTC()
179 }
180 if !exp.Truncate(time.Second).After(now) {
181 return "", time.Time{}, domain.ErrUnauthenticated
182 }
183 tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{
184 Epoch: &epoch,
185 RegisteredClaims: jwt.RegisteredClaims{
186 Subject: sessionSubject,
187 IssuedAt: jwt.NewNumericDate(now),
188 ExpiresAt: jwt.NewNumericDate(exp),
189 },
190 }).SignedString(s.key)
191 if err != nil {
192 return "", time.Time{}, fmt.Errorf("sign session: %w", err)
193 }
194 return tok, exp.Truncate(time.Second), nil
195}
196
197// Verify checks a session token and returns the owner. The signature must be
198// HS256 with the session key (the algorithm is pinned, so "none" and any other
199// are refused), the token must not be expired or issued in the future, and its
200// epoch must be the current one. Any such failure gives
201// domain.ErrUnauthenticated; a store failure is returned as it is, so that the
202// caller fails closed.
203func (s *Sessions) Verify(ctx context.Context, token string) (domain.Principal, error) {
204 var c sessionClaims
205 _, err := jwt.ParseWithClaims(token, &c, func(*jwt.Token) (any, error) { return s.key, nil },
206 jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
207 jwt.WithExpirationRequired(),
208 jwt.WithIssuedAt(),
209 jwt.WithSubject(sessionSubject),
210 jwt.WithTimeFunc(s.clock.Now),
211 )
212 if err != nil || c.Epoch == nil {
213 return domain.Principal{}, domain.ErrUnauthenticated
214 }
215 epoch, err := s.store.Epoch(ctx)
216 if err != nil {
217 return domain.Principal{}, fmt.Errorf("read session epoch: %w", err)
218 }
219 if *c.Epoch != epoch {
220 return domain.Principal{}, domain.ErrUnauthenticated
221 }
222 return domain.Owner(), nil
223}
224
225// SignOutEverywhere ends every session by incrementing the epoch. Only the
226// owner may. Pending login links stay valid.
227func (s *Sessions) SignOutEverywhere(ctx context.Context, p domain.Principal) error {
228 if err := requireOwner(p); err != nil {
229 return err
230 }
231 epoch, err := s.store.BumpEpoch(ctx)
232 if err != nil {
233 if errors.Is(err, domain.ErrConflict) {
234 return err
235 }
236 return fmt.Errorf("sign out everywhere: %w", err)
237 }
238 log := zerowrap.FromCtx(ctx)
239 log.Info().Str("by", p.Label()).Str("by_token", p.TokenID()).Uint64("epoch", epoch).Msg("every web session signed out")
240 return nil
241}