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