@nauticana/sail 1.1.1 → 1.1.3

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 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.1",
3
+ "version": "1.1.3",
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": {
package/src/config.ts CHANGED
@@ -59,6 +59,15 @@ export interface SailGuiConfig {
59
59
  defaultPolicyLanguage?: string;
60
60
  /** Route to navigate to after account deletion. Defaults to '/login/local'. */
61
61
  accountDeletedRoute?: string;
62
+
63
+ // ── OAuth authorization-flow bridge (for an OAuth AS on a different host) ──
64
+ /** Backend path that mirrors the bearer JWT into an HttpOnly session cookie so
65
+ * a cross-site OAuth `/authorize` can see the session, e.g. '/api/v1/auth/session-cookie'.
66
+ * When set, login POSTs here before handing off to an allowed ?return= host. */
67
+ sessionCookieUrl?: string;
68
+ /** Hostnames a post-login `?return=` may redirect to (open-redirect allowlist),
69
+ * e.g. ['mcp.daxoom.com']. Anything not listed is ignored. */
70
+ allowedReturnHosts?: string[];
62
71
  }
63
72
 
64
73
  export const SAIL_GUI_CONFIG = new InjectionToken<SailGuiConfig>('SailGuiConfig');
@@ -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
- if (token && isKeelApiUrl(req.url)) {
7
- req = req.clone({
8
- setHeaders: { Authorization: `Bearer ${token}` },
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
- return next(req);
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
+ }
@@ -110,10 +110,40 @@ export abstract class BaseAuthService extends BaseRestService {
110
110
  this.token = token;
111
111
  this.isLoggedIn.set(true);
112
112
  localStorage.setItem('jwt', token);
113
+ const ret = this.validatedReturnUrl();
114
+ if (ret && this.guiConfig.sessionCookieUrl) {
115
+ // OAuth flow: mirror the bearer into a cookie, then hand off to the
116
+ // (cross-origin) authorization server via a full navigation.
117
+ this.http.post(this.url(this.guiConfig.sessionCookieUrl), {}, { withCredentials: true })
118
+ .pipe(take(1))
119
+ .subscribe({
120
+ next: () => window.location.assign(ret),
121
+ error: () => window.location.assign(ret),
122
+ });
123
+ return;
124
+ }
113
125
  this.loadAppData();
114
126
  this.initRoutes();
115
127
  }
116
128
 
129
+ /** A post-login `?return=` URL, only when its host is in allowedReturnHosts and
130
+ * the scheme is https (or loopback) — an open-redirect guard. '' otherwise. */
131
+ private validatedReturnUrl(): string {
132
+ const raw = new URLSearchParams(window.location.search).get('return');
133
+ if (!raw) return '';
134
+ try {
135
+ const u = new URL(raw, window.location.origin);
136
+ if (u.username || u.password) return ''; // reject userinfo ("user@host") spoofing
137
+ const host = u.hostname.toLowerCase();
138
+ const okScheme = u.protocol === 'https:' || host === 'localhost' || host === '127.0.0.1';
139
+ const allowed = (this.guiConfig.allowedReturnHosts ?? []).some((h) => h.toLowerCase() === host);
140
+ if (okScheme && allowed) {
141
+ return u.toString();
142
+ }
143
+ } catch { /* malformed → reject */ }
144
+ return '';
145
+ }
146
+
117
147
  login(username: string, password: string) {
118
148
  // withCredentials so the browser sends the server-set `keel_td` cookie
119
149
  // (keel v0.9 trusted-device secret). When it maps to a trusted row, keel