@devopsplaybook.io/common-web 0.4.0-beta.11.4c5668d → 0.5.0-beta.12.6909540

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,23 @@ 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.5.0] - 2026-10-24
9
+
10
+ ### Fixed
11
+
12
+ - Treat corrupt or undecodable stored JWTs as signed out: clear every auth key,
13
+ emit `AUTH_UPDATED`, and return `null` instead of throwing.
14
+ - Bound session renewal: 10 s request timeout, at most one retry after a 500 ms
15
+ backoff for network/5xx failures, and a 30 s per-token failure cooldown so a
16
+ failing refresh endpoint is not retried on every authenticated call.
17
+ - Clear the session and emit `AUTH_UPDATED` when session renewal returns
18
+ 401/403 (revoked session).
19
+ - Validate renewed JWTs (decodable, non-empty, future `exp`, `exp > iat`) before
20
+ persisting them; keep the newer token when it changes during renewal.
21
+ - Make `AuthService` storage access SSR-safe: no `localStorage` access outside
22
+ the browser; `getToken()` returns `null` and save/remove are no-ops.
23
+ - Cover the failure paths of session renewal and token handling with tests.
24
+
8
25
  ## [0.4.0] - 2026-10-24
9
26
 
10
27
  ### Changed
package/README.md CHANGED
@@ -42,8 +42,10 @@ required. The `playground/` directory demonstrates a consuming app.
42
42
  - `useAppHeight()` updates `--app-height` from `visualViewport` when available
43
43
  and cleans up resize listeners on unmount.
44
44
  - `useOfflineStatus()` returns a reactive `isOnline` ref.
45
- - Browser services are available through package deep imports, for example
46
- `@devopsplaybook.io/common-web/services/AuthService`.
45
+ - Services are browser-only and available through package deep imports, for
46
+ example `@devopsplaybook.io/common-web/services/AuthService`. `AuthService`
47
+ guards every `localStorage` access, so during SSR `getToken()` resolves
48
+ `null` and `saveToken()` / `removeToken()` are no-ops.
47
49
  - `createAuthenticationStore()` returns the Pinia store definition; `Pinia`
48
50
  must be available in the consuming app.
49
51
 
@@ -55,6 +57,26 @@ When an authenticated token has entered the final 20% of its lifetime,
55
57
  returning it from `getToken()` or `getAuthHeader()`. This extends active
56
58
  sessions without requiring users to sign in again.
57
59
 
60
+ `getToken()` never rejects because of the stored or renewed token:
61
+
62
+ - A corrupt or undecodable token is treated as signed out: every auth key is
63
+ removed, `AUTH_UPDATED` is emitted and `null` is returned. Expired tokens are
64
+ removed and return `null`.
65
+ - A `401` or `403` from the refresh endpoint means the session was revoked:
66
+ the session is cleared, `AUTH_UPDATED` is emitted and `null` is returned.
67
+ - Timeouts and invalid renewal responses perform a single request. Network
68
+ errors and `5xx` responses retry once after a 500 ms backoff.
69
+ - When renewal still fails, the still-valid current token is returned and
70
+ further renewal attempts for that token are paused for 30 seconds, so a slow
71
+ or failing refresh endpoint is not retried on every authenticated call. The
72
+ worst case is about 20.5 seconds per call for network/`5xx` failures and
73
+ about 10 seconds for timeouts or invalid responses.
74
+ - Renewal requests time out after 10 seconds and concurrent calls share a
75
+ single request. A token saved while a renewal is in flight wins over the
76
+ renewed value.
77
+ - Renewed tokens are validated (decodable, non-empty, future `exp`, and
78
+ `exp > iat` when `iat` is present) before they are persisted.
79
+
58
80
  ## Tokens and app-shell styles
59
81
 
60
82
  The layer loads `assets/css/tokens.css` and `assets/css/app-shell.css`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/common-web",
3
- "version": "0.4.0-beta.11.4c5668d",
3
+ "version": "0.5.0-beta.12.6909540",
4
4
  "description": "Shared Nuxt layer for DevOpsPlaybook web applications",
5
5
  "license": "ISC",
6
6
  "engines": {
@@ -1,10 +1,17 @@
1
1
  import axios from "axios";
2
2
  import { jwtDecode } from "jwt-decode";
3
+ import { EventBus, EventTypes } from "../composables/EventBus";
3
4
  import Config from "./Config";
5
+ import { Timeout } from "./Timeout";
4
6
 
5
7
  export const AUTH_TOKEN_KEY = "auth_token";
6
8
  const LEGACY_TOKEN_KEYS = ["AUTH_TOKEN", "AUTH_TOKEN_KEY"] as const;
7
9
 
10
+ const RENEWAL_TIMEOUT_MS = 10_000;
11
+ const RENEWAL_RETRY_DELAY_MS = 500;
12
+ const RENEWAL_FAILURE_COOLDOWN_MS = 30_000;
13
+ const RENEWAL_MAX_ATTEMPTS = 2;
14
+
8
15
  interface AuthTokenClaims {
9
16
  exp?: number;
10
17
  iat?: number;
@@ -16,42 +23,53 @@ export interface AuthHeaders {
16
23
  };
17
24
  }
18
25
 
26
+ class SessionRenewalError extends Error {
27
+ retryable: boolean;
28
+ status?: number;
29
+
30
+ constructor(message: string, retryable: boolean, status?: number) {
31
+ super(message);
32
+ this.name = "SessionRenewalError";
33
+ this.retryable = retryable;
34
+ this.status = status;
35
+ }
36
+ }
37
+
19
38
  export class AuthService {
20
39
  private static renewal:
21
40
  | { token: string; promise: Promise<string | null> }
22
41
  | null = null;
23
42
 
43
+ private static renewalFailure: { token: string; at: number } | null = null;
44
+
24
45
  static async isAuthenticated(): Promise<boolean> {
25
46
  return Boolean(await AuthService.getToken());
26
47
  }
27
48
 
28
49
  static async saveToken(token: string): Promise<void> {
29
- localStorage.setItem(AUTH_TOKEN_KEY, token);
30
- for (const key of LEGACY_TOKEN_KEYS) localStorage.removeItem(key);
50
+ const storage = AuthService.storage();
51
+ if (!storage) return;
52
+ storage.setItem(AUTH_TOKEN_KEY, token);
53
+ for (const key of LEGACY_TOKEN_KEYS) storage.removeItem(key);
31
54
  }
32
55
 
33
56
  static async removeToken(_token?: string): Promise<void> {
34
- localStorage.removeItem(AUTH_TOKEN_KEY);
35
- for (const key of LEGACY_TOKEN_KEYS) localStorage.removeItem(key);
57
+ const storage = AuthService.storage();
58
+ if (!storage) return;
59
+ storage.removeItem(AUTH_TOKEN_KEY);
60
+ for (const key of LEGACY_TOKEN_KEYS) storage.removeItem(key);
36
61
  }
37
62
 
38
63
  static async getToken(): Promise<string | null> {
39
- let token = localStorage.getItem(AUTH_TOKEN_KEY);
64
+ const token = AuthService.readToken();
65
+ if (!token) return null;
40
66
 
41
- if (!token) {
42
- for (const legacyKey of LEGACY_TOKEN_KEYS) {
43
- token = localStorage.getItem(legacyKey);
44
- if (token) {
45
- localStorage.setItem(AUTH_TOKEN_KEY, token);
46
- localStorage.removeItem(legacyKey);
47
- break;
48
- }
49
- }
67
+ const decoded = AuthService.tryDecode(token);
68
+ if (!decoded) {
69
+ await AuthService.clearSession();
70
+ return null;
50
71
  }
51
72
 
52
- if (!token) return null;
53
-
54
- const decoded = jwtDecode<AuthTokenClaims>(token);
55
73
  const now = Date.now() / 1000;
56
74
  if (typeof decoded.exp === "number" && decoded.exp <= now) {
57
75
  await AuthService.removeToken();
@@ -77,12 +95,59 @@ export class AuthService {
77
95
  : {};
78
96
  }
79
97
 
98
+ private static storage(): Storage | null {
99
+ return typeof localStorage === "undefined" ? null : localStorage;
100
+ }
101
+
102
+ private static readToken(): string | null {
103
+ const storage = AuthService.storage();
104
+ if (!storage) return null;
105
+
106
+ let token = storage.getItem(AUTH_TOKEN_KEY);
107
+
108
+ if (!token) {
109
+ for (const legacyKey of LEGACY_TOKEN_KEYS) {
110
+ token = storage.getItem(legacyKey);
111
+ if (token) {
112
+ storage.setItem(AUTH_TOKEN_KEY, token);
113
+ storage.removeItem(legacyKey);
114
+ break;
115
+ }
116
+ }
117
+ }
118
+
119
+ return token;
120
+ }
121
+
122
+ private static tryDecode(token: string): AuthTokenClaims | null {
123
+ try {
124
+ const decoded = jwtDecode<AuthTokenClaims>(token);
125
+ if (typeof decoded !== "object" || decoded === null) return null;
126
+ return decoded;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ private static async clearSession(): Promise<void> {
133
+ await AuthService.removeToken();
134
+ EventBus.emit(EventTypes.AUTH_UPDATED);
135
+ }
136
+
80
137
  private static async renewToken(token: string): Promise<string | null> {
81
138
  const existing = AuthService.renewal;
82
139
  if (existing?.token === token) {
83
140
  return existing.promise;
84
141
  }
85
142
 
143
+ const failure = AuthService.renewalFailure;
144
+ if (
145
+ failure?.token === token &&
146
+ Date.now() - failure.at < RENEWAL_FAILURE_COOLDOWN_MS
147
+ ) {
148
+ return AuthService.readToken();
149
+ }
150
+
86
151
  const promise = AuthService.requestTokenRenewal(token);
87
152
  AuthService.renewal = { token, promise };
88
153
  try {
@@ -98,21 +163,137 @@ export class AuthService {
98
163
  token: string,
99
164
  ): Promise<string | null> {
100
165
  const config = await Config.get();
166
+
167
+ for (let attempt = 1; attempt <= RENEWAL_MAX_ATTEMPTS; attempt += 1) {
168
+ try {
169
+ const renewedToken = await AuthService.requestRenewedToken(
170
+ config.SERVER_URL,
171
+ token,
172
+ );
173
+
174
+ const storedToken = AuthService.readToken();
175
+ if (storedToken !== token) return storedToken;
176
+
177
+ await AuthService.saveToken(renewedToken);
178
+ return renewedToken;
179
+ } catch (error) {
180
+ const renewalError = AuthService.asRenewalError(error);
181
+
182
+ if (renewalError.status === 401 || renewalError.status === 403) {
183
+ const storedToken = AuthService.readToken();
184
+ if (storedToken !== token) return storedToken;
185
+ await AuthService.clearSession();
186
+ return null;
187
+ }
188
+
189
+ if (!renewalError.retryable || attempt === RENEWAL_MAX_ATTEMPTS) {
190
+ return AuthService.handleRenewalFailure(token);
191
+ }
192
+
193
+ await Timeout.wait(RENEWAL_RETRY_DELAY_MS);
194
+ }
195
+ }
196
+
197
+ return AuthService.handleRenewalFailure(token);
198
+ }
199
+
200
+ private static async requestRenewedToken(
201
+ serverUrl: string,
202
+ token: string,
203
+ ): Promise<string> {
101
204
  const response = await axios.post<{ token?: unknown }>(
102
- `${config.SERVER_URL}/users/session/refresh`,
205
+ `${serverUrl}/users/session/refresh`,
103
206
  {},
104
- { headers: { Authorization: `Bearer ${token}` } },
207
+ {
208
+ headers: { Authorization: `Bearer ${token}` },
209
+ timeout: RENEWAL_TIMEOUT_MS,
210
+ },
105
211
  );
212
+
106
213
  const renewedToken = response.data?.token;
107
- if (typeof renewedToken !== "string" || renewedToken.length === 0) {
108
- throw new Error("Session renewal response did not include a token");
214
+ if (
215
+ typeof renewedToken !== "string" ||
216
+ renewedToken.trim().length === 0
217
+ ) {
218
+ throw new SessionRenewalError(
219
+ "Session renewal response did not include a token",
220
+ false,
221
+ );
109
222
  }
110
223
 
111
- if (localStorage.getItem(AUTH_TOKEN_KEY) !== token) {
112
- return localStorage.getItem(AUTH_TOKEN_KEY);
224
+ const decoded = AuthService.tryDecode(renewedToken);
225
+ if (!decoded) {
226
+ throw new SessionRenewalError(
227
+ "Session renewal returned an invalid token",
228
+ false,
229
+ );
230
+ }
231
+
232
+ const now = Date.now() / 1000;
233
+ if (
234
+ typeof decoded.exp !== "number" ||
235
+ !Number.isFinite(decoded.exp) ||
236
+ decoded.exp <= now
237
+ ) {
238
+ throw new SessionRenewalError(
239
+ "Session renewal returned a token without a valid expiry",
240
+ false,
241
+ );
242
+ }
243
+
244
+ if (
245
+ typeof decoded.iat === "number" &&
246
+ decoded.exp <= decoded.iat
247
+ ) {
248
+ throw new SessionRenewalError(
249
+ "Session renewal returned a token with an inconsistent claim window",
250
+ false,
251
+ );
113
252
  }
114
253
 
115
- await AuthService.saveToken(renewedToken);
116
254
  return renewedToken;
117
255
  }
256
+
257
+ private static asRenewalError(error: unknown): SessionRenewalError {
258
+ if (error instanceof SessionRenewalError) return error;
259
+
260
+ const candidate = error as
261
+ | { code?: unknown; response?: { status?: unknown } }
262
+ | null
263
+ | undefined;
264
+ const status =
265
+ typeof candidate?.response?.status === "number"
266
+ ? candidate.response.status
267
+ : undefined;
268
+
269
+ if (status === 401 || status === 403) {
270
+ return new SessionRenewalError(
271
+ "Session renewal was rejected",
272
+ false,
273
+ status,
274
+ );
275
+ }
276
+
277
+ if (candidate?.code === "ECONNABORTED" || candidate?.code === "ETIMEDOUT") {
278
+ return new SessionRenewalError("Session renewal timed out", false, status);
279
+ }
280
+
281
+ if (typeof status === "number") {
282
+ return new SessionRenewalError(
283
+ `Session renewal failed with status ${status}`,
284
+ status >= 500,
285
+ status,
286
+ );
287
+ }
288
+
289
+ return new SessionRenewalError("Session renewal failed", true, status);
290
+ }
291
+
292
+ private static handleRenewalFailure(token: string): string | null {
293
+ const storedToken = AuthService.readToken();
294
+ if (storedToken !== token) return storedToken;
295
+
296
+ AuthService.renewalFailure = { token, at: Date.now() };
297
+ return token;
298
+ }
118
299
  }