1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app_test
4
5import (
6 "context"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/golang-jwt/jwt/v5"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15
16 "bnema.dev/ermite/internal/app"
17 "bnema.dev/ermite/internal/domain"
18)
19
20// memSessions is an in-memory ports.SessionStore.
21type memSessions struct {
22 mu sync.Mutex
23 key []byte
24 epoch uint64
25}
26
27func (m *memSessions) Key(context.Context) ([]byte, error) { return m.key, nil }
28func (m *memSessions) Epoch(context.Context) (uint64, error) {
29 m.mu.Lock()
30 defer m.mu.Unlock()
31 return m.epoch, nil
32}
33
34func (m *memSessions) BumpEpoch(context.Context) (uint64, error) {
35 m.mu.Lock()
36 defer m.mu.Unlock()
37 m.epoch++
38 return m.epoch, nil
39}
40
41// testClock is a clock the test moves.
42type testClock struct {
43 mu sync.Mutex
44 t time.Time
45}
46
47func (c *testClock) Now() time.Time {
48 c.mu.Lock()
49 defer c.mu.Unlock()
50 return c.t
51}
52
53func (c *testClock) advance(d time.Duration) {
54 c.mu.Lock()
55 defer c.mu.Unlock()
56 c.t = c.t.Add(d)
57}
58
59type sessionFixture struct {
60 store *memSessions
61 clock *testClock
62 sessions *app.Sessions
63 key []byte
64}
65
66func newSessionFixture(t *testing.T) *sessionFixture {
67 t.Helper()
68 f := &sessionFixture{
69 store: &memSessions{key: []byte("0123456789abcdef0123456789abcdef")},
70 clock: &testClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)},
71 }
72 f.key = f.store.key
73 s, err := app.NewSessions(context.Background(), f.store, f.clock, "https://forge.example")
74 require.NoError(t, err)
75 f.sessions = s
76 return f
77}
78
79func (f *sessionFixture) nonce(t *testing.T) string {
80 t.Helper()
81 link, err := f.sessions.CreateLoginLink(context.Background(), domain.Root())
82 require.NoError(t, err)
83 n, ok := strings.CutPrefix(link.URL, "https://forge.example/-/login/")
84 require.True(t, ok, link.URL)
85 return n
86}
87
88func allScopeToken(repos ...domain.RepoName) domain.Principal {
89 return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), Repos: repos})
90}
91
92// Only the owner creates a link: root, or a token with every scope and no
93// repository list.
94func TestLoginLinkIsOwnerOnly(t *testing.T) {
95 ctx := context.Background()
96 f := newSessionFixture(t)
97 partial := domain.TokenPrincipal(domain.Token{ID: "p", Scopes: domain.AllScopes()[:8]})
98 limited := allScopeToken(mustRepo(t, "a"))
99
100 for name, tc := range map[string]struct {
101 p domain.Principal
102 err error
103 }{
104 "root": {domain.Root(), nil},
105 "owner session": {domain.Owner(), nil},
106 "every scope": {allScopeToken(), nil},
107 "one scope short": {partial, domain.ErrForbidden},
108 "repo list": {limited, domain.ErrForbidden},
109 "anonymous": {domain.Anonymous(), domain.ErrUnauthenticated},
110 } {
111 _, err := f.sessions.CreateLoginLink(ctx, tc.p)
112 if tc.err == nil {
113 assert.NoError(t, err, name)
114 } else {
115 assert.ErrorIs(t, err, tc.err, name)
116 }
117 err = f.sessions.SignOutEverywhere(ctx, tc.p)
118 if tc.err == nil {
119 assert.NoError(t, err, name)
120 } else {
121 assert.ErrorIs(t, err, tc.err, name)
122 }
123 }
124}
125
126func mustRepo(t *testing.T, s string) domain.RepoName {
127 t.Helper()
128 n, err := domain.ParseRepoName(s)
129 require.NoError(t, err)
130 return n
131}
132
133// AC1: checking a link does not use it; logging in uses it once; an expired or
134// replayed link fails.
135func TestLoginLinkLifecycle(t *testing.T) {
136 ctx := context.Background()
137 f := newSessionFixture(t)
138
139 link, err := f.sessions.CreateLoginLink(ctx, domain.Root())
140 require.NoError(t, err)
141 assert.Equal(t, f.clock.Now().Add(app.LoginLinkTTL), link.ExpiresAt)
142 nonce := strings.TrimPrefix(link.URL, "https://forge.example/-/login/")
143 assert.Len(t, nonce, 43, "256 bits")
144
145 for range 3 {
146 assert.True(t, f.sessions.LoginLinkValid(nonce), "looking never consumes")
147 }
148 token, exp, err := f.sessions.Login(ctx, nonce)
149 require.NoError(t, err)
150 assert.NotEmpty(t, token)
151 assert.Equal(t, f.clock.Now().Add(app.SessionTTL), exp)
152 assert.False(t, f.sessions.LoginLinkValid(nonce))
153 _, _, err = f.sessions.Login(ctx, nonce)
154 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "replay")
155
156 // Expired.
157 nonce = f.nonce(t)
158 f.clock.advance(app.LoginLinkTTL)
159 assert.False(t, f.sessions.LoginLinkValid(nonce))
160 _, _, err = f.sessions.Login(ctx, nonce)
161 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired")
162
163 // Unknown.
164 _, _, err = f.sessions.Login(ctx, strings.Repeat("a", 43))
165 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
166
167 // Two links are independent.
168 a, b := f.nonce(t), f.nonce(t)
169 assert.NotEqual(t, a, b)
170 _, _, err = f.sessions.Login(ctx, a)
171 require.NoError(t, err)
172 assert.True(t, f.sessions.LoginLinkValid(b))
173}
174
175// GO-003a: the session of a link made with a token never outlives that token;
176// root and a token that never expires leave it at SessionTTL.
177func TestSessionIsCappedByTheTokenThatMadeTheLink(t *testing.T) {
178 ctx := context.Background()
179 f := newSessionFixture(t)
180 tokenExpiring := func(in time.Duration) domain.Principal {
181 return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), ExpiresAt: f.clock.Now().Add(in)})
182 }
183 login := func(p domain.Principal) (string, time.Time, error) {
184 link, err := f.sessions.CreateLoginLink(ctx, p)
185 require.NoError(t, err)
186 return f.sessions.Login(ctx, strings.TrimPrefix(link.URL, "https://forge.example/-/login/"))
187 }
188 start := f.clock.Now()
189
190 for name, tc := range map[string]struct {
191 p domain.Principal
192 want time.Time
193 }{
194 "root": {domain.Root(), start.Add(app.SessionTTL)},
195 "owner session": {domain.Owner(), start.Add(app.SessionTTL)},
196 "never expires": {allScopeToken(), start.Add(app.SessionTTL)},
197 "expires sooner": {tokenExpiring(2 * time.Hour), start.Add(2 * time.Hour)},
198 "expires later": {tokenExpiring(48 * time.Hour), start.Add(app.SessionTTL)},
199 "expires exactly TTL": {tokenExpiring(app.SessionTTL), start.Add(app.SessionTTL)},
200 } {
201 tok, exp, err := login(tc.p)
202 require.NoError(t, err, name)
203 assert.Equal(t, tc.want, exp, name)
204 _, err = f.sessions.Verify(ctx, tok)
205 assert.NoError(t, err, name)
206 // The session token itself carries the cap as its exp claim.
207 var c claims
208 _, _, err = jwt.NewParser().ParseUnverified(tok, &c)
209 require.NoError(t, err, name)
210 assert.Equal(t, tc.want, c.ExpiresAt.UTC(), name)
211 }
212
213 // It stops working when the token would have.
214 tok, _, err := login(tokenExpiring(2 * time.Hour))
215 require.NoError(t, err)
216 f.clock.advance(2*time.Hour - time.Second)
217 _, err = f.sessions.Verify(ctx, tok)
218 assert.NoError(t, err)
219 f.clock.advance(time.Second)
220 _, err = f.sessions.Verify(ctx, tok)
221 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the token expired, so did the session")
222
223 // A token that expires before the link is used opens nothing.
224 link, err := f.sessions.CreateLoginLink(ctx, tokenExpiring(30*time.Second))
225 require.NoError(t, err)
226 f.clock.advance(31 * time.Second)
227 _, _, err = f.sessions.Login(ctx, strings.TrimPrefix(link.URL, "https://forge.example/-/login/"))
228 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
229}
230
231// A single use also holds under concurrent confirmations.
232func TestLoginLinkConsumedOnceConcurrently(t *testing.T) {
233 f := newSessionFixture(t)
234 nonce := f.nonce(t)
235 var wg sync.WaitGroup
236 var mu sync.Mutex
237 won := 0
238 for range 16 {
239 wg.Add(1)
240 go func() {
241 defer wg.Done()
242 if _, _, err := f.sessions.Login(context.Background(), nonce); err == nil {
243 mu.Lock()
244 won++
245 mu.Unlock()
246 }
247 }()
248 }
249 wg.Wait()
250 assert.Equal(t, 1, won)
251}
252
253func TestLoginLinksAreBounded(t *testing.T) {
254 ctx := context.Background()
255 f := newSessionFixture(t)
256 for range app.MaxPendingLoginLinks {
257 f.nonce(t)
258 }
259 _, err := f.sessions.CreateLoginLink(ctx, domain.Root())
260 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
261
262 f.clock.advance(app.LoginLinkTTL)
263 _, err = f.sessions.CreateLoginLink(ctx, domain.Root())
264 assert.NoError(t, err, "expired links make room")
265}
266
267func sign(t *testing.T, m jwt.SigningMethod, key any, claims jwt.Claims) string {
268 t.Helper()
269 s, err := jwt.NewWithClaims(m, claims).SignedString(key)
270 require.NoError(t, err)
271 return s
272}
273
274type claims struct {
275 Epoch *uint64 `json:"epoch"`
276 jwt.RegisteredClaims
277}
278
279// AC1: a valid session is the owner; none, alg none, another algorithm, another
280// key, an old epoch, expiry, a missing claim and garbage are rejected.
281func TestSessionTokenVerification(t *testing.T) {
282 ctx := context.Background()
283 f := newSessionFixture(t)
284 now := f.clock.Now()
285 zero, one := uint64(0), uint64(1)
286 reg := func(sub string, iat, exp time.Time) jwt.RegisteredClaims {
287 return jwt.RegisteredClaims{Subject: sub, IssuedAt: jwt.NewNumericDate(iat), ExpiresAt: jwt.NewNumericDate(exp)}
288 }
289 good := reg("owner", now, now.Add(time.Hour))
290
291 token, _, err := f.sessions.Login(ctx, f.nonce(t))
292 require.NoError(t, err)
293 p, err := f.sessions.Verify(ctx, token)
294 require.NoError(t, err)
295 assert.True(t, p.IsOwner())
296 assert.Equal(t, "owner", p.Label())
297
298 cases := map[string]string{
299 "alg none": sign(t, jwt.SigningMethodNone, jwt.UnsafeAllowNoneSignatureType, claims{&zero, good}),
300 "hs384": sign(t, jwt.SigningMethodHS384, f.key, claims{&zero, good}),
301 "hs512": sign(t, jwt.SigningMethodHS512, f.key, claims{&zero, good}),
302 "wrong key": sign(t, jwt.SigningMethodHS256, []byte("another key of 32 bytes.........."), claims{&zero, good}),
303 "old epoch": sign(t, jwt.SigningMethodHS256, f.key, claims{&one, good}),
304 "no epoch": sign(t, jwt.SigningMethodHS256, f.key, claims{nil, good}),
305 "expired": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("owner", now.Add(-2*time.Hour), now.Add(-time.Hour))}),
306 "no expiry": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, jwt.RegisteredClaims{Subject: "owner", IssuedAt: jwt.NewNumericDate(now)}}),
307 "future iat": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("owner", now.Add(time.Hour), now.Add(2*time.Hour))}),
308 "other subject": sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("someone", now, now.Add(time.Hour))}),
309 "empty": "",
310 "garbage": "not.a.jwt",
311 "truncated": token[:len(token)-3],
312 "signature swap": token[:strings.LastIndex(token, ".")] + ".AAAA",
313 "payload only": strings.Split(token, ".")[1],
314 "three empty": "..",
315 }
316 for name, tok := range cases {
317 _, err := f.sessions.Verify(ctx, tok)
318 assert.ErrorIs(t, err, domain.ErrUnauthenticated, name)
319 }
320 // The control: the same claims signed right are accepted.
321 _, err = f.sessions.Verify(ctx, sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, good}))
322 assert.NoError(t, err)
323
324 // Expiry after the session lifetime; and the epoch ends every session.
325 f.clock.advance(app.SessionTTL)
326 _, err = f.sessions.Verify(ctx, token)
327 assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h")
328}
329
330func TestSignOutEverywhereEndsSessions(t *testing.T) {
331 ctx := context.Background()
332 f := newSessionFixture(t)
333 a, _, err := f.sessions.Login(ctx, f.nonce(t))
334 require.NoError(t, err)
335 b, _, err := f.sessions.Login(ctx, f.nonce(t))
336 require.NoError(t, err)
337 pending := f.nonce(t)
338
339 require.NoError(t, f.sessions.SignOutEverywhere(ctx, domain.Root()))
340 for _, tok := range []string{a, b} {
341 _, err := f.sessions.Verify(ctx, tok)
342 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
343 }
344 assert.True(t, f.sessions.LoginLinkValid(pending), "pending links are not affected")
345 c, _, err := f.sessions.Login(ctx, pending)
346 require.NoError(t, err)
347 _, err = f.sessions.Verify(ctx, c)
348 assert.NoError(t, err, "a new session works")
349}
350
351// The owner signed in on the web signs what it writes as "owner", where root
352// signs as "root" although both hold every right.
353func TestAuthorOfOwnerSession(t *testing.T) {
354 assert.Equal(t, "owner", app.AuthorOf(domain.Owner()))
355 assert.Equal(t, "root", app.AuthorOf(domain.Root()))
356 assert.Equal(t, "ci", app.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()})))
357}