@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.
- package/lib/env/index.d.ts +21 -7
- package/lib/env/index.d.ts.map +1 -1
- package/lib/env/index.js +7 -0
- package/lib/env/index.js.map +1 -1
- package/lib/session/UserSession.d.ts +1 -0
- package/lib/session/UserSession.d.ts.map +1 -1
- package/lib/session/UserSession.js +57 -2
- package/lib/session/UserSession.js.map +1 -1
- package/lib/session/UserSessionProvider.d.ts.map +1 -1
- package/lib/session/UserSessionProvider.js +25 -0
- package/lib/session/UserSessionProvider.js.map +1 -1
- package/lib/session/auth/domainRouting.d.ts +3 -4
- package/lib/session/auth/domainRouting.d.ts.map +1 -1
- package/lib/session/auth/domainRouting.js +15 -4
- package/lib/session/auth/domainRouting.js.map +1 -1
- package/lib/session/auth/gateway.d.ts +8 -0
- package/lib/session/auth/gateway.d.ts.map +1 -0
- package/lib/session/auth/gateway.js +47 -0
- package/lib/session/auth/gateway.js.map +1 -0
- package/lib/session/auth/oauth.d.ts +4 -0
- package/lib/session/auth/oauth.d.ts.map +1 -0
- package/lib/session/auth/oauth.js +167 -0
- package/lib/session/auth/oauth.js.map +1 -0
- package/lib/vertesia-ui-env.js +1 -1
- package/lib/vertesia-ui-env.js.map +1 -1
- package/lib/vertesia-ui-session.js +1 -1
- package/lib/vertesia-ui-session.js.map +1 -1
- package/package.json +7 -7
- package/src/env/index.test.ts +21 -0
- package/src/env/index.ts +18 -5
- package/src/session/UserSession.ts +58 -2
- package/src/session/UserSessionProvider.strict-mode.test.tsx +36 -0
- package/src/session/UserSessionProvider.tsx +24 -0
- package/src/session/auth/auth-flow.md +21 -9
- package/src/session/auth/domainRouting.test.ts +1 -1
- package/src/session/auth/domainRouting.ts +14 -4
- package/src/session/auth/gateway.test.ts +82 -0
- package/src/session/auth/gateway.ts +49 -0
- package/src/session/auth/oauth.test.ts +173 -0
- 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
|
|
22
|
-
* does not set it
|
|
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
|
-
*
|
|
56
|
-
*
|
|
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:
|
|
35
|
-
|
|
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** (
|
|
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
|
-
│
|
|
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
|
|
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
|
-
│
|
|
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/>
|
|
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/>
|
|
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/>(
|
|
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/>
|
|
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/>
|
|
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://
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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://
|
|
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
|
+
}
|