@devopsplaybook.io/common-web 0.9.0-beta.17.623b2b7 → 0.10.0-beta.18.afa437b

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/CHANGELOG.md CHANGED
@@ -5,6 +5,55 @@ All notable changes to this project are documented here.
5
5
  This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.10.0] - 2026-11-01
9
+
10
+ ### Added
11
+
12
+ - `registerAxiosAuthInterceptors(instance, { allowedOrigins, loginPaths })`:
13
+ `allowedOrigins` lists extra origins that may receive the bearer token, and
14
+ `loginPaths` replaces the default `/users/session` login-path exemption.
15
+ - `AUTH_RENEWAL_FAILED` event with `{ reason }` (`timeout`, `5xx`, `network` or
16
+ `invalid-response`), carrying no token or PII. No OpenTelemetry metric or
17
+ dependency is added.
18
+ - `UserService.isInitialized({ timeoutMs, signal, cacheTtlMs })`: a 5 s default
19
+ timeout, an optional `AbortSignal`, an opt-in short-lived cache for a
20
+ successful result and a logged failure reason.
21
+ - `handleError(error, { debug })`: `debug: true` logs the raw error object; the
22
+ default logs a sanitized summary with redacted credentials.
23
+ - `removeToken(token)` compare-and-remove semantics.
24
+
25
+ ### Changed
26
+
27
+ - Bearer-token attachment is origin-scoped: relative URLs, the page origin, the
28
+ origin of an absolute `SERVER_URL` and configured `allowedOrigins` receive
29
+ the token; every other origin receives none and cannot trigger renewal.
30
+ - A `401` clears the session only when the failed request used the currently
31
+ stored token; a stale `401` is ignored.
32
+ - Session renewal runs in the background: `getToken()` and `getAuthHeader()`
33
+ return the still-valid token immediately while renewal continues. The
34
+ renewal timeout drops from 10 s to 3 s; one 500 ms retry and the 30 s cooldown
35
+ are unchanged.
36
+ - `Config.set()` trims whitespace and strips trailing slashes and warns in
37
+ development about insecure non-loopback `http:` URLs.
38
+ - `removeToken()`'s optional argument is no longer ignored, and
39
+ `AuthenticationStore` unsubscribes its `AUTH_UPDATED` listener when disposed.
40
+
41
+ ### Fixed
42
+
43
+ - H1: bearer tokens are no longer attached to requests to third-party origins.
44
+ - M1: a stale `401` no longer clears a newer session.
45
+ - M2: error logging no longer includes the bearer token.
46
+ - M6: `UserService.isInitialized()` no longer stalls on a hung backend and
47
+ reports the failure reason.
48
+ - M10: session renewal no longer blocks the triggering request.
49
+ - L2: an expired token is never returned after a slow renewal failure.
50
+ - L4: the authentication store no longer leaks an event listener per instance.
51
+ - L10: `SERVER_URL` trailing-slash and insecure-HTTP handling is enforced.
52
+ - L11: login-path detection is configurable.
53
+ - L14: tokens with malformed `exp`/`iat` claims are treated as corrupt.
54
+
55
+ No dependency changed in this release.
56
+
8
57
  ## [0.9.0] - 2026-11-01
9
58
 
10
59
  ### Added
package/README.md CHANGED
@@ -37,8 +37,11 @@ required. The `playground/` directory demonstrates a consuming app.
37
37
  Identical `type` + `text` pairs are deduplicated and the queue keeps at most
38
38
  five messages, dropping the oldest first. Alert styles use the prefixed
39
39
  `.alert-message*` classes, so consumer stylesheets are not affected by the
40
- layer's global styles. `handleError(error)` logs the error and emits a
41
- corresponding error alert.
40
+ layer's global styles. `handleError(error, { debug = false } = {})` logs a
41
+ sanitized summary (message, status, method, path without query string, and
42
+ headers with credentials redacted) and emits a corresponding error alert.
43
+ `debug: true` logs the raw error object, which may contain credentials, and is
44
+ intended for development only.
42
45
 
43
46
  `AppNavigation` renders plain `<a href>` links by default, which keeps
44
47
  non-Nuxt deep imports and raw-source usage working. Nuxt applications should
@@ -91,29 +94,40 @@ The canonical auth storage key is `auth_token`. During this compatibility
91
94
  release, `AUTH_TOKEN` and `AUTH_TOKEN_KEY` are read and migrated to the
92
95
  canonical key. Tokens remain in `localStorage`, matching existing applications.
93
96
  When an authenticated token has entered the final 20% of its lifetime,
94
- `AuthService` renews it through `POST /api/users/session/refresh` before
95
- returning it from `getToken()` or `getAuthHeader()`. This extends active
96
- sessions without requiring users to sign in again.
97
+ `AuthService` renews it through `POST /api/users/session/refresh` in the
98
+ background. `getToken()` and `getAuthHeader()` return the still-valid current
99
+ token immediately and the next call sees the renewed token, so a slow refresh
100
+ endpoint never stalls a caller.
97
101
 
98
102
  `getToken()` never rejects because of the stored or renewed token:
99
103
 
100
104
  - A corrupt or undecodable token is treated as signed out: every auth key is
101
105
  removed, `AUTH_UPDATED` is emitted and `null` is returned. Expired tokens are
102
- removed and return `null`.
106
+ removed and return `null`. A present `exp` or `iat` claim must be a finite
107
+ number; tokens with string or otherwise malformed claims are corrupt.
103
108
  - A `401` or `403` from the refresh endpoint means the session was revoked:
104
109
  the session is cleared, `AUTH_UPDATED` is emitted and `null` is returned.
105
110
  - Timeouts and invalid renewal responses perform a single request. Network
106
111
  errors and `5xx` responses retry once after a 500 ms backoff.
107
112
  - When renewal still fails, the still-valid current token is returned and
108
- further renewal attempts for that token are paused for 30 seconds, so a slow
109
- or failing refresh endpoint is not retried on every authenticated call. The
110
- worst case is about 20.5 seconds per call for network/`5xx` failures and
111
- about 10 seconds for timeouts or invalid responses.
112
- - Renewal requests time out after 10 seconds and concurrent calls share a
113
- single request. A token saved while a renewal is in flight wins over the
114
- renewed value.
113
+ further renewal attempts for that token are paused for 30 seconds. Renewal
114
+ runs in the background, so the worst case is about 6.5 seconds of background
115
+ work (two 3 s attempts plus the 500 ms backoff) and never blocks the caller.
116
+ If the token expired during the attempt, the session is cleared and later
117
+ calls return `null`.
118
+ - Renewal requests time out after 3 seconds and concurrent calls share a single
119
+ request. A token saved while a renewal is in flight wins over the renewed
120
+ value.
115
121
  - Renewed tokens are validated (decodable, non-empty, future `exp`, and
116
122
  `exp > iat` when `iat` is present) before they are persisted.
123
+ - `AUTH_RENEWAL_FAILED` is emitted with `{ reason }`, where `reason` is one of
124
+ `timeout`, `5xx`, `network` or `invalid-response`. It carries no token, URL or
125
+ PII; consumers may instrument their own telemetry. This package adds no
126
+ OpenTelemetry metric or dependency.
127
+
128
+ `removeToken()` clears every stored auth key. `removeToken(token)` is a
129
+ compare-and-remove operation: it clears the session only when the stored token
130
+ still equals `token`, so a stale response cannot discard a newer session.
117
131
 
118
132
  ## Authentication state and HTTP integration
119
133
 
@@ -140,13 +154,27 @@ export default defineNuxtPlugin(() => {
140
154
  });
141
155
  ```
142
156
 
143
- `registerAxiosAuthInterceptors(instance?)` attaches `Authorization: Bearer
144
- <token>` only when the request has no explicit `Authorization` header, and is
145
- idempotent per Axios instance (it returns `false` when already registered). It
146
- preserves an explicit header without renewing the token, and does not clear the
147
- session on a failed login to a path ending in `/users/session`; a `401` from
148
- any other endpoint (including `/users/session/refresh`) removes the stored token
149
- and emits `AUTH_UPDATED`.
157
+ `registerAxiosAuthInterceptors(instance = axios, { allowedOrigins = [],
158
+ loginPaths = ["/users/session"] } = {})` attaches `Authorization: Bearer
159
+ <token>` only when the request has no explicit `Authorization` header and the
160
+ effective target is allowed:
161
+
162
+ - relative URLs, the page origin, the origin of an absolute `Config.SERVER_URL`
163
+ and any origin listed in `allowedOrigins` receive the token;
164
+ - cross-origin requests to any other origin receive no token, do not trigger a
165
+ token renewal, and fail closed when an absolute URL cannot be parsed;
166
+ - `allowedOrigins` entries are normalised to origins and an invalid entry
167
+ throws `TypeError`;
168
+ - registration is idempotent per Axios instance and returns `false` when
169
+ already registered; options apply only on the first registration.
170
+
171
+ A `401` clears the stored token only when the failed request used the currently
172
+ stored token (the attached token is recorded on the request or parsed from the
173
+ sent `Authorization` header). A stale `401` is ignored, and a `401` from a
174
+ request that sent no token clears nothing. Login paths are configurable through
175
+ `loginPaths`: suffix matches on the effective pathname, default
176
+ `["/users/session"]`, replaced when configured, with empty entries ignored.
177
+ `loginPaths: []` exempts no path.
150
178
 
151
179
  ### Server URL configuration
152
180
 
@@ -161,14 +189,24 @@ await Config.get(); // { SERVER_URL: "https://api.example.com" }
161
189
  Config.reset(); // back to "/api"
162
190
  ```
163
191
 
164
- A non-same-origin `SERVER_URL` receives the bearer token when the opt-in Axios
165
- integration is used, so it must use HTTPS and allow the application origin
166
- through CORS.
192
+ `Config.set()` trims whitespace and removes trailing slashes, so
193
+ `"https://api.example.com///"` and `"/api/"` become `"https://api.example.com"`
194
+ and `"/api"`. In development it warns when the URL uses `http:` and is not a
195
+ loopback host (`localhost`, `127.0.0.1`, `[::1]`). A non-same-origin
196
+ `SERVER_URL` must use HTTPS and allow the application origin through CORS;
197
+ cross-origin requests receive the bearer token only when that origin is the
198
+ configured `SERVER_URL` origin or listed in `allowedOrigins`.
167
199
 
168
200
  ### Service fallbacks
169
201
 
170
- `UserService.isInitialized()` resolves `false` on network or HTTP errors
171
- instead of rejecting, so setup code can gate on it safely.
202
+ `UserService.isInitialized({ timeoutMs = 5000, signal, cacheTtlMs = 0 } = {})`
203
+ resolves `false` on network or HTTP errors instead of rejecting, so setup code
204
+ can gate on it safely. Each request is bounded by `timeoutMs` (5 s by default)
205
+ and accepts an optional `AbortSignal`. With `cacheTtlMs > 0` a successful
206
+ `true` result is reused for that duration (keyed by URL); `false` is never
207
+ cached and `cacheTtlMs: 0` disables the cache. Failures log a warning with a
208
+ `reason` (`timeout`, `aborted`, `http-<status>` or `network`) and no URL, token
209
+ or response body.
172
210
  `RefreshIntervalService.get()` falls back to `"10000"` when the stored value is
173
211
  missing or malformed; `set()` normalises valid non-negative integer strings
174
212
  (for example `"05000"` becomes `"5000"`) and throws `TypeError` otherwise.
@@ -2,32 +2,141 @@ import mitt from "mitt";
2
2
 
3
3
  export type AlertType = "info" | "success" | "warning" | "error";
4
4
 
5
+ export type AuthRenewalFailureReason =
6
+ | "timeout"
7
+ | "5xx"
8
+ | "network"
9
+ | "invalid-response";
10
+
5
11
  export interface AlertMessage {
6
12
  type?: AlertType;
7
13
  text: string;
8
14
  durationMs?: number;
9
15
  }
10
16
 
17
+ export interface AuthRenewalFailedEvent {
18
+ reason: AuthRenewalFailureReason;
19
+ }
20
+
21
+ export interface HandleErrorOptions {
22
+ debug?: boolean;
23
+ }
24
+
11
25
  // mitt's Emitter constraint requires string and symbol index signatures;
12
- // the explicit ALERT_MESSAGE/AUTH_UPDATED keys keep their payload typing.
26
+ // the explicit ALERT_MESSAGE/AUTH_UPDATED/AUTH_RENEWAL_FAILED keys keep their
27
+ // payload typing.
13
28
  interface AppEvents {
14
29
  [event: string]: unknown;
15
30
  [event: symbol]: unknown;
16
31
  ALERT_MESSAGE: AlertMessage;
17
32
  AUTH_UPDATED: void;
33
+ AUTH_RENEWAL_FAILED: AuthRenewalFailedEvent;
18
34
  }
19
35
 
20
36
  export const EventTypes = {
21
37
  ALERT_MESSAGE: "ALERT_MESSAGE",
22
38
  AUTH_UPDATED: "AUTH_UPDATED",
39
+ AUTH_RENEWAL_FAILED: "AUTH_RENEWAL_FAILED",
23
40
  } as const;
24
41
 
25
42
  export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
26
43
 
27
44
  export const EventBus = mitt<AppEvents>();
28
45
 
29
- export function handleError(error: unknown): void {
30
- console.error(error);
46
+ const SENSITIVE_HEADER =
47
+ /authorization|proxy-authorization|cookie|set-cookie|x-api-key|token|secret|key|auth/i;
48
+ const BEARER_VALUE = /Bearer\s+[A-Za-z0-9._~+/=-]+/gi;
49
+ const JWT_VALUE =
50
+ /[A-Za-z0-9_+/-]{6,}\.[A-Za-z0-9_+/=-]{6,}\.[A-Za-z0-9_+/=-]{6,}/g;
51
+
52
+ interface ErrorCandidate {
53
+ message?: unknown;
54
+ config?: {
55
+ url?: unknown;
56
+ method?: unknown;
57
+ headers?: unknown;
58
+ };
59
+ response?: {
60
+ status?: unknown;
61
+ };
62
+ }
63
+
64
+ function redactText(text: string): string {
65
+ return text
66
+ .replace(BEARER_VALUE, "Bearer [redacted]")
67
+ .replace(JWT_VALUE, "[redacted]");
68
+ }
69
+
70
+ function toPath(url: string): string {
71
+ try {
72
+ return new URL(url, "http://localhost").pathname;
73
+ } catch {
74
+ return url.split(/[?#]/, 1)[0] ?? url;
75
+ }
76
+ }
77
+
78
+ function toPlainHeaders(headers: unknown): Record<string, unknown> | null {
79
+ if (typeof headers !== "object" || headers === null) return null;
80
+ const candidate = headers as { toJSON?: () => unknown };
81
+ if (typeof candidate.toJSON === "function") {
82
+ const plain = candidate.toJSON();
83
+ return typeof plain === "object" && plain !== null
84
+ ? (plain as Record<string, unknown>)
85
+ : null;
86
+ }
87
+ return headers as Record<string, unknown>;
88
+ }
89
+
90
+ function sanitizeHeaders(
91
+ headers: unknown,
92
+ ): Record<string, string> | undefined {
93
+ const plain = toPlainHeaders(headers);
94
+ if (!plain) return undefined;
95
+ const sanitized: Record<string, string> = {};
96
+ for (const [name, value] of Object.entries(plain)) {
97
+ sanitized[name] = SENSITIVE_HEADER.test(name)
98
+ ? "[redacted]"
99
+ : String(value);
100
+ }
101
+ return sanitized;
102
+ }
103
+
104
+ function errorMessage(error: unknown, candidate: ErrorCandidate): string {
105
+ if (error instanceof Error) return error.message;
106
+ if (typeof candidate.message === "string") return candidate.message;
107
+ return String(error);
108
+ }
109
+
110
+ function sanitizeError(error: unknown): Record<string, unknown> {
111
+ const candidate =
112
+ typeof error === "object" && error !== null
113
+ ? (error as ErrorCandidate)
114
+ : {};
115
+ const summary: Record<string, unknown> = {
116
+ message: redactText(errorMessage(error, candidate)),
117
+ };
118
+
119
+ const status = candidate.response?.status;
120
+ if (typeof status === "number") summary.status = status;
121
+
122
+ const method = candidate.config?.method;
123
+ if (typeof method === "string") summary.method = method;
124
+
125
+ const url = candidate.config?.url;
126
+ if (typeof url === "string") summary.path = toPath(url);
127
+
128
+ const headers = sanitizeHeaders(candidate.config?.headers);
129
+ if (headers) summary.headers = headers;
130
+
131
+ return summary;
132
+ }
133
+
134
+ export function handleError(
135
+ error: unknown,
136
+ options: HandleErrorOptions = {},
137
+ ): void {
138
+ console.error(options.debug ? error : sanitizeError(error));
139
+
31
140
  let text = error instanceof Error ? error.message : String(error);
32
141
 
33
142
  if (typeof error === "object" && error !== null && "response" in error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/common-web",
3
- "version": "0.9.0-beta.17.623b2b7",
3
+ "version": "0.10.0-beta.18.afa437b",
4
4
  "description": "Shared Nuxt layer for DevOpsPlaybook web applications",
5
5
  "license": "ISC",
6
6
  "repository": {
@@ -1,13 +1,17 @@
1
1
  import axios from "axios";
2
2
  import { jwtDecode } from "jwt-decode";
3
- import { EventBus, EventTypes } from "../composables/EventBus";
3
+ import {
4
+ EventBus,
5
+ EventTypes,
6
+ type AuthRenewalFailureReason,
7
+ } from "../composables/EventBus";
4
8
  import Config from "./Config";
5
9
  import { Timeout } from "./Timeout";
6
10
 
7
11
  export const AUTH_TOKEN_KEY = "auth_token";
8
12
  const LEGACY_TOKEN_KEYS = ["AUTH_TOKEN", "AUTH_TOKEN_KEY"] as const;
9
13
 
10
- const RENEWAL_TIMEOUT_MS = 10_000;
14
+ const RENEWAL_TIMEOUT_MS = 3_000;
11
15
  const RENEWAL_RETRY_DELAY_MS = 500;
12
16
  const RENEWAL_FAILURE_COOLDOWN_MS = 30_000;
13
17
  const RENEWAL_MAX_ATTEMPTS = 2;
@@ -25,12 +29,19 @@ export interface AuthHeaders {
25
29
 
26
30
  class SessionRenewalError extends Error {
27
31
  retryable: boolean;
32
+ reason: AuthRenewalFailureReason;
28
33
  status?: number;
29
34
 
30
- constructor(message: string, retryable: boolean, status?: number) {
35
+ constructor(
36
+ message: string,
37
+ retryable: boolean,
38
+ reason: AuthRenewalFailureReason,
39
+ status?: number,
40
+ ) {
31
41
  super(message);
32
42
  this.name = "SessionRenewalError";
33
43
  this.retryable = retryable;
44
+ this.reason = reason;
34
45
  this.status = status;
35
46
  }
36
47
  }
@@ -56,12 +67,17 @@ export class AuthService {
56
67
  }
57
68
 
58
69
  /**
59
- * Clears every stored token and emits `AUTH_UPDATED`. The optional argument
60
- * is accepted for API compatibility with older callers and is ignored.
70
+ * Clears every stored token and emits `AUTH_UPDATED` once.
71
+ *
72
+ * Without an argument every auth key is removed. With a token, this is a
73
+ * compare-and-remove operation: the session is cleared only when the stored
74
+ * token still equals the argument, so a stale response cannot discard a
75
+ * newer session.
61
76
  */
62
- static async removeToken(_token?: string): Promise<void> {
77
+ static async removeToken(token?: string): Promise<void> {
63
78
  const storage = AuthService.storage();
64
79
  if (!storage) return;
80
+ if (token !== undefined && AuthService.readToken() !== token) return;
65
81
  const keys = [AUTH_TOKEN_KEY, ...LEGACY_TOKEN_KEYS];
66
82
  const present = keys.filter((key) => storage.getItem(key) !== null);
67
83
  if (present.length === 0) return;
@@ -91,7 +107,11 @@ export class AuthService {
91
107
  decoded.exp > decoded.iat &&
92
108
  decoded.exp - now <= (decoded.exp - decoded.iat) * 0.2
93
109
  ) {
94
- return AuthService.renewToken(token);
110
+ // Renew in the background: the caller keeps using the still-valid token
111
+ // and a later call picks up the renewed value. Expected failures are
112
+ // handled inside renewal, so this promise never rejects.
113
+ void AuthService.renewToken(token);
114
+ return token;
95
115
  }
96
116
 
97
117
  return token;
@@ -130,16 +150,38 @@ export class AuthService {
130
150
 
131
151
  private static tryDecode(token: string): AuthTokenClaims | null {
132
152
  try {
133
- const decoded = jwtDecode<AuthTokenClaims>(token);
134
- if (typeof decoded !== "object" || decoded === null) return null;
135
- return decoded;
153
+ const decoded = jwtDecode<unknown>(token);
154
+ if (
155
+ typeof decoded !== "object" ||
156
+ decoded === null ||
157
+ Array.isArray(decoded)
158
+ ) {
159
+ return null;
160
+ }
161
+ const claims = decoded as Record<string, unknown>;
162
+ if (
163
+ "exp" in claims &&
164
+ (typeof claims.exp !== "number" || !Number.isFinite(claims.exp))
165
+ ) {
166
+ return null;
167
+ }
168
+ if (
169
+ "iat" in claims &&
170
+ (typeof claims.iat !== "number" || !Number.isFinite(claims.iat))
171
+ ) {
172
+ return null;
173
+ }
174
+ return {
175
+ exp: claims.exp as number | undefined,
176
+ iat: claims.iat as number | undefined,
177
+ };
136
178
  } catch {
137
179
  return null;
138
180
  }
139
181
  }
140
182
 
141
- private static async clearSession(): Promise<void> {
142
- await AuthService.removeToken();
183
+ private static async clearSession(token?: string): Promise<void> {
184
+ await AuthService.removeToken(token);
143
185
  }
144
186
 
145
187
  private static emitAuthUpdated(): void {
@@ -174,10 +216,9 @@ export class AuthService {
174
216
  private static async requestTokenRenewal(
175
217
  token: string,
176
218
  ): Promise<string | null> {
177
- const config = await Config.get();
178
-
179
219
  for (let attempt = 1; attempt <= RENEWAL_MAX_ATTEMPTS; attempt += 1) {
180
220
  try {
221
+ const config = await Config.get();
181
222
  const renewedToken = await AuthService.requestRenewedToken(
182
223
  config.SERVER_URL,
183
224
  token,
@@ -194,19 +235,19 @@ export class AuthService {
194
235
  if (renewalError.status === 401 || renewalError.status === 403) {
195
236
  const storedToken = AuthService.readToken();
196
237
  if (storedToken !== token) return storedToken;
197
- await AuthService.clearSession();
238
+ await AuthService.clearSession(token);
198
239
  return null;
199
240
  }
200
241
 
201
242
  if (!renewalError.retryable || attempt === RENEWAL_MAX_ATTEMPTS) {
202
- return AuthService.handleRenewalFailure(token);
243
+ return AuthService.handleRenewalFailure(token, renewalError.reason);
203
244
  }
204
245
 
205
246
  await Timeout.wait(RENEWAL_RETRY_DELAY_MS);
206
247
  }
207
248
  }
208
249
 
209
- return AuthService.handleRenewalFailure(token);
250
+ return AuthService.handleRenewalFailure(token, "network");
210
251
  }
211
252
 
212
253
  private static async requestRenewedToken(
@@ -230,6 +271,7 @@ export class AuthService {
230
271
  throw new SessionRenewalError(
231
272
  "Session renewal response did not include a token",
232
273
  false,
274
+ "invalid-response",
233
275
  );
234
276
  }
235
277
 
@@ -238,6 +280,7 @@ export class AuthService {
238
280
  throw new SessionRenewalError(
239
281
  "Session renewal returned an invalid token",
240
282
  false,
283
+ "invalid-response",
241
284
  );
242
285
  }
243
286
 
@@ -250,6 +293,7 @@ export class AuthService {
250
293
  throw new SessionRenewalError(
251
294
  "Session renewal returned a token without a valid expiry",
252
295
  false,
296
+ "invalid-response",
253
297
  );
254
298
  }
255
299
 
@@ -260,6 +304,7 @@ export class AuthService {
260
304
  throw new SessionRenewalError(
261
305
  "Session renewal returned a token with an inconsistent claim window",
262
306
  false,
307
+ "invalid-response",
263
308
  );
264
309
  }
265
310
 
@@ -282,29 +327,58 @@ export class AuthService {
282
327
  return new SessionRenewalError(
283
328
  "Session renewal was rejected",
284
329
  false,
330
+ "invalid-response",
285
331
  status,
286
332
  );
287
333
  }
288
334
 
289
335
  if (candidate?.code === "ECONNABORTED" || candidate?.code === "ETIMEDOUT") {
290
- return new SessionRenewalError("Session renewal timed out", false, status);
336
+ return new SessionRenewalError(
337
+ "Session renewal timed out",
338
+ false,
339
+ "timeout",
340
+ status,
341
+ );
291
342
  }
292
343
 
293
344
  if (typeof status === "number") {
294
345
  return new SessionRenewalError(
295
346
  `Session renewal failed with status ${status}`,
296
347
  status >= 500,
348
+ status >= 500 ? "5xx" : "invalid-response",
297
349
  status,
298
350
  );
299
351
  }
300
352
 
301
- return new SessionRenewalError("Session renewal failed", true, status);
353
+ return new SessionRenewalError(
354
+ "Session renewal failed",
355
+ true,
356
+ "network",
357
+ status,
358
+ );
302
359
  }
303
360
 
304
- private static handleRenewalFailure(token: string): string | null {
361
+ private static async handleRenewalFailure(
362
+ token: string,
363
+ reason: AuthRenewalFailureReason,
364
+ ): Promise<string | null> {
305
365
  const storedToken = AuthService.readToken();
306
366
  if (storedToken !== token) return storedToken;
307
367
 
368
+ EventBus.emit(EventTypes.AUTH_RENEWAL_FAILED, { reason });
369
+
370
+ // A slow renewal can outlive the token it tried to refresh; never hand an
371
+ // expired token back to the caller.
372
+ const decoded = AuthService.tryDecode(token);
373
+ const expired =
374
+ !decoded ||
375
+ (typeof decoded.exp === "number" &&
376
+ decoded.exp <= Date.now() / 1000);
377
+ if (expired) {
378
+ await AuthService.removeToken(token);
379
+ return null;
380
+ }
381
+
308
382
  AuthService.renewalFailure = { token, at: Date.now() };
309
383
  return token;
310
384
  }
@@ -1,30 +1,162 @@
1
1
  import axios, { AxiosHeaders } from "axios";
2
- import type { AxiosInstance } from "axios";
2
+ import type {
3
+ AxiosInstance,
4
+ InternalAxiosRequestConfig,
5
+ RawAxiosHeaders,
6
+ } from "axios";
3
7
  import { AuthService } from "./AuthService";
8
+ import Config from "./Config";
4
9
 
5
10
  const REGISTRATION_MARKER = Symbol.for("common-web.axios-auth");
11
+ const TOKEN_CONTEXT = Symbol("common-web.axios-auth-token");
12
+
13
+ const DEFAULT_LOGIN_PATHS = ["/users/session"];
14
+
15
+ export interface AxiosAuthInterceptorOptions {
16
+ /** Extra origins (for example `https://analytics.example.com`) that may receive the token. */
17
+ allowedOrigins?: string[];
18
+ /** Path suffixes exempt from the 401 session clear (for example `/auth/login`). */
19
+ loginPaths?: string[];
20
+ }
6
21
 
7
22
  type MarkedAxiosInstance = AxiosInstance & {
8
23
  [key: symbol]: unknown;
9
24
  };
10
25
 
11
- function isLoginRequest(url: unknown): boolean {
12
- if (typeof url !== "string" || url.length === 0) return false;
26
+ type TokenContextConfig = InternalAxiosRequestConfig & {
27
+ [TOKEN_CONTEXT]?: string;
28
+ };
29
+
30
+ function pageOrigin(): string | null {
31
+ if (typeof window === "undefined" || !window.location) return null;
32
+ const origin = window.location.origin;
33
+ return origin && origin !== "null" ? origin : null;
34
+ }
35
+
36
+ function absoluteOrigin(value: string): string | null {
13
37
  try {
14
- const { pathname } = new URL(url, "http://localhost");
15
- return pathname.replace(/\/+$/, "").endsWith("/users/session");
38
+ const url = new URL(value);
39
+ return url.origin === "null" ? null : url.origin;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function normalizeOrigins(origins: string[]): Set<string> {
46
+ return new Set(
47
+ origins.map((entry) => {
48
+ try {
49
+ return new URL(entry).origin;
50
+ } catch {
51
+ throw new TypeError(`Invalid allowed origin: ${entry}`);
52
+ }
53
+ }),
54
+ );
55
+ }
56
+
57
+ function normalizeLoginPaths(paths: string[]): string[] {
58
+ return paths
59
+ .map((path) => {
60
+ try {
61
+ return new URL(path, "http://localhost").pathname.replace(/\/+$/, "");
62
+ } catch {
63
+ return path.replace(/[?#].*$/, "").replace(/\/+$/, "");
64
+ }
65
+ })
66
+ .filter((path) => path.length > 0);
67
+ }
68
+
69
+ function isAllowedTarget(
70
+ uri: string,
71
+ serverOrigin: string | null,
72
+ allowedOrigins: Set<string>,
73
+ ): boolean {
74
+ let url: URL;
75
+ try {
76
+ url = new URL(uri);
77
+ } catch {
78
+ if (!uri.startsWith("//")) {
79
+ // Relative URLs inherit the page origin, so they are always allowed.
80
+ return true;
81
+ }
82
+ const origin = pageOrigin();
83
+ if (!origin) return false;
84
+ try {
85
+ url = new URL(uri, origin);
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ if (url.origin === "null") return false;
92
+ if (url.origin === pageOrigin()) return true;
93
+ if (serverOrigin !== null && url.origin === serverOrigin) return true;
94
+ return allowedOrigins.has(url.origin);
95
+ }
96
+
97
+ function effectiveUri(
98
+ instance: AxiosInstance,
99
+ config: InternalAxiosRequestConfig,
100
+ ): string {
101
+ try {
102
+ return instance.getUri(config);
103
+ } catch {
104
+ // Axios rejects some special-scheme URLs while building the full path;
105
+ // fall back to the raw target so the origin check can still fail closed.
106
+ return typeof config.url === "string" ? config.url : "";
107
+ }
108
+ }
109
+
110
+ function isLoginRequest(
111
+ instance: AxiosInstance,
112
+ config: InternalAxiosRequestConfig,
113
+ loginPaths: string[],
114
+ ): boolean {
115
+ let uri: string;
116
+ try {
117
+ uri = instance.getUri(config);
118
+ } catch {
119
+ return false;
120
+ }
121
+
122
+ let pathname: string;
123
+ try {
124
+ pathname = new URL(uri, "http://localhost").pathname.replace(/\/+$/, "");
16
125
  } catch {
17
126
  return false;
18
127
  }
128
+
129
+ return loginPaths.some(
130
+ (loginPath) => pathname === loginPath || pathname.endsWith(loginPath),
131
+ );
132
+ }
133
+
134
+ function sentBearerToken(headers: unknown): string | null {
135
+ if (typeof headers !== "object" || headers === null) return null;
136
+ const parsed = AxiosHeaders.from(headers as RawAxiosHeaders);
137
+ const value = parsed.get("Authorization");
138
+ if (typeof value !== "string") return null;
139
+ const match = /^Bearer\s+(.+)$/i.exec(value.trim());
140
+ const token = match?.[1];
141
+ return token ? token.trim() : null;
19
142
  }
20
143
 
21
144
  export function registerAxiosAuthInterceptors(
22
145
  instance: AxiosInstance = axios,
146
+ options: AxiosAuthInterceptorOptions = {},
23
147
  ): boolean {
24
148
  const marked = instance as MarkedAxiosInstance;
25
149
  if (marked[REGISTRATION_MARKER]) {
26
150
  return false;
27
151
  }
152
+
153
+ // Validate options before marking the instance so a failed registration
154
+ // leaves it reusable.
155
+ const allowedOrigins = normalizeOrigins(options.allowedOrigins ?? []);
156
+ const loginPaths = normalizeLoginPaths(
157
+ options.loginPaths ?? DEFAULT_LOGIN_PATHS,
158
+ );
159
+
28
160
  marked[REGISTRATION_MARKER] = true;
29
161
 
30
162
  instance.interceptors.request.use(async (config) => {
@@ -33,10 +165,17 @@ export function registerAxiosAuthInterceptors(
33
165
  return config;
34
166
  }
35
167
 
168
+ const uri = effectiveUri(instance, config);
169
+ const { SERVER_URL } = await Config.get();
170
+ if (!isAllowedTarget(uri, absoluteOrigin(SERVER_URL), allowedOrigins)) {
171
+ return config;
172
+ }
173
+
36
174
  const token = await AuthService.getToken();
37
175
  if (token) {
38
176
  headers.set("Authorization", `Bearer ${token}`);
39
177
  config.headers = headers;
178
+ (config as TokenContextConfig)[TOKEN_CONTEXT] = token;
40
179
  }
41
180
  return config;
42
181
  });
@@ -44,8 +183,15 @@ export function registerAxiosAuthInterceptors(
44
183
  instance.interceptors.response.use(
45
184
  (response) => response,
46
185
  async (error) => {
47
- if (error?.response?.status === 401 && !isLoginRequest(error?.config?.url)) {
48
- await AuthService.removeToken();
186
+ if (
187
+ error?.response?.status === 401 &&
188
+ error?.config &&
189
+ !isLoginRequest(instance, error.config, loginPaths)
190
+ ) {
191
+ const config = error.config as TokenContextConfig;
192
+ const sentToken =
193
+ config[TOKEN_CONTEXT] ?? sentBearerToken(config.headers);
194
+ if (sentToken) await AuthService.removeToken(sentToken);
49
195
  }
50
196
  return Promise.reject(error);
51
197
  },
@@ -3,6 +3,28 @@ export interface AppConfig {
3
3
  }
4
4
 
5
5
  const DEFAULT_CONFIG: AppConfig = { SERVER_URL: "/api" };
6
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
7
+
8
+ function normalizeServerUrl(value: string): string {
9
+ return value.trim().replace(/\/+$/, "");
10
+ }
11
+
12
+ function warnAboutInsecureUrl(serverUrl: string): void {
13
+ const inDevelopment =
14
+ typeof process !== "undefined" && process.env.NODE_ENV !== "production";
15
+ if (!inDevelopment) return;
16
+
17
+ try {
18
+ const url = new URL(serverUrl);
19
+ if (url.protocol === "http:" && !LOOPBACK_HOSTS.has(url.hostname)) {
20
+ console.warn(
21
+ `[common-web] SERVER_URL uses insecure HTTP: ${url.origin}`,
22
+ );
23
+ }
24
+ } catch {
25
+ // Relative URLs inherit the page scheme and are not checked.
26
+ }
27
+ }
6
28
 
7
29
  export default class Config {
8
30
  private static overrides: Partial<AppConfig> = {};
@@ -12,7 +34,12 @@ export default class Config {
12
34
  }
13
35
 
14
36
  static set(config: Partial<AppConfig>): void {
15
- Config.overrides = { ...Config.overrides, ...config };
37
+ const next: Partial<AppConfig> = { ...config };
38
+ if (typeof config.SERVER_URL === "string") {
39
+ next.SERVER_URL = normalizeServerUrl(config.SERVER_URL);
40
+ warnAboutInsecureUrl(next.SERVER_URL);
41
+ }
42
+ Config.overrides = { ...Config.overrides, ...next };
16
43
  }
17
44
 
18
45
  static reset(): void {
@@ -1,16 +1,82 @@
1
1
  import axios from "axios";
2
2
  import Config from "./Config";
3
3
 
4
+ export interface UserInitializationOptions {
5
+ timeoutMs?: number;
6
+ signal?: AbortSignal;
7
+ cacheTtlMs?: number;
8
+ }
9
+
10
+ type InitializationFailureReason =
11
+ | "timeout"
12
+ | "aborted"
13
+ | "network"
14
+ | `http-${number}`;
15
+
16
+ const DEFAULT_TIMEOUT_MS = 5_000;
17
+
4
18
  export class UserService {
5
- static async isInitialized(): Promise<boolean> {
19
+ private static cache: { url: string; expiresAt: number } | null = null;
20
+
21
+ static async isInitialized(
22
+ options: UserInitializationOptions = {},
23
+ ): Promise<boolean> {
24
+ const cacheTtlMs = options.cacheTtlMs ?? 0;
6
25
  try {
7
26
  const config = await Config.get();
8
- const response = await axios.get<{ initialized: boolean }>(
9
- `${config.SERVER_URL}/users/status/initialization`,
10
- );
11
- return response.data.initialized === true;
12
- } catch {
27
+ const url = `${config.SERVER_URL}/users/status/initialization`;
28
+
29
+ if (
30
+ cacheTtlMs > 0 &&
31
+ UserService.cache?.url === url &&
32
+ UserService.cache.expiresAt > Date.now()
33
+ ) {
34
+ return true;
35
+ }
36
+
37
+ const response = await axios.get<{ initialized: boolean }>(url, {
38
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
39
+ ...(options.signal ? { signal: options.signal } : {}),
40
+ });
41
+ const initialized = response.data.initialized === true;
42
+
43
+ if (initialized && cacheTtlMs > 0) {
44
+ UserService.cache = { url, expiresAt: Date.now() + cacheTtlMs };
45
+ }
46
+
47
+ return initialized;
48
+ } catch (error) {
49
+ console.warn("[common-web] user initialization check failed", {
50
+ reason: UserService.failureReason(error),
51
+ });
13
52
  return false;
14
53
  }
15
54
  }
55
+
56
+ private static failureReason(
57
+ error: unknown,
58
+ ): InitializationFailureReason {
59
+ const candidate = error as
60
+ | { code?: unknown; name?: unknown; response?: { status?: unknown } }
61
+ | null
62
+ | undefined;
63
+
64
+ if (
65
+ candidate?.code === "ECONNABORTED" ||
66
+ candidate?.code === "ETIMEDOUT"
67
+ ) {
68
+ return "timeout";
69
+ }
70
+ if (
71
+ candidate?.code === "ERR_CANCELED" ||
72
+ candidate?.name === "CanceledError"
73
+ ) {
74
+ return "aborted";
75
+ }
76
+
77
+ const status = candidate?.response?.status;
78
+ if (typeof status === "number") return `http-${status}`;
79
+
80
+ return "network";
81
+ }
16
82
  }
@@ -1,5 +1,5 @@
1
1
  import { acceptHMRUpdate, defineStore } from "pinia";
2
- import { ref } from "vue";
2
+ import { onScopeDispose, ref } from "vue";
3
3
  import { EventBus, EventTypes } from "../composables/EventBus";
4
4
  import { AUTH_TOKEN_KEY, AuthService } from "../services/AuthService";
5
5
 
@@ -29,8 +29,12 @@ export function createAuthenticationStore() {
29
29
  }
30
30
 
31
31
  if (typeof window !== "undefined") {
32
- EventBus.on(EventTypes.AUTH_UPDATED, () => {
32
+ const handleAuthUpdated = () => {
33
33
  void refreshAuthentication();
34
+ };
35
+ EventBus.on(EventTypes.AUTH_UPDATED, handleAuthUpdated);
36
+ onScopeDispose(() => {
37
+ EventBus.off(EventTypes.AUTH_UPDATED, handleAuthUpdated);
34
38
  });
35
39
  registerStorageSync();
36
40
  }