v0.6.0 d5cc8fe

sessions.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close