@johnnyboy11234/esg-idea-contest 0.2.0 → 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.
- package/backend/api/openapi.yaml +44 -0
- package/backend/cmd/server/main.go +20 -2
- package/backend/db/migration/V004__session.sql +19 -0
- package/backend/go.mod +1 -1
- package/backend/internal/account/account_test.go +191 -0
- package/backend/internal/account/handler.go +58 -0
- package/backend/internal/account/integration_test.go +268 -0
- package/backend/internal/account/internal/store/store.go +35 -0
- package/backend/internal/account/service.go +153 -0
- package/backend/internal/account/validate.go +100 -0
- package/backend/internal/api/api.gen.go +82 -0
- package/backend/internal/audit/integration_test.go +1 -1
- package/backend/internal/auth/csrf.go +212 -0
- package/backend/internal/auth/csrf_test.go +281 -0
- package/backend/internal/auth/internal/store/session.go +111 -0
- package/backend/internal/auth/middleware.go +66 -0
- package/backend/internal/auth/middleware_test.go +153 -0
- package/backend/internal/auth/session.go +113 -0
- package/backend/internal/auth/session_integration_test.go +245 -0
- package/backend/internal/auth/session_test.go +36 -0
- package/backend/internal/server/api.go +12 -0
- package/backend/internal/server/errors.go +10 -25
- package/backend/internal/server/errors_test.go +18 -0
- package/backend/internal/server/public.go +35 -0
- package/backend/internal/server/public_test.go +73 -0
- package/backend/internal/server/server.go +7 -3
- package/backend/internal/shared/config/config.go +23 -4
- package/backend/internal/shared/config/config_test.go +14 -8
- package/backend/internal/shared/db/intx_test.go +25 -0
- package/backend/internal/shared/db/mysqlerr.go +26 -0
- package/backend/internal/shared/security/security.go +6 -2
- package/backend/internal/shared/security/security_test.go +6 -4
- package/backend/internal/shared/web/problem.go +6 -0
- package/backend/internal/shared/web/request.go +27 -0
- package/frontend/src/app/App.test.tsx +13 -0
- package/frontend/src/app/App.tsx +4 -0
- package/frontend/src/app/session.ts +28 -6
- package/frontend/src/shared/api/csrf.ts +51 -0
- package/frontend/src/shared/api/http.test.tsx +209 -0
- package/frontend/src/shared/api/http.ts +91 -0
- package/frontend/src/shared/api/index.ts +3 -0
- package/frontend/src/shared/text/index.ts +93 -0
- package/frontend/src/shared/text/text.test.ts +22 -0
- package/package.json +1 -1
- package/frontend/src/shared/text/.gitkeep +0 -0
|
@@ -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":
|
|
36
|
-
"log level":
|
|
37
|
-
"timeout":
|
|
38
|
-
"zero timeout":
|
|
39
|
-
"drain":
|
|
40
|
-
"
|
|
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)
|
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"database/sql"
|
|
6
6
|
"database/sql/driver"
|
|
7
7
|
"errors"
|
|
8
|
+
"fmt"
|
|
8
9
|
"net/http"
|
|
9
10
|
"testing"
|
|
10
11
|
"time"
|
|
@@ -221,3 +222,27 @@ func TestSQLStateClassNonMySQL(t *testing.T) {
|
|
|
221
222
|
t.Error("non-MySQL error must have no class and no contention")
|
|
222
223
|
}
|
|
223
224
|
}
|
|
225
|
+
|
|
226
|
+
func TestDuplicateKey(t *testing.T) {
|
|
227
|
+
dup := func(msg string) error { return fmt.Errorf("wrap: %w", &mysql.MySQLError{Number: 1062, Message: msg}) }
|
|
228
|
+
cases := []struct {
|
|
229
|
+
name string
|
|
230
|
+
err error
|
|
231
|
+
key string
|
|
232
|
+
ok bool
|
|
233
|
+
}{
|
|
234
|
+
{"plain", dup("Duplicate entry 'a@b' for key 'uq_app_user_email'"), "uq_app_user_email", true},
|
|
235
|
+
{"table qualified", dup("Duplicate entry '1' for key 'app_user.uq_app_user_emp_no'"), "uq_app_user_emp_no", true},
|
|
236
|
+
{"value contains the marker", dup("Duplicate entry 'for key ''x' for key 'uq_a'"), "uq_a", true},
|
|
237
|
+
{"no key in message", dup("boom"), "", true},
|
|
238
|
+
{"unterminated", dup("Duplicate entry 'x' for key 'uq_a"), "uq_a", true},
|
|
239
|
+
{"other number", &mysql.MySQLError{Number: 1205}, "", false},
|
|
240
|
+
{"not a mysql error", errors.New("x"), "", false},
|
|
241
|
+
}
|
|
242
|
+
for _, tc := range cases {
|
|
243
|
+
key, ok := DuplicateKey(tc.err)
|
|
244
|
+
if key != tc.key || ok != tc.ok {
|
|
245
|
+
t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, key, ok, tc.key, tc.ok)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -2,6 +2,7 @@ package db
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"errors"
|
|
5
|
+
"strings"
|
|
5
6
|
|
|
6
7
|
"github.com/go-sql-driver/mysql"
|
|
7
8
|
)
|
|
@@ -31,3 +32,28 @@ func sqlStateClass(err error) string {
|
|
|
31
32
|
}
|
|
32
33
|
return string(me.SQLState[:2])
|
|
33
34
|
}
|
|
35
|
+
|
|
36
|
+
const errDupEntry = 1062 // ER_DUP_ENTRY
|
|
37
|
+
|
|
38
|
+
// DuplicateKey reports whether err is a unique-key violation (1062) and names
|
|
39
|
+
// the violated key, as in "Duplicate entry 'x' for key 'uq_app_user_email'".
|
|
40
|
+
func DuplicateKey(err error) (key string, ok bool) {
|
|
41
|
+
var me *mysql.MySQLError
|
|
42
|
+
if !errors.As(err, &me) || me.Number != errDupEntry {
|
|
43
|
+
return "", false
|
|
44
|
+
}
|
|
45
|
+
const marker = "for key '"
|
|
46
|
+
i := strings.LastIndex(me.Message, marker)
|
|
47
|
+
if i < 0 {
|
|
48
|
+
return "", true
|
|
49
|
+
}
|
|
50
|
+
rest := me.Message[i+len(marker):]
|
|
51
|
+
if j := strings.IndexByte(rest, '\''); j >= 0 {
|
|
52
|
+
rest = rest[:j]
|
|
53
|
+
}
|
|
54
|
+
// MariaDB may qualify the key with the table name ("app_user.uq_x").
|
|
55
|
+
if k := strings.LastIndexByte(rest, '.'); k >= 0 {
|
|
56
|
+
rest = rest[k+1:]
|
|
57
|
+
}
|
|
58
|
+
return rest, true
|
|
59
|
+
}
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
// that any package may import it.
|
|
5
5
|
package security
|
|
6
6
|
|
|
7
|
-
import
|
|
7
|
+
import (
|
|
8
|
+
"context"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
)
|
|
8
12
|
|
|
9
13
|
// Role is an account role (PRD §3). Leader, Member and Applicant are relations
|
|
10
14
|
// to one Idea, not roles.
|
|
@@ -34,7 +38,7 @@ const (
|
|
|
34
38
|
// Principal is the caller resolved from a valid session. The role is read from
|
|
35
39
|
// the account on every request, never copied into the session (PRD §6.5).
|
|
36
40
|
type Principal struct {
|
|
37
|
-
UserID
|
|
41
|
+
UserID uuid.UUID
|
|
38
42
|
EmpNo string
|
|
39
43
|
Role Role
|
|
40
44
|
}
|
|
@@ -4,6 +4,8 @@ import (
|
|
|
4
4
|
"context"
|
|
5
5
|
"testing"
|
|
6
6
|
|
|
7
|
+
"github.com/google/uuid"
|
|
8
|
+
|
|
7
9
|
"github.com/brian-huanggg/AwardRegistration/backend/internal/shared/security"
|
|
8
10
|
)
|
|
9
11
|
|
|
@@ -12,7 +14,7 @@ func TestPrincipalRoundTrip(t *testing.T) {
|
|
|
12
14
|
if _, ok := security.PrincipalFrom(ctx); ok {
|
|
13
15
|
t.Fatal("empty context must have no principal")
|
|
14
16
|
}
|
|
15
|
-
want := security.Principal{UserID:
|
|
17
|
+
want := security.Principal{UserID: uuid.New(), EmpNo: "A123", Role: security.RoleUser}
|
|
16
18
|
got, ok := security.PrincipalFrom(security.WithPrincipal(ctx, want))
|
|
17
19
|
if !ok || got != want {
|
|
18
20
|
t.Fatalf("got %+v, %v", got, ok)
|
|
@@ -21,9 +23,9 @@ func TestPrincipalRoundTrip(t *testing.T) {
|
|
|
21
23
|
|
|
22
24
|
func TestActorRole(t *testing.T) {
|
|
23
25
|
bg := context.Background()
|
|
24
|
-
user := security.Principal{UserID:
|
|
25
|
-
admin := security.Principal{UserID:
|
|
26
|
-
bad := security.Principal{UserID:
|
|
26
|
+
user := security.Principal{UserID: uuid.New(), EmpNo: "U1", Role: security.RoleUser}
|
|
27
|
+
admin := security.Principal{UserID: uuid.New(), EmpNo: "A1", Role: security.RoleAdmin}
|
|
28
|
+
bad := security.Principal{UserID: uuid.New(), EmpNo: "X", Role: "ROOT"}
|
|
27
29
|
|
|
28
30
|
tests := []struct {
|
|
29
31
|
name string
|
|
@@ -41,6 +41,12 @@ type Error struct {
|
|
|
41
41
|
// AuditTargetType and AuditTargetID name the resource, when there is one.
|
|
42
42
|
AuditTargetType string
|
|
43
43
|
AuditTargetID uuid.UUID
|
|
44
|
+
// AuditSubmittedEmpNo is the employee ID typed into a failed registration
|
|
45
|
+
// or login; the audit package sanitizes it before storing.
|
|
46
|
+
AuditSubmittedEmpNo string
|
|
47
|
+
// AuditDetail adds keys to the row's after_json beside "reason". Callers
|
|
48
|
+
// must not put secrets here.
|
|
49
|
+
AuditDetail map[string]string
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
func (e *Error) Error() string {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package web
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"net/netip"
|
|
6
|
+
|
|
7
|
+
"github.com/go-chi/chi/v5"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// RoutePattern is the matched chi pattern (/api/v1/ideas/{id}), never the raw
|
|
11
|
+
// path, so IDs do not leak into the audit log and rows group by route. It is
|
|
12
|
+
// empty when no route matched.
|
|
13
|
+
func RoutePattern(r *http.Request) string {
|
|
14
|
+
if rc := chi.RouteContext(r.Context()); rc != nil {
|
|
15
|
+
return rc.RoutePattern()
|
|
16
|
+
}
|
|
17
|
+
return ""
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ClientIP is the TCP peer. Trusted-proxy handling is not decided yet.
|
|
21
|
+
func ClientIP(r *http.Request) netip.Addr {
|
|
22
|
+
ap, err := netip.ParseAddrPort(r.RemoteAddr)
|
|
23
|
+
if err != nil {
|
|
24
|
+
return netip.Addr{}
|
|
25
|
+
}
|
|
26
|
+
return ap.Addr().Unmap()
|
|
27
|
+
}
|
|
@@ -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
|
+
});
|
package/frontend/src/app/App.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { http, HttpResponse } from 'msw';
|
|
2
|
+
import { problem } from './mocks/problem';
|
|
3
|
+
import { server } from './mocks/server';
|
|
4
|
+
import { ApiError } from './problem';
|
|
5
|
+
import { clearCsrfToken } from './csrf';
|
|
6
|
+
import { apiFetch, setUnauthenticatedHandler } from './http';
|
|
7
|
+
|
|
8
|
+
const API = 'http://localhost:3000/api/v1';
|
|
9
|
+
|
|
10
|
+
/** Counts GET /auth/csrf and hands out t1, t2, … */
|
|
11
|
+
function csrfEndpoint() {
|
|
12
|
+
const state = { calls: 0 };
|
|
13
|
+
server.use(
|
|
14
|
+
http.get(`${API}/auth/csrf`, () => {
|
|
15
|
+
state.calls += 1;
|
|
16
|
+
return HttpResponse.json({ token: `t${state.calls}` });
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
return state;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
clearCsrfToken();
|
|
24
|
+
setUnauthenticatedHandler(null);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('CSRF token', () => {
|
|
28
|
+
it('is fetched lazily, cached, and sent on writes only', async () => {
|
|
29
|
+
const csrf = csrfEndpoint();
|
|
30
|
+
const seen: (string | null)[] = [];
|
|
31
|
+
server.use(
|
|
32
|
+
http.get(`${API}/deadline`, ({ request }) => {
|
|
33
|
+
seen.push(request.headers.get('X-CSRF-Token'));
|
|
34
|
+
return HttpResponse.json({});
|
|
35
|
+
}),
|
|
36
|
+
http.post(`${API}/join-requests`, ({ request }) => {
|
|
37
|
+
seen.push(request.headers.get('X-CSRF-Token'));
|
|
38
|
+
return HttpResponse.json({}, { status: 201 });
|
|
39
|
+
}),
|
|
40
|
+
http.delete(`${API}/ideas/x`, ({ request }) => {
|
|
41
|
+
seen.push(request.headers.get('X-CSRF-Token'));
|
|
42
|
+
return new HttpResponse(null, { status: 204 });
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
await apiFetch('/deadline');
|
|
46
|
+
expect(csrf.calls).toBe(0);
|
|
47
|
+
await apiFetch('/join-requests', { method: 'POST', body: { a: 1 } });
|
|
48
|
+
await apiFetch('/ideas/x', { method: 'DELETE' });
|
|
49
|
+
expect(csrf.calls).toBe(1);
|
|
50
|
+
expect(seen).toEqual([null, 't1', 't1']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('is shared by concurrent first writes', async () => {
|
|
54
|
+
const csrf = csrfEndpoint();
|
|
55
|
+
server.use(http.post(`${API}/join-requests`, () => HttpResponse.json({}, { status: 201 })));
|
|
56
|
+
await Promise.all([1, 2, 3].map(() => apiFetch('/join-requests', { method: 'POST', body: {} })));
|
|
57
|
+
expect(csrf.calls).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('sends JSON bodies with a JSON content type', async () => {
|
|
61
|
+
csrfEndpoint();
|
|
62
|
+
let ct: string | null = null;
|
|
63
|
+
let body: unknown;
|
|
64
|
+
server.use(
|
|
65
|
+
http.post(`${API}/auth/register`, async ({ request }) => {
|
|
66
|
+
ct = request.headers.get('Content-Type');
|
|
67
|
+
body = await request.json();
|
|
68
|
+
return HttpResponse.json({}, { status: 201 });
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
await apiFetch('/auth/register', { method: 'POST', body: { empNo: '1' } });
|
|
72
|
+
expect(ct).toBe('application/json');
|
|
73
|
+
expect(body).toEqual({ empNo: '1' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it.each(['/auth/login', '/auth/logout'])('is refetched after a successful %s', async (path) => {
|
|
77
|
+
const csrf = csrfEndpoint();
|
|
78
|
+
const seen: string[] = [];
|
|
79
|
+
server.use(
|
|
80
|
+
http.post(`${API}${path}`, ({ request }) => {
|
|
81
|
+
seen.push(request.headers.get('X-CSRF-Token') ?? '');
|
|
82
|
+
return path === '/auth/login' ? HttpResponse.json({}) : new HttpResponse(null, { status: 204 });
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
await apiFetch(path, { method: 'POST', body: {} });
|
|
86
|
+
await apiFetch(path, { method: 'POST', body: {} });
|
|
87
|
+
expect(csrf.calls).toBe(2);
|
|
88
|
+
expect(seen).toEqual(['t1', 't2']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('is kept after a failed login', async () => {
|
|
92
|
+
const csrf = csrfEndpoint();
|
|
93
|
+
server.use(http.post(`${API}/auth/login`, () => problem(401, 'INVALID_CREDENTIALS')));
|
|
94
|
+
const unauth = vi.fn();
|
|
95
|
+
setUnauthenticatedHandler(unauth);
|
|
96
|
+
await expect(apiFetch('/auth/login', { method: 'POST', body: {} })).rejects.toMatchObject({ code: 'INVALID_CREDENTIALS' });
|
|
97
|
+
await expect(apiFetch('/auth/login', { method: 'POST', body: {} })).rejects.toBeInstanceOf(ApiError);
|
|
98
|
+
expect(csrf.calls).toBe(1);
|
|
99
|
+
expect(unauth).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('CSRF_INVALID', () => {
|
|
104
|
+
it('refetches and retries once, resending the same body', async () => {
|
|
105
|
+
const csrf = csrfEndpoint();
|
|
106
|
+
const attempts: { token: string | null; body: unknown }[] = [];
|
|
107
|
+
server.use(
|
|
108
|
+
http.post(`${API}/join-requests`, async ({ request }) => {
|
|
109
|
+
attempts.push({ token: request.headers.get('X-CSRF-Token'), body: await request.json() });
|
|
110
|
+
return attempts.length === 1 ? problem(403, 'CSRF_INVALID') : HttpResponse.json({}, { status: 201 });
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
const res = await apiFetch('/join-requests', { method: 'POST', body: { ideaId: 'i' } });
|
|
114
|
+
expect(res.status).toBe(201);
|
|
115
|
+
expect(csrf.calls).toBe(2);
|
|
116
|
+
expect(attempts).toEqual([
|
|
117
|
+
{ token: 't1', body: { ideaId: 'i' } },
|
|
118
|
+
{ token: 't2', body: { ideaId: 'i' } },
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('does not retry a second time', async () => {
|
|
123
|
+
const csrf = csrfEndpoint();
|
|
124
|
+
let posts = 0;
|
|
125
|
+
server.use(
|
|
126
|
+
http.post(`${API}/join-requests`, () => {
|
|
127
|
+
posts += 1;
|
|
128
|
+
return problem(403, 'CSRF_INVALID');
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
await expect(apiFetch('/join-requests', { method: 'POST', body: {} })).rejects.toMatchObject({ status: 403, code: 'CSRF_INVALID' });
|
|
132
|
+
expect(posts).toBe(2);
|
|
133
|
+
expect(csrf.calls).toBe(2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('does not retry other 403s', async () => {
|
|
137
|
+
const csrf = csrfEndpoint();
|
|
138
|
+
let posts = 0;
|
|
139
|
+
server.use(
|
|
140
|
+
http.post(`${API}/join-requests`, () => {
|
|
141
|
+
posts += 1;
|
|
142
|
+
return problem(403, 'SUBMISSION_CLOSED');
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
await expect(apiFetch('/join-requests', { method: 'POST', body: {} })).rejects.toMatchObject({ code: 'SUBMISSION_CLOSED' });
|
|
146
|
+
expect(posts).toBe(1);
|
|
147
|
+
expect(csrf.calls).toBe(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('resends a multipart body on retry', async () => {
|
|
151
|
+
csrfEndpoint();
|
|
152
|
+
const names: (string | null)[] = [];
|
|
153
|
+
server.use(
|
|
154
|
+
http.post(`${API}/ideas`, async ({ request }) => {
|
|
155
|
+
const f = (await request.formData()).get('title');
|
|
156
|
+
names.push(f as string);
|
|
157
|
+
return names.length === 1 ? problem(403, 'CSRF_INVALID') : HttpResponse.json({}, { status: 201 });
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
const fd = new FormData();
|
|
161
|
+
fd.set('title', 'T');
|
|
162
|
+
await apiFetch('/ideas', { method: 'POST', body: fd });
|
|
163
|
+
expect(names).toEqual(['T', 'T']);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('a failed token fetch surfaces its error and caches nothing', async () => {
|
|
167
|
+
server.use(http.get(`${API}/auth/csrf`, () => problem(429, 'RATE_LIMITED')));
|
|
168
|
+
await expect(apiFetch('/join-requests', { method: 'POST', body: {} })).rejects.toMatchObject({ status: 429 });
|
|
169
|
+
const csrf = csrfEndpoint();
|
|
170
|
+
server.use(http.post(`${API}/join-requests`, () => HttpResponse.json({}, { status: 201 })));
|
|
171
|
+
await apiFetch('/join-requests', { method: 'POST', body: {} });
|
|
172
|
+
expect(csrf.calls).toBe(1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('401', () => {
|
|
177
|
+
it('clears the token and calls the handler', async () => {
|
|
178
|
+
const csrf = csrfEndpoint();
|
|
179
|
+
const unauth = vi.fn();
|
|
180
|
+
setUnauthenticatedHandler(unauth);
|
|
181
|
+
let n = 0;
|
|
182
|
+
server.use(
|
|
183
|
+
http.post(`${API}/join-requests`, () => {
|
|
184
|
+
n += 1;
|
|
185
|
+
return n === 1 ? problem(401, 'UNAUTHENTICATED') : HttpResponse.json({}, { status: 201 });
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
await expect(apiFetch('/join-requests', { method: 'POST', body: {} })).rejects.toMatchObject({ status: 401, code: 'UNAUTHENTICATED' });
|
|
189
|
+
expect(unauth).toHaveBeenCalledTimes(1);
|
|
190
|
+
await apiFetch('/join-requests', { method: 'POST', body: {} });
|
|
191
|
+
expect(csrf.calls).toBe(2);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('is an answer, not a redirect, when allowUnauthenticated is set', async () => {
|
|
195
|
+
const unauth = vi.fn();
|
|
196
|
+
setUnauthenticatedHandler(unauth);
|
|
197
|
+
server.use(http.get(`${API}/auth/me`, () => problem(401, 'UNAUTHENTICATED')));
|
|
198
|
+
await expect(apiFetch('/auth/me', { allowUnauthenticated: true })).rejects.toMatchObject({ status: 401 });
|
|
199
|
+
expect(unauth).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('fires for reads too', async () => {
|
|
203
|
+
const unauth = vi.fn();
|
|
204
|
+
setUnauthenticatedHandler(unauth);
|
|
205
|
+
server.use(http.get(`${API}/ideas`, () => problem(401, 'UNAUTHENTICATED')));
|
|
206
|
+
await expect(apiFetch('/ideas')).rejects.toBeInstanceOf(ApiError);
|
|
207
|
+
expect(unauth).toHaveBeenCalledTimes(1);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { API_BASE, clearCsrfToken, getCsrfToken, refreshCsrfToken } from './csrf';
|
|
2
|
+
import { parseApiError, type ApiError } from './problem';
|
|
3
|
+
|
|
4
|
+
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
5
|
+
|
|
6
|
+
/** Endpoints after which the session binding of the CSRF token changes (PRD §6.5). */
|
|
7
|
+
const SESSION_CHANGING = new Set(['/auth/login', '/auth/logout']);
|
|
8
|
+
|
|
9
|
+
type Unauthenticated = () => void;
|
|
10
|
+
let onUnauthenticated: Unauthenticated | null = null;
|
|
11
|
+
|
|
12
|
+
/** The app registers what a 401 does (clear `me`, go to /login?next=). Returns an unregister function. */
|
|
13
|
+
export function setUnauthenticatedHandler(fn: Unauthenticated | null): () => void {
|
|
14
|
+
onUnauthenticated = fn;
|
|
15
|
+
return () => {
|
|
16
|
+
if (onUnauthenticated === fn) onUnauthenticated = null;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
|
21
|
+
/** Plain objects are sent as JSON; strings, FormData and Blobs are sent as given. */
|
|
22
|
+
body?: BodyInit | Record<string, unknown> | null;
|
|
23
|
+
/** A 401 is an answer here (e.g. `GET /auth/me` means "visitor"), not a session end. */
|
|
24
|
+
allowUnauthenticated?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function prepare(init: ApiRequestInit): { headers: Headers; body: BodyInit | null | undefined } {
|
|
28
|
+
const headers = new Headers(init.headers);
|
|
29
|
+
if (!headers.has('Accept')) headers.set('Accept', 'application/json');
|
|
30
|
+
let body = init.body as BodyInit | null | undefined;
|
|
31
|
+
if (body !== null && body !== undefined && typeof body === 'object' && !isNativeBody(body)) {
|
|
32
|
+
body = JSON.stringify(body);
|
|
33
|
+
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
|
34
|
+
}
|
|
35
|
+
return { headers, body };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isNativeBody(b: object): boolean {
|
|
39
|
+
return (
|
|
40
|
+
b instanceof FormData ||
|
|
41
|
+
b instanceof Blob ||
|
|
42
|
+
b instanceof URLSearchParams ||
|
|
43
|
+
b instanceof ArrayBuffer ||
|
|
44
|
+
ArrayBuffer.isView(b) ||
|
|
45
|
+
b instanceof ReadableStream
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* fetch for `/api/v1`: `path` is relative to it (`/auth/me`). Resolves with the
|
|
51
|
+
* Response on 2xx and throws ApiError otherwise.
|
|
52
|
+
*
|
|
53
|
+
* - Writes send `X-CSRF-Token` (lazily fetched, cached in memory).
|
|
54
|
+
* - `CSRF_INVALID` refetches the token and retries the write once. The request
|
|
55
|
+
* was rejected before processing, so the retry is safe (Frontend.md §5.2).
|
|
56
|
+
* A request whose body is a stream cannot be replayed and is not retried.
|
|
57
|
+
* - A successful login or logout clears the token (the binding changed).
|
|
58
|
+
* - A 401 clears the token and calls the registered handler, except for
|
|
59
|
+
* `INVALID_CREDENTIALS` (a wrong password is not a session end).
|
|
60
|
+
*/
|
|
61
|
+
export async function apiFetch(path: string, init: ApiRequestInit = {}): Promise<Response> {
|
|
62
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
63
|
+
const write = !SAFE.has(method);
|
|
64
|
+
const { headers, body } = prepare(init);
|
|
65
|
+
const send = (csrf: string | null) => {
|
|
66
|
+
const h = new Headers(headers);
|
|
67
|
+
if (csrf !== null) h.set('X-CSRF-Token', csrf);
|
|
68
|
+
return fetch(`${API_BASE()}${path}`, { ...init, method, headers: h, body, credentials: 'same-origin' });
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
let res = await send(write ? await getCsrfToken() : null);
|
|
72
|
+
if (write && !res.ok && !(body instanceof ReadableStream) && (await errorCode(res)) === 'CSRF_INVALID') {
|
|
73
|
+
res = await send(await refreshCsrfToken());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (res.ok) {
|
|
77
|
+
if (write && SESSION_CHANGING.has(path)) clearCsrfToken();
|
|
78
|
+
return res;
|
|
79
|
+
}
|
|
80
|
+
const err = await parseApiError(res);
|
|
81
|
+
if (err.status === 401 && err.code !== 'INVALID_CREDENTIALS' && !init.allowUnauthenticated) {
|
|
82
|
+
clearCsrfToken();
|
|
83
|
+
onUnauthenticated?.();
|
|
84
|
+
}
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Reads the error code without consuming the response. */
|
|
89
|
+
async function errorCode(res: Response): Promise<ApiError['code']> {
|
|
90
|
+
return (await parseApiError(res.clone())).code;
|
|
91
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export { ApiError, parseApiError } from './problem';
|
|
2
2
|
export type { ErrorCode, FieldError } from './problem';
|
|
3
|
+
export { apiFetch, setUnauthenticatedHandler } from './http';
|
|
4
|
+
export type { ApiRequestInit } from './http';
|
|
5
|
+
export { clearCsrfToken, getCsrfToken } from './csrf';
|
|
3
6
|
export type { paths, components } from './types.generated';
|