@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 +49 -0
- package/README.md +63 -25
- package/composables/EventBus.ts +112 -3
- package/package.json +1 -1
- package/services/AuthService.ts +94 -20
- package/services/AxiosAuthInterceptor.ts +153 -7
- package/services/Config.ts +28 -1
- package/services/UserService.ts +72 -6
- package/stores/AuthenticationStore.ts +6 -2
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
|
|
41
|
-
|
|
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`
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
renewed
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
and
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
171
|
-
instead of rejecting, so setup code
|
|
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.
|
package/composables/EventBus.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
30
|
-
|
|
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
package/services/AuthService.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
import { jwtDecode } from "jwt-decode";
|
|
3
|
-
import {
|
|
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 =
|
|
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(
|
|
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
|
|
60
|
-
*
|
|
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(
|
|
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
|
-
|
|
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<
|
|
134
|
-
if (
|
|
135
|
-
|
|
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(
|
|
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(
|
|
353
|
+
return new SessionRenewalError(
|
|
354
|
+
"Session renewal failed",
|
|
355
|
+
true,
|
|
356
|
+
"network",
|
|
357
|
+
status,
|
|
358
|
+
);
|
|
302
359
|
}
|
|
303
360
|
|
|
304
|
-
private static handleRenewalFailure(
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
15
|
-
return
|
|
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 (
|
|
48
|
-
|
|
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
|
},
|
package/services/Config.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/services/UserService.ts
CHANGED
|
@@ -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
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
}
|