@tumbaland/frontend-core 1.13.0 → 1.14.0

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.
@@ -3,6 +3,8 @@ export interface AuthServiceConfig {
3
3
  /** Read fresh at call time (config may not be resolved yet at module load). */
4
4
  getAuthServiceUrl: () => string;
5
5
  getAuthFrontUrl: () => string;
6
+ /** The shell's URL. When the app runs inside it, the shell answers auth checks. */
7
+ getShellUrl?: () => string | undefined;
6
8
  }
7
9
  /**
8
10
  * Builds a per-front auth service instance. Each of the 7 frontends that
@@ -37,10 +39,11 @@ export type AuthService = ReturnType<typeof createAuthService>;
37
39
  export interface AppAuthConfig {
38
40
  AUTH_SERVICE_URL?: string;
39
41
  AUTH_FRONT_URL?: string;
42
+ SHELL_FRONT_URL?: string;
40
43
  }
41
44
  /**
42
45
  * Convenience over `createAuthService` for the frontends, which all read the
43
- * same two auth URLs from their resolved config. Centralizes that key mapping
46
+ * same auth URLs (and the shell's) from their resolved config. Centralizes that key mapping
44
47
  * so each app's `services/authService.ts` is just:
45
48
  *
46
49
  * export const authService = createAppAuthService(getGlobalConfig);
@@ -1,6 +1,63 @@
1
1
  import { getCorrelationId, getSessionId, logger } from './monitoring';
2
2
  import { clearSessionScopedStorage } from './sessionStorage';
3
3
  const AUTH_CACHE_TTL = 5 * 60 * 1000;
4
+ /** How long an embedded app waits for the shell before asking auth-service itself. */
5
+ const SHELL_AUTH_TIMEOUT_MS = 3000;
6
+ /**
7
+ * The shell's origin, when this app runs inside it: framed, and loaded with the
8
+ * `embedded=1` flag the shell puts in the iframe src (see buildEmbeddedUrl in
9
+ * @tumbaland/components). A direct visit is never embedded.
10
+ */
11
+ function embeddingShellOrigin(shellUrl) {
12
+ if (typeof window === 'undefined' || window.parent === window.self || !shellUrl)
13
+ return null;
14
+ if (new URLSearchParams(window.location.search).get('embedded') !== '1')
15
+ return null;
16
+ try {
17
+ return new URL(shellUrl).origin;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ /**
24
+ * Asks the shell for its auth state instead of calling /auth/verify.
25
+ *
26
+ * The shell has already checked the session when it framed this app, and
27
+ * every app calling verify on its own meant several parallel renewals on each
28
+ * page open. Resolves to null when the shell does not answer in time (an older
29
+ * shell, or a slow one), and the caller then asks auth-service itself.
30
+ *
31
+ * The answer only decides what the app shows. Every API call still carries the
32
+ * session cookie and is checked by the service it goes to.
33
+ *
34
+ * Contract lives in @tumbaland/components `routing/messages` (app:auth-request
35
+ * and shell:auth); a lower layer cannot import it, so the literals are repeated.
36
+ */
37
+ function askShellForAuth(shellOrigin) {
38
+ return new Promise((resolve) => {
39
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
40
+ const finish = (result) => {
41
+ window.clearTimeout(timer);
42
+ window.removeEventListener('message', onMessage);
43
+ resolve(result);
44
+ };
45
+ const onMessage = (event) => {
46
+ if (event.origin !== shellOrigin || event.source !== window.parent)
47
+ return;
48
+ const data = event.data;
49
+ if (!data || data.source !== 'tumbaland-shell' || data.type !== 'shell:auth' || data.id !== id)
50
+ return;
51
+ finish({ authenticated: data.authenticated === true, user: data.user ?? null });
52
+ };
53
+ const timer = window.setTimeout(() => {
54
+ logger.warn('Shell did not answer the auth check; asking auth-service instead');
55
+ finish(null);
56
+ }, SHELL_AUTH_TIMEOUT_MS);
57
+ window.addEventListener('message', onMessage);
58
+ window.parent.postMessage({ source: 'tumbaland-app', type: 'app:auth-request', id }, shellOrigin);
59
+ });
60
+ }
4
61
  /**
5
62
  * Tell the shell its session just ended.
6
63
  *
@@ -52,10 +109,51 @@ function authHeaders() {
52
109
  export function createAuthService(config) {
53
110
  let authCache = null;
54
111
  let authCacheTime = 0;
112
+ let pendingCheck = null;
113
+ // Bumped by clearAuthCache, so a check already in flight (say, across a
114
+ // logout) cannot put its stale answer back into the cache.
115
+ let cacheGeneration = 0;
116
+ /** Inside the shell, the shell's answer; otherwise (or if it does not answer) auth-service's. */
117
+ const resolveAuth = async () => {
118
+ const generation = cacheGeneration;
119
+ const remember = (result) => {
120
+ if (generation === cacheGeneration) {
121
+ authCache = result;
122
+ authCacheTime = Date.now();
123
+ }
124
+ return result;
125
+ };
126
+ const shellOrigin = embeddingShellOrigin(config.getShellUrl?.());
127
+ if (shellOrigin) {
128
+ const fromShell = await askShellForAuth(shellOrigin);
129
+ if (fromShell)
130
+ return remember(fromShell);
131
+ }
132
+ try {
133
+ const response = await fetch(`${config.getAuthServiceUrl()}/auth/verify`, {
134
+ credentials: 'include',
135
+ headers: authHeaders()
136
+ });
137
+ if (!response.ok) {
138
+ throw new Error(`HTTP error! status: ${response.status}`);
139
+ }
140
+ const data = await response.json();
141
+ return remember({
142
+ authenticated: data.success && data.authenticated,
143
+ user: data.user || null
144
+ });
145
+ }
146
+ catch (error) {
147
+ logger.error('Auth check failed', error);
148
+ return remember({ authenticated: false, user: null });
149
+ }
150
+ };
55
151
  return {
56
152
  clearAuthCache() {
57
153
  authCache = null;
58
154
  authCacheTime = 0;
155
+ pendingCheck = null;
156
+ cacheGeneration += 1;
59
157
  },
60
158
  /** Verifies the session against auth-service. Cached for 5 minutes. */
61
159
  async checkAuth() {
@@ -63,30 +161,13 @@ export function createAuthService(config) {
63
161
  if (authCache && now - authCacheTime < AUTH_CACHE_TTL) {
64
162
  return authCache;
65
163
  }
66
- try {
67
- const response = await fetch(`${config.getAuthServiceUrl()}/auth/verify`, {
68
- credentials: 'include',
69
- headers: authHeaders()
164
+ // Callers that ask at the same time share one check.
165
+ if (!pendingCheck) {
166
+ pendingCheck = resolveAuth().finally(() => {
167
+ pendingCheck = null;
70
168
  });
71
- if (!response.ok) {
72
- throw new Error(`HTTP error! status: ${response.status}`);
73
- }
74
- const data = await response.json();
75
- const result = {
76
- authenticated: data.success && data.authenticated,
77
- user: data.user || null
78
- };
79
- authCache = result;
80
- authCacheTime = now;
81
- return result;
82
- }
83
- catch (error) {
84
- logger.error('Auth check failed', error);
85
- const result = { authenticated: false, user: null };
86
- authCache = result;
87
- authCacheTime = now;
88
- return result;
89
169
  }
170
+ return pendingCheck;
90
171
  },
91
172
  redirectToLogin(returnUrl) {
92
173
  const currentUrl = returnUrl || window.location.href;
@@ -104,8 +185,7 @@ export function createAuthService(config) {
104
185
  }
105
186
  const data = await response.json();
106
187
  if (data.success) {
107
- authCache = null;
108
- authCacheTime = 0;
188
+ this.clearAuthCache();
109
189
  clearSessionScopedStorage();
110
190
  notifyShellOfLogout();
111
191
  window.location.reload();
@@ -149,7 +229,7 @@ export function createAuthService(config) {
149
229
  }
150
230
  /**
151
231
  * Convenience over `createAuthService` for the frontends, which all read the
152
- * same two auth URLs from their resolved config. Centralizes that key mapping
232
+ * same auth URLs (and the shell's) from their resolved config. Centralizes that key mapping
153
233
  * so each app's `services/authService.ts` is just:
154
234
  *
155
235
  * export const authService = createAppAuthService(getGlobalConfig);
@@ -159,6 +239,7 @@ export function createAuthService(config) {
159
239
  export function createAppAuthService(getGlobalConfig) {
160
240
  return createAuthService({
161
241
  getAuthServiceUrl: () => getGlobalConfig().AUTH_SERVICE_URL,
162
- getAuthFrontUrl: () => getGlobalConfig().AUTH_FRONT_URL
242
+ getAuthFrontUrl: () => getGlobalConfig().AUTH_FRONT_URL,
243
+ getShellUrl: () => getGlobalConfig().SHELL_FRONT_URL
163
244
  });
164
245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/frontend-core",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Shared frontend auth/group/API-client logic for Tumbaland frontends",
5
5
  "author": "Tumbaland",
6
6
  "license": "MIT",