@vertesia/ui 1.6.0-dev.20260918.061604Z → 1.6.0-dev.20260918.090710Z

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.
Files changed (40) hide show
  1. package/lib/env/index.d.ts +21 -7
  2. package/lib/env/index.d.ts.map +1 -1
  3. package/lib/env/index.js +7 -0
  4. package/lib/env/index.js.map +1 -1
  5. package/lib/session/UserSession.d.ts +1 -0
  6. package/lib/session/UserSession.d.ts.map +1 -1
  7. package/lib/session/UserSession.js +57 -2
  8. package/lib/session/UserSession.js.map +1 -1
  9. package/lib/session/UserSessionProvider.d.ts.map +1 -1
  10. package/lib/session/UserSessionProvider.js +25 -0
  11. package/lib/session/UserSessionProvider.js.map +1 -1
  12. package/lib/session/auth/domainRouting.d.ts +3 -4
  13. package/lib/session/auth/domainRouting.d.ts.map +1 -1
  14. package/lib/session/auth/domainRouting.js +15 -4
  15. package/lib/session/auth/domainRouting.js.map +1 -1
  16. package/lib/session/auth/gateway.d.ts +8 -0
  17. package/lib/session/auth/gateway.d.ts.map +1 -0
  18. package/lib/session/auth/gateway.js +47 -0
  19. package/lib/session/auth/gateway.js.map +1 -0
  20. package/lib/session/auth/oauth.d.ts +4 -0
  21. package/lib/session/auth/oauth.d.ts.map +1 -0
  22. package/lib/session/auth/oauth.js +167 -0
  23. package/lib/session/auth/oauth.js.map +1 -0
  24. package/lib/vertesia-ui-env.js +1 -1
  25. package/lib/vertesia-ui-env.js.map +1 -1
  26. package/lib/vertesia-ui-session.js +1 -1
  27. package/lib/vertesia-ui-session.js.map +1 -1
  28. package/package.json +7 -7
  29. package/src/env/index.test.ts +21 -0
  30. package/src/env/index.ts +18 -5
  31. package/src/session/UserSession.ts +58 -2
  32. package/src/session/UserSessionProvider.strict-mode.test.tsx +36 -0
  33. package/src/session/UserSessionProvider.tsx +24 -0
  34. package/src/session/auth/auth-flow.md +21 -9
  35. package/src/session/auth/domainRouting.test.ts +1 -1
  36. package/src/session/auth/domainRouting.ts +14 -4
  37. package/src/session/auth/gateway.test.ts +82 -0
  38. package/src/session/auth/gateway.ts +49 -0
  39. package/src/session/auth/oauth.test.ts +173 -0
  40. package/src/session/auth/oauth.ts +176 -0
package/src/env/index.ts CHANGED
@@ -18,8 +18,8 @@ export interface EnvProps {
18
18
  /**
19
19
  * Central Auth broker that issues the sign-in redirect and hosts `/logout`.
20
20
  *
21
- * Optional: when unset the session falls back to the long-standing broker, so an app that
22
- * does not set it keeps its current behaviour exactly. Set it to move one environment at a
21
+ * Optional: when unset the session falls back to the first-party broker, so an app that
22
+ * does not set it uses auth.vertesia.io. Set it to move one environment at a
23
23
  * time onto a different broker.
24
24
  */
25
25
  auth?: string;
@@ -28,6 +28,8 @@ export interface EnvProps {
28
28
  /** Appgen app-gateway endpoint (serves live development previews and app bundles). */
29
29
  gateway?: string;
30
30
  };
31
+ /** Public OAuth client for independently hosted apps; ignored inside Studio or a gateway session. */
32
+ oauth?: { clientId: string; redirectUri: string; scopes?: string[] };
31
33
  firebase?: {
32
34
  apiKey: string;
33
35
  authDomain: string;
@@ -52,9 +54,8 @@ export interface EnvProps {
52
54
  /**
53
55
  * Optional host-provided Vertesia auth token bootstrap.
54
56
  *
55
- * Published generated apps use this to ask their same-origin app gateway for
56
- * the token backing the gateway session cookie, allowing UserSession to
57
- * initialize without redirecting through Central Auth.
57
+ * Embedded apps use this to obtain a scoped token from their trusted host.
58
+ * Standalone generated apps use the gateway cookie session instead.
58
59
  */
59
60
  authTokenProvider?: () => Promise<string | undefined>;
60
61
  logger?: {
@@ -79,6 +80,10 @@ export type VertesiaRuntimeConfig =
79
80
  }
80
81
  | {
81
82
  authMode: 'central';
83
+ /** Broker selected by the serving gateway. Overrides build-time configuration. */
84
+ authUrl?: string;
85
+ /** The serving gateway owns this standalone app's OAuth session. */
86
+ gatewaySession?: boolean;
82
87
  };
83
88
 
84
89
  declare global {
@@ -141,6 +146,10 @@ export class VertesiaEnvironment implements Readonly<EnvProps> {
141
146
  const runtimeConfig = injectedRuntimeConfig() ?? buildRuntimeConfig(buildEnv);
142
147
  const runtimeFirebase = runtimeConfig?.authMode === 'firebase' ? runtimeConfig.firebase : undefined;
143
148
  this._props = props && runtimeFirebase && !props.firebase ? { ...props, firebase: runtimeFirebase } : props;
149
+ const authUrl = runtimeConfig?.authMode === 'central' ? runtimeConfig.authUrl?.trim() : undefined;
150
+ if (this._props && authUrl) {
151
+ this._props = { ...this._props, endpoints: { ...this._props.endpoints, auth: authUrl } };
152
+ }
144
153
  const tenantId =
145
154
  typeof buildEnv?.VITE_FIREBASE_TENANT_ID === 'string' ? buildEnv.VITE_FIREBASE_TENANT_ID.trim() : '';
146
155
  if (this._props?.firebase && tenantId && !this._props.firebase.tenantId) {
@@ -243,6 +252,10 @@ export class VertesiaEnvironment implements Readonly<EnvProps> {
243
252
  return this._props?.defaultAuthSelection;
244
253
  }
245
254
 
255
+ get oauth() {
256
+ return this._props?.oauth;
257
+ }
258
+
246
259
  get authTokenProvider() {
247
260
  return this._props?.authTokenProvider;
248
261
  }
@@ -7,6 +7,8 @@ import { createContext, useContext } from 'react';
7
7
  import { getComposableToken } from './auth/composable';
8
8
  import { authReturnUrl, centralAuthUrl, mountRootUrl, shouldRedirectToCentralAuth } from './auth/domainRouting';
9
9
  import { getFirebaseAuth } from './auth/firebase';
10
+ import { gatewayFetch, loadGatewaySession, logoutGatewaySession, usesGatewaySession } from './auth/gateway';
11
+ import { clearAppOAuth, getAppOAuthToken, usesAppOAuth } from './auth/oauth';
10
12
 
11
13
  import { LastSelectedAccountId_KEY, LastSelectedProjectId_KEY } from './constants';
12
14
 
@@ -31,9 +33,14 @@ class UserSession {
31
33
  this.client = client;
32
34
  } else {
33
35
  this.client = new VertesiaClient({
34
- serverUrl: Env.endpoints.studio,
35
- storeUrl: Env.endpoints.zeno,
36
+ serverUrl: usesGatewaySession()
37
+ ? `${window.location.origin}/__appgen/session/studio`
38
+ : Env.endpoints.studio,
39
+ storeUrl: usesGatewaySession()
40
+ ? `${window.location.origin}/__appgen/session/store`
41
+ : Env.endpoints.zeno,
36
42
  tokenServerUrl: Env.endpoints.sts,
43
+ fetch: usesGatewaySession() ? gatewayFetch : undefined,
37
44
  });
38
45
  }
39
46
 
@@ -73,6 +80,9 @@ class UserSession {
73
80
  }
74
81
 
75
82
  get rawAuthToken() {
83
+ if (usesAppOAuth()) return getAppOAuthToken();
84
+ if (usesGatewaySession())
85
+ return Promise.reject(new Error('Gateway session credentials are not available to application JavaScript'));
76
86
  return getComposableToken().then((res) => {
77
87
  const token = res?.rawToken;
78
88
  if (!token) {
@@ -89,6 +99,17 @@ class UserSession {
89
99
  * stale — STS recomputes the `apps` claim from ACEs on every issuance.
90
100
  */
91
101
  async refreshAuthToken(): Promise<AuthTokenPayload> {
102
+ if (usesGatewaySession()) {
103
+ const payload = await this.loginGatewaySession();
104
+ this.setSession?.(this.clone());
105
+ return payload;
106
+ }
107
+ if (usesAppOAuth()) {
108
+ clearAppOAuth();
109
+ this.authToken = jwtDecode<AuthTokenPayload>(await getAppOAuthToken());
110
+ this.setSession?.(this.clone());
111
+ return this.authToken;
112
+ }
92
113
  const res = await getComposableToken(undefined, undefined, undefined, true);
93
114
  const token = res?.rawToken;
94
115
  if (!token) {
@@ -133,12 +154,47 @@ class UserSession {
133
154
  return Promise.resolve();
134
155
  }
135
156
 
157
+ async loginGatewaySession() {
158
+ this.authToken = await loadGatewaySession();
159
+ this.client.withAuthCallback(undefined);
160
+ this.authError = undefined;
161
+ this.isLoading = false;
162
+ Env.onLogin?.(this.authToken);
163
+ return this.authToken;
164
+ }
165
+
136
166
  isLoggedIn() {
137
167
  return !!this.authToken;
138
168
  }
139
169
 
140
170
  logout() {
141
171
  console.log('Logging out');
172
+ if (usesGatewaySession()) {
173
+ void logoutGatewaySession()
174
+ .then(() => {
175
+ this.authToken = undefined;
176
+ this.authError = undefined;
177
+ this.isLoading = false;
178
+ Env.onLogout?.();
179
+ this.setSession?.(this.clone());
180
+ })
181
+ .catch((error: unknown) => {
182
+ this.authError = error instanceof Error ? error : new Error(String(error));
183
+ this.setSession?.(this.clone());
184
+ });
185
+ return;
186
+ }
187
+
188
+ if (usesAppOAuth()) {
189
+ clearAppOAuth();
190
+ this.client.withAuthCallback(undefined);
191
+ this.authToken = undefined;
192
+ this.authError = undefined;
193
+ this.isLoading = false;
194
+ Env.onLogout?.();
195
+ this.setSession?.(this.clone());
196
+ return;
197
+ }
142
198
 
143
199
  if (shouldRedirectToCentralAuth()) {
144
200
  // Redirect to central auth for logout
@@ -3,6 +3,7 @@ import { Env } from '@vertesia/ui/env';
3
3
  import { StrictMode } from 'react';
4
4
  import { afterEach, expect, it, vi } from 'vitest';
5
5
  import { redirectToCentralAuth } from './auth/domainRouting';
6
+ import { useUserSession } from './UserSession';
6
7
  import { UserSessionProvider } from './UserSessionProvider';
7
8
 
8
9
  vi.mock('./auth/useAuthState', () => ({ useAuthState: () => ({}) }));
@@ -15,6 +16,8 @@ vi.mock('./auth/domainRouting', async (importOriginal) => ({
15
16
  afterEach(() => {
16
17
  cleanup();
17
18
  vi.clearAllMocks();
19
+ vi.unstubAllGlobals();
20
+ delete window.__VERTESIA_RUNTIME_CONFIG__;
18
21
  });
19
22
 
20
23
  it('continues authentication after StrictMode cancels the first host-token attempt', async () => {
@@ -42,3 +45,36 @@ it('continues authentication after StrictMode cancels the first host-token attem
42
45
  await waitFor(() => expect(redirectToCentralAuth).toHaveBeenCalledTimes(1));
43
46
  expect(authTokenProvider).toHaveBeenCalledTimes(2);
44
47
  });
48
+
49
+ it('bootstraps a gateway session from claims without starting broker authentication', async () => {
50
+ window.__VERTESIA_RUNTIME_CONFIG__ = { authMode: 'central', gatewaySession: true };
51
+ const claims = { sub: 'gateway-user', name: 'Gateway User', account: { id: 'a' }, project: { id: 'p' } };
52
+ const fetch = vi.fn(async (_request: Request) => Response.json(claims));
53
+ vi.stubGlobal('fetch', fetch);
54
+ Env.init({
55
+ name: 'gateway',
56
+ version: '1',
57
+ type: 'production',
58
+ isLocalDev: false,
59
+ isDocker: false,
60
+ endpoints: {
61
+ studio: 'https://api.example.com',
62
+ zeno: 'https://api.example.com',
63
+ sts: 'https://sts.example.com',
64
+ },
65
+ });
66
+ function Probe() {
67
+ const session = useUserSession();
68
+ return <span>{session.user?.name}</span>;
69
+ }
70
+ const view = render(
71
+ <StrictMode>
72
+ <UserSessionProvider>
73
+ <Probe />
74
+ </UserSessionProvider>
75
+ </StrictMode>,
76
+ );
77
+ await view.findByText('Gateway User');
78
+ expect(redirectToCentralAuth).not.toHaveBeenCalled();
79
+ expect(fetch.mock.calls.every(([request]) => new URL(request.url).pathname === '/__appgen/session')).toBe(true);
80
+ });
@@ -14,6 +14,8 @@ import {
14
14
  } from './auth/composable';
15
15
  import { buildCentralAuthRedirectUrl, redirectToCentralAuth, shouldRedirectToCentralAuth } from './auth/domainRouting';
16
16
  import { getFirebaseAuth } from './auth/firebase';
17
+ import { usesGatewaySession } from './auth/gateway';
18
+ import { getAppOAuthToken, usesAppOAuth } from './auth/oauth';
17
19
  import { useAuthState } from './auth/useAuthState';
18
20
  import { UserSession, UserSessionContext } from './UserSession';
19
21
 
@@ -130,6 +132,28 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
130
132
  },
131
133
  });
132
134
 
135
+ if (usesGatewaySession() || usesAppOAuth()) {
136
+ let cancelled = false;
137
+ session.setSession = setSession;
138
+ const initialize = usesGatewaySession()
139
+ ? session.loginGatewaySession()
140
+ : getAppOAuthToken().then((token) => session.login(token, { loadOnboardingStatus: false }));
141
+ void initialize
142
+ .then(() => {
143
+ if (!cancelled) setSession(session.clone());
144
+ })
145
+ .catch((error: unknown) => {
146
+ if (cancelled) return;
147
+ session.isLoading = false;
148
+ session.authError = error instanceof Error ? error : new Error(String(error));
149
+ setSession(session.clone());
150
+ });
151
+ return () => {
152
+ cancelled = true;
153
+ hasInitiatedAuthRef.current = false;
154
+ };
155
+ }
156
+
133
157
  if (Env.isLocalDev && Env.devAuthToken) {
134
158
  session.setSession = setSession;
135
159
  getComposableToken(selectedAccount, selectedProject, Env.devAuthToken)
@@ -6,10 +6,22 @@ This document describes the authentication flow for the Vertesia user-facing app
6
6
 
7
7
  The authentication system uses:
8
8
  - **Firebase** for identity management and OAuth provider integration
9
- - **Central Auth** (internal-auth.vertesia.app) for centralized authentication flow
9
+ - **Central Auth** (auth.vertesia.io) for centralized authentication flow
10
10
  - **STS (Security Token Service)** (sts.vertesia.io) to validate Firebase tokens and generate Vertesia JWT tokens
11
11
  - **Session state management** to validate redirects and prevent CSRF attacks
12
12
 
13
+ ### Central Auth endpoint selection
14
+
15
+ `https://auth.vertesia.io/` is the default broker. Regional scaffolding can set
16
+ `VITE_AUTH_ENDPOINT`; applications can also configure the `Env` initialization property `endpoints.auth` explicitly.
17
+ For compatible generated app shells, a gateway-provided central runtime config
18
+ (`window.__VERTESIA_RUNTIME_CONFIG__` with `authMode: 'central'` and `authUrl`)
19
+ takes precedence over the build-time endpoint. A runtime config without `authUrl`
20
+ retains the configured endpoint or default. Explicit Firebase mode uses direct
21
+ Firebase authentication instead of the central broker.
22
+
23
+ The diagrams below use the default hostname; redirects use the selected broker.
24
+
13
25
  ### STS Endpoints
14
26
  - Production/Preview: `https://sts.vertesia.io`
15
27
  - Staging: `https://sts.dev1.vertesia.io`
@@ -179,7 +191,7 @@ The authentication system uses:
179
191
  ┌──────────────────────┐ │ - Microsoft │ ┌──────────────────────┐
180
192
  │ Build redirect URL: │ └──────────┬───────────┘ │ Redirect to SSO │
181
193
  │ │ │ │ provider (Okta, │
182
- │ internal-auth │ ▼ │ Azure AD, etc.) │
194
+ │ auth.vertesia.io │ ▼ │ Azure AD, etc.) │
183
195
  │ .vertesia.app │ ┌──────────────────────┐ └──────────┬───────────┘
184
196
  │ ?sts=... │ │ Firebase OAuth │ │
185
197
  │ &redirect_uri=... │ │ redirect & return │ ▼
@@ -329,7 +341,7 @@ The authentication system uses:
329
341
 
330
342
  ### Path A: Central Auth (Option 1) - Returns with JWT
331
343
  1. User clicks "Continue with Central Auth"
332
- 2. Redirects to internal-auth.vertesia.app
344
+ 2. Redirects to auth.vertesia.io
333
345
  3. Central auth authenticates user and gets Firebase token
334
346
  4. **Central auth calls STS** (`POST sts.vertesia.io/token/issue` with Firebase token)
335
347
  5. **STS validates Firebase token and generates Vertesia JWT**
@@ -472,7 +484,7 @@ The authentication system uses:
472
484
  │ Redirect to Central │ │ getFirebaseAuth() │
473
485
  │ Auth for logout: │ │ .signOut() │
474
486
  │ │ │ │
475
- │ internal-auth │ │ (Triggers │
487
+ │ auth.vertesia.io │ │ (Triggers │
476
488
  │ .vertesia.app │ │ onAuthStateChanged │
477
489
  │ /logout │ │ with anonymous user)│
478
490
  │ │ └──────────┬───────────┘
@@ -712,7 +724,7 @@ flowchart TB
712
724
  subgraph Option1[OPTION 1: CENTRAL AUTH]
713
725
  C1[Generate state<br/>Save to sessionStorage]
714
726
  C2[Get account/project<br/>from URL or localStorage]
715
- C3[Build redirect URL:<br/>internal-auth.vertesia.app<br/>?sts=...&redirect_uri=...&state=...]
727
+ C3[Build redirect URL:<br/>auth.vertesia.io<br/>?sts=...&redirect_uri=...&state=...]
716
728
  C4[window.location.replace<br/>to central auth]
717
729
  C5[Central Auth Page<br/>User authenticates<br/>Gets Firebase token]
718
730
  C6[Central auth calls STS:<br/>POST sts.vertesia.io/token/issue<br/>Authorization: Bearer Firebase<br/>Body: type, account_id, project_id]
@@ -774,7 +786,7 @@ flowchart TB
774
786
 
775
787
  Check -->|YES| ShouldRedirect{shouldRedirectTo<br/>CentralAuth?}
776
788
 
777
- ShouldRedirect -->|YES| RedirectLogout[Redirect to Central Auth<br/>for logout<br/><br/>internal-auth.vertesia.app<br/>/logout<br/><br/>Central auth handles<br/>Firebase logout]
789
+ ShouldRedirect -->|YES| RedirectLogout[Redirect to Central Auth<br/>for logout<br/><br/>auth.vertesia.io<br/>/logout<br/><br/>Central auth handles<br/>Firebase logout]
778
790
 
779
791
  ShouldRedirect -->|NO| FirebaseSignout[getFirebaseAuth.signOut<br/><br/>Triggers onAuthStateChanged<br/>with anonymous user]
780
792
 
@@ -837,7 +849,7 @@ flowchart TB
837
849
  sequenceDiagram
838
850
  actor User
839
851
  participant App as Composable UI
840
- participant CentralAuth as Central Auth<br/>(internal-auth.vertesia.app)
852
+ participant CentralAuth as Central Auth<br/>(auth.vertesia.io)
841
853
  participant Firebase as Firebase Auth
842
854
  participant STS as STS<br/>(sts.vertesia.io)
843
855
 
@@ -1030,7 +1042,7 @@ flowchart TB
1030
1042
 
1031
1043
  Check -->|YES| ShouldRedirect{shouldRedirectTo<br/>CentralAuth?}
1032
1044
 
1033
- ShouldRedirect -->|YES| RedirectLogout[Redirect to Central Auth<br/>for logout<br/><br/>internal-auth.vertesia.app<br/>/logout<br/><br/>Central auth handles<br/>Firebase logout]
1045
+ ShouldRedirect -->|YES| RedirectLogout[Redirect to Central Auth<br/>for logout<br/><br/>auth.vertesia.io<br/>/logout<br/><br/>Central auth handles<br/>Firebase logout]
1034
1046
 
1035
1047
  ShouldRedirect -->|NO| FirebaseSignout[getFirebaseAuth.signOut<br/><br/>Triggers onAuthStateChanged<br/>with anonymous user]
1036
1048
 
@@ -1078,7 +1090,7 @@ flowchart TB
1078
1090
  LO2[session.logout or<br/>session.signOut]
1079
1091
  LO3{authToken<br/>exists?}
1080
1092
  LO4{shouldRedirectTo<br/>CentralAuth?}
1081
- LO5[Redirect to Central Auth<br/>for logout<br/><br/>internal-auth.vertesia.app<br/>/logout<br/><br/>Central auth handles<br/>Firebase logout]
1093
+ LO5[Redirect to Central Auth<br/>for logout<br/><br/>auth.vertesia.io<br/>/logout<br/><br/>Central auth handles<br/>Firebase logout]
1082
1094
  LO6[getFirebaseAuth.signOut<br/><br/>Triggers onAuthStateChanged<br/>with anonymous user]
1083
1095
  LO7[Clear session data:<br/>- authError = undefined<br/>- isLoading = false<br/>- authToken = undefined<br/>- typeRegistry = undefined]
1084
1096
  LO8[client.withAuthCallback<br/>undefined<br/><br/>Clear client auth]
@@ -77,7 +77,7 @@ describe('centralAuthUrl', () => {
77
77
  it('falls back to the default broker when no auth endpoint is configured', () => {
78
78
  initEnv(undefined);
79
79
  expect(centralAuthUrl()).toBe(DEFAULT_CENTRAL_AUTH_URL);
80
- expect(DEFAULT_CENTRAL_AUTH_URL).toBe('https://internal-auth.vertesia.app/');
80
+ expect(DEFAULT_CENTRAL_AUTH_URL).toBe('https://auth.vertesia.io/');
81
81
  });
82
82
 
83
83
  it('uses the configured auth endpoint when one is set', () => {
@@ -1,5 +1,7 @@
1
1
  import { Env } from '@vertesia/ui/env';
2
2
  import { generateAuthState } from './authState';
3
+ import { gatewayLoginUrl, usesGatewaySession } from './gateway';
4
+ import { getAppOAuthToken, usesAppOAuth } from './oauth';
3
5
 
4
6
  declare global {
5
7
  interface Window {
@@ -10,11 +12,10 @@ declare global {
10
12
  /**
11
13
  * The broker this app sends users to for sign-in and logout.
12
14
  *
13
- * Hard-coded until now, which meant every consumer of this package reached the same deployment no
14
- * matter what was running. It is read from the environment so a single app can be pointed at a
15
- * different broker, and it keeps this default so an app that configures nothing is unaffected.
15
+ * A gateway or app can select a regional broker through Env.endpoints.auth.
16
+ * Unconfigured apps use the first-party central broker.
16
17
  */
17
- export const DEFAULT_CENTRAL_AUTH_URL = 'https://internal-auth.vertesia.app/';
18
+ export const DEFAULT_CENTRAL_AUTH_URL = 'https://auth.vertesia.io/';
18
19
 
19
20
  export function centralAuthUrl(): string {
20
21
  const configured = Env.endpoints.auth;
@@ -146,6 +147,14 @@ export function clearCentralAuthRoundTripMarker(): void {
146
147
  * into producing different `redirect_uri` / `state` / `sts` values for the same page load.
147
148
  */
148
149
  export function redirectToCentralAuth(selection: AuthSelection = {}): void {
150
+ if (usesAppOAuth()) {
151
+ void getAppOAuthToken().catch((error: unknown) => Env.logger.error('Application OAuth sign-in failed', error));
152
+ return;
153
+ }
154
+ if (usesGatewaySession()) {
155
+ window.location.replace(gatewayLoginUrl());
156
+ return;
157
+ }
149
158
  const url = buildCentralAuthRedirectUrl(
150
159
  centralAuthUrl(),
151
160
  Env.endpoints.sts ?? 'https://sts.vertesia.io',
@@ -178,6 +187,7 @@ export function redirectToCentralAuth(selection: AuthSelection = {}): void {
178
187
  * chunks that the navigation is about to discard.
179
188
  */
180
189
  export function isCentralAuthRedirectPending(): boolean {
190
+ if (usesGatewaySession() || usesAppOAuth()) return false;
181
191
  if (!shouldRedirectToCentralAuth()) return false;
182
192
  if (Env.authTokenProvider) return false;
183
193
  if (Env.isLocalDev && Env.devAuthToken) return false;
@@ -0,0 +1,82 @@
1
+ // @vitest-environment node
2
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
3
+ import { gatewayFetch, gatewayLoginUrl, loadGatewaySession, logoutGatewaySession, usesGatewaySession } from './gateway';
4
+
5
+ const replace = vi.fn();
6
+ beforeEach(() => {
7
+ const browser = {
8
+ location: {
9
+ href: 'https://app.example/reports?a=account&p=project#chart',
10
+ origin: 'https://app.example',
11
+ replace,
12
+ },
13
+ __VERTESIA_RUNTIME_CONFIG__: { authMode: 'central', gatewaySession: true },
14
+ };
15
+ vi.stubGlobal('window', Object.assign(browser, { parent: browser }));
16
+ });
17
+ afterEach(() => {
18
+ vi.unstubAllGlobals();
19
+ vi.clearAllMocks();
20
+ });
21
+
22
+ it('activates only for a standalone app explicitly served with a gateway session', () => {
23
+ expect(usesGatewaySession()).toBe(true);
24
+ window.__VERTESIA_RUNTIME_CONFIG__ = { authMode: 'central' };
25
+ expect(usesGatewaySession()).toBe(false);
26
+ window.__VERTESIA_RUNTIME_CONFIG__ = { authMode: 'central', gatewaySession: true };
27
+ Object.defineProperty(window, 'parent', { value: {} });
28
+ expect(usesGatewaySession()).toBe(false);
29
+ });
30
+ it('preserves account, project and deep link through the existing OAuth login route', () => {
31
+ const url = new URL(gatewayLoginUrl());
32
+ expect(url.origin + url.pathname).toBe('https://app.example/__appgen/auth/login');
33
+ expect(url.searchParams.get('target')).toBe('/reports?a=account&p=project#chart');
34
+ });
35
+ it('uses same-origin cookies and never forwards browser bearer credentials', async () => {
36
+ const fetch = vi.fn(async (_request: Request) => new Response('{}'));
37
+ vi.stubGlobal('fetch', fetch);
38
+ await gatewayFetch('https://app.example/__appgen/session/studio/api/v1/apps', {
39
+ headers: { Authorization: 'Bearer browser-token' },
40
+ });
41
+ const request = fetch.mock.calls[0]?.[0] as Request | undefined;
42
+ expect(request?.headers.get('authorization')).toBeNull();
43
+ expect(request?.headers.get('x-vertesia-app-session')).toBe('1');
44
+ expect(request?.credentials).toBe('same-origin');
45
+ await expect(gatewayFetch('https://other.example/api')).rejects.toThrow('application origin');
46
+ expect(fetch).toHaveBeenCalledTimes(1);
47
+ });
48
+ it('redirects an expired session into CIMD login without invoking the direct broker', async () => {
49
+ vi.stubGlobal(
50
+ 'fetch',
51
+ vi.fn(
52
+ async () =>
53
+ new Response('{}', {
54
+ status: 401,
55
+ headers: { 'x-vertesia-session-required': '1' },
56
+ }),
57
+ ),
58
+ );
59
+ void loadGatewaySession();
60
+ await vi.waitFor(() => expect(replace).toHaveBeenCalledWith(gatewayLoginUrl()));
61
+ });
62
+ it('surfaces outages and ordinary upstream 401s instead of creating login loops', async () => {
63
+ vi.stubGlobal(
64
+ 'fetch',
65
+ vi.fn(async () => new Response('{}', { status: 503 })),
66
+ );
67
+ await expect(loadGatewaySession()).rejects.toThrow('503');
68
+ vi.stubGlobal(
69
+ 'fetch',
70
+ vi.fn(async () => new Response('{}', { status: 401 })),
71
+ );
72
+ expect((await gatewayFetch('https://app.example/api')).status).toBe(401);
73
+ expect(replace).not.toHaveBeenCalled();
74
+ });
75
+ it('loads claims without a JWT and signs out through a protected POST', async () => {
76
+ const claims = { sub: 'user', account: { id: 'account' }, project: { id: 'project' } };
77
+ const fetch = vi.fn(async (_request: Request) => new Response(JSON.stringify(claims)));
78
+ vi.stubGlobal('fetch', fetch);
79
+ expect(await loadGatewaySession()).toEqual(claims);
80
+ await logoutGatewaySession();
81
+ expect(fetch.mock.calls.at(-1)?.[0].method).toBe('POST');
82
+ });
@@ -0,0 +1,49 @@
1
+ import type { AuthTokenPayload } from '@vertesia/common';
2
+
3
+ export function usesGatewaySession(): boolean {
4
+ if (typeof window === 'undefined' || window.parent !== window) return false;
5
+ const config = window.__VERTESIA_RUNTIME_CONFIG__;
6
+ return config?.authMode === 'central' && config.gatewaySession === true;
7
+ }
8
+
9
+ export function gatewayLoginUrl(): string {
10
+ const current = new URL(window.location.href);
11
+ const url = new URL('/__appgen/auth/login', current.origin);
12
+ url.searchParams.set('target', `${current.pathname}${current.search}${current.hash}`);
13
+ return url.toString();
14
+ }
15
+
16
+ /** Same-origin cookie transport. The gateway attaches its OAuth credential upstream. */
17
+ export async function gatewayFetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
18
+ const request = new Request(input, init);
19
+ if (new URL(request.url).origin !== window.location.origin) {
20
+ throw new Error('Gateway session requests must stay on the application origin');
21
+ }
22
+ const headers = new Headers(request.headers);
23
+ headers.delete('authorization');
24
+ headers.set('x-vertesia-app-session', '1');
25
+ const response = await fetch(new Request(request, { headers, credentials: 'same-origin', cache: 'no-store' }));
26
+ if (response.status === 401 && response.headers.get('x-vertesia-session-required') === '1') {
27
+ window.location.replace(gatewayLoginUrl());
28
+ // Navigation owns recovery. Do not fall back to Firebase or the direct broker flow.
29
+ return new Promise(() => {});
30
+ }
31
+ return response;
32
+ }
33
+
34
+ export async function loadGatewaySession(): Promise<AuthTokenPayload> {
35
+ const response = await gatewayFetch(new URL('/__appgen/session', window.location.origin).toString());
36
+ if (!response.ok) throw new Error(`Application session could not be loaded (${response.status})`);
37
+ const payload: AuthTokenPayload = await response.json();
38
+ if (!payload.sub || !payload.account?.id || !payload.project?.id) {
39
+ throw new Error('Application session returned invalid user information');
40
+ }
41
+ return payload;
42
+ }
43
+
44
+ export async function logoutGatewaySession(): Promise<void> {
45
+ const response = await gatewayFetch(new URL('/__appgen/session/logout', window.location.origin).toString(), {
46
+ method: 'POST',
47
+ });
48
+ if (!response.ok) throw new Error(`Application sign-out failed (${response.status})`);
49
+ }