@johnnyboy11234/esg-idea-contest 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ package auth_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "log/slog"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/go-chi/chi/v5"
13
+ "github.com/google/uuid"
14
+
15
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
16
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
17
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/web"
18
+ )
19
+
20
+ type fakeValidator struct {
21
+ live map[string]security.Principal
22
+ err error
23
+ calls int
24
+ }
25
+
26
+ func (f *fakeValidator) Validate(_ context.Context, id string) (security.Principal, bool, error) {
27
+ f.calls++
28
+ if f.err != nil {
29
+ return security.Principal{}, false, f.err
30
+ }
31
+ p, ok := f.live[id]
32
+ return p, ok, nil
33
+ }
34
+
35
+ func isPublic(r *http.Request) bool {
36
+ return r.Method == "GET" && r.URL.Path == "/open"
37
+ }
38
+
39
+ // chain mirrors production: middleware wraps the router, whose NotFound and
40
+ // MethodNotAllowed hooks are the web ones.
41
+ func chain(v auth.Validator, seen *security.Principal) http.Handler {
42
+ r := chi.NewRouter()
43
+ r.NotFound(web.NotFound)
44
+ r.MethodNotAllowed(web.MethodNotAllowed)
45
+ h := func(w http.ResponseWriter, req *http.Request) {
46
+ if p, ok := security.PrincipalFrom(req.Context()); ok && seen != nil {
47
+ *seen = p
48
+ }
49
+ w.WriteHeader(http.StatusNoContent)
50
+ }
51
+ r.Get("/open", h)
52
+ r.Get("/closed", h)
53
+ return web.RequestID(auth.Middleware(v, isPublic, slog.New(slog.DiscardHandler))(r))
54
+ }
55
+
56
+ func request(method, path, cookie string) *http.Request {
57
+ req := httptest.NewRequest(method, path, nil)
58
+ if cookie != "" {
59
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: cookie})
60
+ }
61
+ return req
62
+ }
63
+
64
+ func TestMiddlewareCombinations(t *testing.T) {
65
+ user := security.Principal{UserID: uuid.New(), EmpNo: "U1", Role: security.RoleUser}
66
+ v := &fakeValidator{live: map[string]security.Principal{"good": user}}
67
+
68
+ tests := []struct {
69
+ name string
70
+ method, path string
71
+ cookie string
72
+ status int
73
+ code string
74
+ principal bool
75
+ }{
76
+ {"public, no session", "GET", "/open", "", 204, "", false},
77
+ {"public, valid session", "GET", "/open", "good", 204, "", true},
78
+ {"public, invalid session is anonymous", "GET", "/open", "stale", 204, "", false},
79
+ {"public path, other method, no session", "POST", "/open", "", 401, "UNAUTHENTICATED", false},
80
+ {"public path, other method, session", "POST", "/open", "good", 405, "METHOD_NOT_ALLOWED", false},
81
+ {"protected, no session", "GET", "/closed", "", 401, "UNAUTHENTICATED", false},
82
+ {"protected, invalid session", "GET", "/closed", "stale", 401, "UNAUTHENTICATED", false},
83
+ {"protected, valid session", "GET", "/closed", "good", 204, "", true},
84
+ {"unknown path, no session", "GET", "/nope", "", 401, "UNAUTHENTICATED", false},
85
+ {"unknown path, invalid session", "GET", "/nope", "stale", 401, "UNAUTHENTICATED", false},
86
+ {"unknown path, session", "GET", "/nope", "good", 404, "NOT_FOUND", false},
87
+ }
88
+ for _, tc := range tests {
89
+ t.Run(tc.name, func(t *testing.T) {
90
+ var seen security.Principal
91
+ rec := httptest.NewRecorder()
92
+ chain(v, &seen).ServeHTTP(rec, request(tc.method, tc.path, tc.cookie))
93
+ if rec.Code != tc.status {
94
+ t.Fatalf("status = %d, want %d (%s)", rec.Code, tc.status, rec.Body)
95
+ }
96
+ if tc.code != "" && !strings.Contains(rec.Body.String(), `"code":"`+tc.code+`"`) {
97
+ t.Errorf("body %s lacks code %s", rec.Body, tc.code)
98
+ }
99
+ if tc.code != "" && rec.Header().Get("Content-Type") != web.ProblemContentType {
100
+ t.Errorf("Content-Type = %q", rec.Header().Get("Content-Type"))
101
+ }
102
+ if got := seen == user; got != tc.principal {
103
+ t.Errorf("principal in context = %v, want %v", got, tc.principal)
104
+ }
105
+ })
106
+ }
107
+ }
108
+
109
+ func TestMiddlewareNoCookieSkipsStore(t *testing.T) {
110
+ v := &fakeValidator{}
111
+ chain(v, nil).ServeHTTP(httptest.NewRecorder(), request("GET", "/open", ""))
112
+ // An empty cookie value is the same as none.
113
+ req := httptest.NewRequest("GET", "/open", nil)
114
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: ""})
115
+ chain(v, nil).ServeHTTP(httptest.NewRecorder(), req)
116
+ if v.calls != 0 {
117
+ t.Errorf("store called %d times without a cookie value", v.calls)
118
+ }
119
+ }
120
+
121
+ func TestMiddlewareIgnoresOtherCookies(t *testing.T) {
122
+ v := &fakeValidator{live: map[string]security.Principal{"good": {Role: security.RoleUser}}}
123
+ req := httptest.NewRequest("GET", "/closed", nil)
124
+ req.AddCookie(&http.Cookie{Name: "session", Value: "good"})
125
+ rec := httptest.NewRecorder()
126
+ chain(v, nil).ServeHTTP(rec, req)
127
+ if rec.Code != 401 || v.calls != 0 {
128
+ t.Errorf("status %d, store calls %d", rec.Code, v.calls)
129
+ }
130
+ }
131
+
132
+ func TestMiddlewareStoreFailureIs500NotPublicFallthrough(t *testing.T) {
133
+ v := &fakeValidator{err: errors.New("db down")}
134
+ for _, path := range []string{"/open", "/closed", "/nope"} {
135
+ rec := httptest.NewRecorder()
136
+ chain(v, nil).ServeHTTP(rec, request("GET", path, "good"))
137
+ if rec.Code != 500 || !strings.Contains(rec.Body.String(), "INTERNAL_ERROR") {
138
+ t.Errorf("%s: status %d body %s", path, rec.Code, rec.Body)
139
+ }
140
+ if strings.Contains(rec.Body.String(), "db down") {
141
+ t.Errorf("%s: cause leaked: %s", path, rec.Body)
142
+ }
143
+ }
144
+ }
145
+
146
+ func TestMiddlewareNilPublicMeansAllProtected(t *testing.T) {
147
+ h := auth.Middleware(&fakeValidator{}, nil, slog.New(slog.DiscardHandler))(http.NotFoundHandler())
148
+ rec := httptest.NewRecorder()
149
+ h.ServeHTTP(rec, request("GET", "/open", ""))
150
+ if rec.Code != 401 {
151
+ t.Errorf("status = %d", rec.Code)
152
+ }
153
+ }
@@ -5,6 +5,7 @@ import (
5
5
 
6
6
  "github.com/brian-huanggg/AwardRegistration/backend/internal/account"
7
7
  "github.com/brian-huanggg/AwardRegistration/backend/internal/api"
8
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/auth"
8
9
  "github.com/brian-huanggg/AwardRegistration/backend/internal/deadline"
9
10
  )
10
11
 
@@ -12,6 +13,7 @@ import (
12
13
  // module has not landed yet stay api.Unimplemented (501).
13
14
  type API struct {
14
15
  api.Unimplemented
16
+ Auth auth.Handler
15
17
  Account account.Handler
16
18
  Deadline deadline.Handler
17
19
  }
@@ -23,3 +25,6 @@ func (a API) GetDeadline(w http.ResponseWriter, r *http.Request) { a.Deadline.Ge
23
25
  func (a API) Register(w http.ResponseWriter, r *http.Request, _ api.RegisterParams) {
24
26
  a.Account.Register(w, r)
25
27
  }
28
+
29
+ // GetCsrfToken serves GET /auth/csrf.
30
+ func (a API) GetCsrfToken(w http.ResponseWriter, r *http.Request) { a.Auth.GetCsrfToken(w, r) }
@@ -0,0 +1,35 @@
1
+ package server
2
+
3
+ import "net/http"
4
+
5
+ // PublicRoutes is the explicit list of operations that need no session
6
+ // (Backend.md §3). It must equal the operations marked `security: []` in
7
+ // api/openapi.yaml; a test locks that. B1-10 adds POST /auth/login to both. Paths include the /api/v1 prefix.
8
+ var PublicRoutes = []struct{ Method, Path string }{
9
+ {http.MethodGet, "/api/v1/auth/csrf"},
10
+ {http.MethodPost, "/api/v1/auth/register"},
11
+ {http.MethodPost, "/api/v1/auth/logout"},
12
+ {http.MethodGet, "/api/v1/deadline"},
13
+ }
14
+
15
+ // IsPublic reports whether r is exactly one of PublicRoutes. A public path with
16
+ // another method is not public: without a session it answers 401, with one 405.
17
+ func IsPublic(r *http.Request) bool {
18
+ for _, p := range PublicRoutes {
19
+ if r.Method == p.Method && r.URL.Path == p.Path {
20
+ return true
21
+ }
22
+ }
23
+ return false
24
+ }
25
+
26
+ // WriteActions maps the writes that exist today to their audit action, so a
27
+ // CSRF denial is recorded (PRD §6.11). Later PRs add their routes here, with
28
+ // the same exact-path rule; a write on an unknown path is not audited.
29
+ var WriteActions = map[string]string{
30
+ http.MethodPost + " /api/v1/auth/register": "AUTH_REGISTER",
31
+ http.MethodPost + " /api/v1/auth/logout": "AUTH_LOGOUT",
32
+ }
33
+
34
+ // ActionFor returns the audit action of r, or "" when unknown.
35
+ func ActionFor(r *http.Request) string { return WriteActions[r.Method+" "+r.URL.Path] }
@@ -0,0 +1,73 @@
1
+ package server_test
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "slices"
8
+ "testing"
9
+
10
+ "github.com/getkin/kin-openapi/openapi3"
11
+
12
+ "github.com/brian-huanggg/AwardRegistration/backend/internal/server"
13
+ )
14
+
15
+ // The public list in code must equal the operations the spec marks
16
+ // `security: []`; otherwise the contract and the middleware disagree.
17
+ func TestPublicRoutesMatchOpenAPI(t *testing.T) {
18
+ doc, err := openapi3.NewLoader().LoadFromFile("../../api/openapi.yaml")
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ var want []string
23
+ for path, item := range doc.Paths.Map() {
24
+ for method, op := range item.Operations() {
25
+ if op.Security != nil && len(*op.Security) == 0 {
26
+ want = append(want, method+" /api/v1"+path)
27
+ }
28
+ }
29
+ }
30
+ var got []string
31
+ for _, p := range server.PublicRoutes {
32
+ got = append(got, p.Method+" "+p.Path)
33
+ }
34
+ slices.Sort(want)
35
+ slices.Sort(got)
36
+ if !slices.Equal(want, got) {
37
+ t.Errorf("PublicRoutes\n got %v\n want %v (spec, security: [])", got, want)
38
+ }
39
+ }
40
+
41
+ func TestIsPublicNeedsExactMethodAndPath(t *testing.T) {
42
+ tests := []struct {
43
+ method, path string
44
+ want bool
45
+ }{
46
+ {"GET", "/api/v1/auth/csrf", true},
47
+ {"POST", "/api/v1/auth/logout", true},
48
+ {"POST", "/api/v1/auth/csrf", false},
49
+ {"HEAD", "/api/v1/deadline", false},
50
+ {"GET", "/api/v1/auth/csrf/", false},
51
+ {"GET", "/api/v1/auth/me", false},
52
+ {"GET", "/api/v1/auth/../ideas", false},
53
+ {"GET", "/auth/csrf", false},
54
+ }
55
+ for _, tc := range tests {
56
+ req := httptest.NewRequestWithContext(context.Background(), tc.method, "http://x"+tc.path, nil)
57
+ if got := server.IsPublic(req); got != tc.want {
58
+ t.Errorf("%s %s = %v, want %v", tc.method, tc.path, got, tc.want)
59
+ }
60
+ }
61
+ }
62
+
63
+ func TestActionForCoversPublicWrites(t *testing.T) {
64
+ for _, p := range server.PublicRoutes {
65
+ if p.Method == http.MethodGet {
66
+ continue
67
+ }
68
+ r := httptest.NewRequest(p.Method, p.Path, nil)
69
+ if server.ActionFor(r) == "" {
70
+ t.Errorf("%s %s has no audit action for a CSRF denial", p.Method, p.Path)
71
+ }
72
+ }
73
+ }
@@ -22,8 +22,8 @@ const (
22
22
 
23
23
  // NewPublic builds the public router. Module routes are mounted by `mount`
24
24
  // (nil in the skeleton) under the chain: request ID → access log → recover →
25
- // Accept check.
26
- func NewPublic(log *slog.Logger, mount func(chi.Router)) http.Handler {
25
+ // extra (the session middleware, so 401 beats 406) → Accept check.
26
+ func NewPublic(log *slog.Logger, mount func(chi.Router), extra ...func(http.Handler) http.Handler) http.Handler {
27
27
  r := chi.NewRouter()
28
28
  r.NotFound(web.NotFound)
29
29
  r.MethodNotAllowed(web.MethodNotAllowed)
@@ -32,7 +32,11 @@ func NewPublic(log *slog.Logger, mount func(chi.Router)) http.Handler {
32
32
  }
33
33
  // The chain wraps the router instead of using r.Use: chi skips r.Use
34
34
  // middleware (also for NotFound) while no route is registered.
35
- return web.RequestID(web.AccessLog(log)(web.Recover(log)(web.AcceptCheck(r))))
35
+ h := web.AcceptCheck(r)
36
+ for i := len(extra) - 1; i >= 0; i-- {
37
+ h = extra[i](h)
38
+ }
39
+ return web.RequestID(web.AccessLog(log)(web.Recover(log)(h)))
36
40
  }
37
41
 
38
42
  // Check reports whether one dependency is usable; a non-nil error makes
@@ -18,8 +18,16 @@ type Config struct {
18
18
  LogLevel slog.Level
19
19
  ShutdownTimeout time.Duration // must stay below terminationGracePeriodSeconds
20
20
  ReadyDrainDelay time.Duration // time readiness stays false before Shutdown starts
21
+ CSRFKey string // HMAC key that signs CSRF tokens (>= 32 bytes)
22
+ CSRFKeyPrevious string // optional: still verifies for 8 h after a rotation
21
23
  }
22
24
 
25
+ // DefaultCSRFKey is the throwaway key of the local profile only; every other
26
+ // profile must set ESG_CSRF_KEY.
27
+ const DefaultCSRFKey = "local-dev-csrf-key-not-a-secret-0000"
28
+
29
+ const minCSRFKeyBytes = 32
30
+
23
31
  // DefaultDSN matches docker-compose.yml (throwaway local credentials). The query
24
32
  // pins UTC and matched-row counts (Backend.md §2.3).
25
33
  const DefaultDSN = "esg:esg@tcp(localhost:3306)/esg?parseTime=true&loc=UTC&time_zone=%27%2B00%3A00%27&clientFoundRows=true"
@@ -33,16 +41,27 @@ func Load(getenv func(string) string) (Config, error) {
33
41
  return def
34
42
  }
35
43
  c := Config{
36
- Profile: get("ESG_PROFILE", "local"),
37
- PublicAddr: get("ESG_PUBLIC_ADDR", ":8080"),
38
- ManagementAddr: get("ESG_MANAGEMENT_ADDR", ":8081"),
39
- DBDSN: get("ESG_DB_DSN", DefaultDSN),
44
+ Profile: get("ESG_PROFILE", "local"),
45
+ PublicAddr: get("ESG_PUBLIC_ADDR", ":8080"),
46
+ ManagementAddr: get("ESG_MANAGEMENT_ADDR", ":8081"),
47
+ DBDSN: get("ESG_DB_DSN", DefaultDSN),
48
+ CSRFKey: getenv("ESG_CSRF_KEY"),
49
+ CSRFKeyPrevious: getenv("ESG_CSRF_KEY_PREVIOUS"),
40
50
  }
41
51
  switch c.Profile {
42
52
  case "local", "k3s", "dev":
43
53
  default:
44
54
  return Config{}, fmt.Errorf("ESG_PROFILE %q: want local, k3s or dev", c.Profile)
45
55
  }
56
+ if c.CSRFKey == "" && c.Profile == "local" {
57
+ c.CSRFKey = DefaultCSRFKey
58
+ }
59
+ if len(c.CSRFKey) < minCSRFKeyBytes {
60
+ return Config{}, fmt.Errorf("ESG_CSRF_KEY: need at least %d bytes", minCSRFKeyBytes)
61
+ }
62
+ if c.CSRFKeyPrevious != "" && len(c.CSRFKeyPrevious) < minCSRFKeyBytes {
63
+ return Config{}, fmt.Errorf("ESG_CSRF_KEY_PREVIOUS: need at least %d bytes", minCSRFKeyBytes)
64
+ }
46
65
  if err := c.LogLevel.UnmarshalText([]byte(strings.ToUpper(get("ESG_LOG_LEVEL", "info")))); err != nil {
47
66
  return Config{}, fmt.Errorf("ESG_LOG_LEVEL: %w", err)
48
67
  }
@@ -8,6 +8,8 @@ import (
8
8
  "github.com/brian-huanggg/AwardRegistration/backend/internal/shared/config"
9
9
  )
10
10
 
11
+ const key = "0123456789abcdef0123456789abcdef"
12
+
11
13
  func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
12
14
 
13
15
  func TestDefaults(t *testing.T) {
@@ -16,14 +18,14 @@ func TestDefaults(t *testing.T) {
16
18
  t.Fatal(err)
17
19
  }
18
20
  if c.Profile != "local" || c.PublicAddr != ":8080" || c.ManagementAddr != ":8081" ||
19
- c.LogLevel != slog.LevelInfo || c.ShutdownTimeout != 20*time.Second {
21
+ c.LogLevel != slog.LevelInfo || c.ShutdownTimeout != 20*time.Second || c.CSRFKey != config.DefaultCSRFKey {
20
22
  t.Fatalf("%+v", c)
21
23
  }
22
24
  }
23
25
 
24
26
  func TestOverrides(t *testing.T) {
25
27
  c, err := config.Load(env(map[string]string{
26
- "ESG_PROFILE": "dev", "ESG_LOG_LEVEL": "debug", "ESG_SHUTDOWN_TIMEOUT": "5s", "ESG_READY_DRAIN_DELAY": "1s",
28
+ "ESG_PROFILE": "dev", "ESG_CSRF_KEY": key, "ESG_CSRF_KEY_PREVIOUS": key + "2", "ESG_LOG_LEVEL": "debug", "ESG_SHUTDOWN_TIMEOUT": "5s", "ESG_READY_DRAIN_DELAY": "1s",
27
29
  }))
28
30
  if err != nil || c.Profile != "dev" || c.LogLevel != slog.LevelDebug || c.ReadyDrainDelay != time.Second {
29
31
  t.Fatalf("%+v %v", c, err)
@@ -32,12 +34,16 @@ func TestOverrides(t *testing.T) {
32
34
 
33
35
  func TestInvalid(t *testing.T) {
34
36
  for name, m := range map[string]map[string]string{
35
- "profile": {"ESG_PROFILE": "prod"},
36
- "log level": {"ESG_LOG_LEVEL": "loud"},
37
- "timeout": {"ESG_SHUTDOWN_TIMEOUT": "soon"},
38
- "zero timeout": {"ESG_SHUTDOWN_TIMEOUT": "0s"},
39
- "drain": {"ESG_READY_DRAIN_DELAY": "x"},
40
- "same address": {"ESG_PUBLIC_ADDR": ":9", "ESG_MANAGEMENT_ADDR": ":9"},
37
+ "profile": {"ESG_PROFILE": "prod"},
38
+ "log level": {"ESG_LOG_LEVEL": "loud"},
39
+ "timeout": {"ESG_SHUTDOWN_TIMEOUT": "soon"},
40
+ "zero timeout": {"ESG_SHUTDOWN_TIMEOUT": "0s"},
41
+ "drain": {"ESG_READY_DRAIN_DELAY": "x"},
42
+ "short csrf key": {"ESG_CSRF_KEY": "short"},
43
+ "short previous key": {"ESG_CSRF_KEY": key, "ESG_CSRF_KEY_PREVIOUS": "short"},
44
+ "dev needs csrf key": {"ESG_PROFILE": "dev"},
45
+ "k3s needs csrf key": {"ESG_PROFILE": "k3s"},
46
+ "same address": {"ESG_PUBLIC_ADDR": ":9", "ESG_MANAGEMENT_ADDR": ":9"},
41
47
  } {
42
48
  if _, err := config.Load(env(m)); err == nil {
43
49
  t.Errorf("%s: want error", name)
@@ -161,3 +161,16 @@ describe('accessibility', () => {
161
161
  expect((await axe(container)).violations).toEqual([]);
162
162
  });
163
163
  });
164
+
165
+ describe('global 401 (Frontend.md §5.2)', () => {
166
+ it('goes to /login?next=<current> with a session-ended notice and forgets the user', async () => {
167
+ const { router } = await open('/ideas?page=2', 'USER');
168
+ await screen.findByRole('heading', { level: 1 });
169
+ server.use(http.get(`${API}/ideas`, () => problem(401, 'UNAUTHENTICATED')));
170
+ const { apiFetch } = await import('@/shared/api');
171
+ await expect(apiFetch('/ideas')).rejects.toMatchObject({ status: 401 });
172
+ await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
173
+ expect(router.state.location.search).toBe(`?next=${encodeURIComponent('/ideas?page=2')}`);
174
+ expect(router.state.location.state).toEqual({ notice: 'sessionEnded' });
175
+ });
176
+ });
@@ -1,11 +1,15 @@
1
+ import { useEffect } from 'react';
1
2
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
3
  import { RouterProvider, createBrowserRouter, type DataRouter } from 'react-router';
4
+ import { setUnauthenticatedHandler } from '@/shared/api';
3
5
  import { routes } from './router';
6
+ import { handleSessionEnded } from './session';
4
7
 
5
8
  const queryClient = new QueryClient();
6
9
  const browserRouter = createBrowserRouter(routes);
7
10
 
8
11
  export function App({ router = browserRouter, client = queryClient }: { router?: DataRouter; client?: QueryClient }) {
12
+ useEffect(() => setUnauthenticatedHandler(() => handleSessionEnded(client, router)), [client, router]);
9
13
  return (
10
14
  <QueryClientProvider client={client}>
11
15
  <RouterProvider router={router} />
@@ -1,5 +1,7 @@
1
- import { useQuery } from '@tanstack/react-query';
2
- import { parseApiError } from '@/shared/api';
1
+ import { useQuery, type QueryClient } from '@tanstack/react-query';
2
+ import type { DataRouter } from 'react-router';
3
+ import { ApiError, apiFetch } from '@/shared/api';
4
+ import { findRoute } from './access';
3
5
  import type { Role } from './routes';
4
6
 
5
7
  /** GET /auth/me body (docs/API/response-bodies.md); `email` is null for Admins. */
@@ -14,10 +16,30 @@ export const meKey = ['me'] as const;
14
16
 
15
17
  /** A 401 means visitor → null (Frontend.md §6.3 rule 1). */
16
18
  export async function fetchMe(signal?: AbortSignal): Promise<Me | null> {
17
- const res = await fetch(`${location.origin}/api/v1/auth/me`, { headers: { Accept: 'application/json' }, signal });
18
- if (res.status === 401) return null;
19
- if (!res.ok) throw await parseApiError(res);
20
- return (await res.json()) as Me;
19
+ try {
20
+ const res = await apiFetch('/auth/me', { signal, allowUnauthenticated: true });
21
+ return (await res.json()) as Me;
22
+ } catch (e) {
23
+ if (e instanceof ApiError && e.status === 401) return null;
24
+ throw e;
25
+ }
26
+ }
27
+
28
+ /** Router state a 401 redirect leaves for the login page to show "your session ended" (Frontend.md §5.2). */
29
+ export interface SessionEndedState {
30
+ notice: 'sessionEnded';
31
+ }
32
+
33
+ /**
34
+ * Global 401 (Frontend.md §5.2): forget the user and go to `/login?next=<current>`.
35
+ * Already on a public route (login, register) there is nothing to leave, so only `me` is cleared.
36
+ */
37
+ export function handleSessionEnded(client: QueryClient, router: DataRouter): void {
38
+ client.setQueryData(meKey, null);
39
+ const { pathname, search } = router.state.location;
40
+ if (findRoute(pathname)?.access === 'public') return;
41
+ const state: SessionEndedState = { notice: 'sessionEnded' };
42
+ void router.navigate(`/login?next=${encodeURIComponent(pathname + search)}`, { replace: true, state });
21
43
  }
22
44
 
23
45
  export function useMe() {
@@ -0,0 +1,51 @@
1
+ import { parseApiError } from './problem';
2
+
3
+ /**
4
+ * Session-bound CSRF token, kept in memory only (PRD §6.5, Frontend.md §5.2, F13).
5
+ * The token is bound to the current session (anonymous before login), so it is
6
+ * dropped on login, logout and any 401 and fetched again lazily.
7
+ */
8
+ let token: string | null = null;
9
+ let inflight: Promise<string> | null = null;
10
+ /** Bumped by clearCsrfToken so a fetch started before the clear cannot repopulate the cache. */
11
+ let generation = 0;
12
+
13
+ export const API_BASE = () => `${location.origin}/api/v1`;
14
+
15
+ async function fetchToken(): Promise<string> {
16
+ const res = await fetch(`${API_BASE()}/auth/csrf`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });
17
+ if (!res.ok) throw await parseApiError(res);
18
+ const body = (await res.json()) as { token?: unknown };
19
+ if (typeof body.token !== 'string' || body.token === '') throw await parseApiError(new Response(null, { status: 500 }));
20
+ return body.token;
21
+ }
22
+
23
+ /** Returns the cached token, fetching it once (shared by concurrent callers) when absent. */
24
+ export function getCsrfToken(): Promise<string> {
25
+ if (token !== null) return Promise.resolve(token);
26
+ if (inflight) return inflight;
27
+ const started = generation;
28
+ const p = fetchToken()
29
+ .then((t) => {
30
+ if (started === generation) token = t;
31
+ return t;
32
+ })
33
+ .finally(() => {
34
+ if (inflight === p) inflight = null;
35
+ });
36
+ inflight = p;
37
+ return p;
38
+ }
39
+
40
+ /** Drops the token (login, logout, 401). The next write fetches a new one. */
41
+ export function clearCsrfToken(): void {
42
+ generation += 1;
43
+ token = null;
44
+ inflight = null;
45
+ }
46
+
47
+ /** Drops the token and fetches a new one (the `CSRF_INVALID` retry). */
48
+ export function refreshCsrfToken(): Promise<string> {
49
+ clearCsrfToken();
50
+ return getCsrfToken();
51
+ }