@nauticana/sail 1.1.1 → 1.1.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/README.md +2 -0
- package/package.json +1 -1
- package/src/service/auth.interceptor.ts +66 -6
package/README.md
CHANGED
|
@@ -140,6 +140,8 @@ bootstrapApplication(App, {
|
|
|
140
140
|
});
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
> **Auth-loop breaker.** `authInterceptor` carries a built-in circuit breaker: after 5 `401`/`403`s on token-bearing keel API calls within 10s it opens for 30s and refuses those requests **locally** (never sent), so a stale/rejected session can't drive the app to hammer the API and the edge/CDN. A later success closes it; login (no bearer) is never blocked, so recovery works. No wiring needed beyond registering `authInterceptor`.
|
|
144
|
+
|
|
143
145
|
### 5. Create the root component
|
|
144
146
|
|
|
145
147
|
```typescript
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -1,12 +1,72 @@
|
|
|
1
|
-
import { HttpInterceptorFn } from '@angular/common/http';
|
|
1
|
+
import { HttpErrorResponse, HttpEventType, HttpInterceptorFn } from '@angular/common/http';
|
|
2
|
+
import { catchError, tap, throwError } from 'rxjs';
|
|
2
3
|
import { isKeelApiUrl } from './rest_url';
|
|
3
4
|
|
|
5
|
+
// Auth-loop breaker: repeated 401/403s on token-bearing API calls (a stale or
|
|
6
|
+
// rejected session) can drive the app to hammer the API — and the edge/CDN in
|
|
7
|
+
// front of it — indefinitely. After AUTH_FAIL_THRESHOLD failures within
|
|
8
|
+
// AUTH_FAIL_WINDOW_MS the circuit opens for AUTH_CIRCUIT_COOLDOWN_MS: authed API
|
|
9
|
+
// requests are refused locally (never sent) so the storm stops at the browser. A
|
|
10
|
+
// later success closes it; login (no bearer) is never blocked, so recovery works.
|
|
11
|
+
const AUTH_FAIL_WINDOW_MS = 10_000;
|
|
12
|
+
const AUTH_FAIL_THRESHOLD = 5;
|
|
13
|
+
const AUTH_CIRCUIT_COOLDOWN_MS = 30_000;
|
|
14
|
+
const FAIL_KEY = 'keel_auth_401s';
|
|
15
|
+
const OPEN_KEY = 'keel_auth_circuit_until';
|
|
16
|
+
|
|
4
17
|
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
|
5
18
|
const token = localStorage.getItem('jwt');
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
19
|
+
const authedApi = !!token && isKeelApiUrl(req.url);
|
|
20
|
+
if (authedApi) {
|
|
21
|
+
req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (authedApi && authCircuitOpen()) {
|
|
25
|
+
return throwError(() => new HttpErrorResponse({
|
|
26
|
+
url: req.url,
|
|
27
|
+
status: 0,
|
|
28
|
+
statusText: 'auth circuit open: too many 401s — request stopped to prevent a loop',
|
|
29
|
+
}));
|
|
10
30
|
}
|
|
11
|
-
|
|
31
|
+
|
|
32
|
+
return next(req).pipe(
|
|
33
|
+
tap((event) => {
|
|
34
|
+
if (authedApi && event.type === HttpEventType.Response) {
|
|
35
|
+
clearAuthFailures();
|
|
36
|
+
}
|
|
37
|
+
}),
|
|
38
|
+
catchError((err: HttpErrorResponse) => {
|
|
39
|
+
if (authedApi && (err.status === 401 || err.status === 403)) {
|
|
40
|
+
recordAuthFailure();
|
|
41
|
+
}
|
|
42
|
+
return throwError(() => err);
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
12
45
|
};
|
|
46
|
+
|
|
47
|
+
function authCircuitOpen(): boolean {
|
|
48
|
+
return Date.now() < Number(sessionStorage.getItem(OPEN_KEY) ?? 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function recordAuthFailure(): void {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
let times: number[];
|
|
54
|
+
try {
|
|
55
|
+
times = (JSON.parse(sessionStorage.getItem(FAIL_KEY) ?? '[]') as number[])
|
|
56
|
+
.filter((t) => now - t < AUTH_FAIL_WINDOW_MS);
|
|
57
|
+
} catch {
|
|
58
|
+
times = [];
|
|
59
|
+
}
|
|
60
|
+
times.push(now);
|
|
61
|
+
if (times.length >= AUTH_FAIL_THRESHOLD) {
|
|
62
|
+
sessionStorage.setItem(OPEN_KEY, String(now + AUTH_CIRCUIT_COOLDOWN_MS));
|
|
63
|
+
sessionStorage.removeItem(FAIL_KEY);
|
|
64
|
+
} else {
|
|
65
|
+
sessionStorage.setItem(FAIL_KEY, JSON.stringify(times));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clearAuthFailures(): void {
|
|
70
|
+
sessionStorage.removeItem(FAIL_KEY);
|
|
71
|
+
sessionStorage.removeItem(OPEN_KEY);
|
|
72
|
+
}
|