@vertesia/ui 1.6.0-dev.20260918.063824Z → 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 +15 -3
- package/lib/env/index.d.ts.map +1 -1
- package/lib/env/index.js +3 -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.map +1 -1
- package/lib/session/auth/domainRouting.js +12 -0
- 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.ts +10 -3
- 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/domainRouting.ts +11 -0
- 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
|
@@ -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)
|
|
@@ -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 {
|
|
@@ -145,6 +147,14 @@ export function clearCentralAuthRoundTripMarker(): void {
|
|
|
145
147
|
* into producing different `redirect_uri` / `state` / `sts` values for the same page load.
|
|
146
148
|
*/
|
|
147
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
|
+
}
|
|
148
158
|
const url = buildCentralAuthRedirectUrl(
|
|
149
159
|
centralAuthUrl(),
|
|
150
160
|
Env.endpoints.sts ?? 'https://sts.vertesia.io',
|
|
@@ -177,6 +187,7 @@ export function redirectToCentralAuth(selection: AuthSelection = {}): void {
|
|
|
177
187
|
* chunks that the navigation is about to discard.
|
|
178
188
|
*/
|
|
179
189
|
export function isCentralAuthRedirectPending(): boolean {
|
|
190
|
+
if (usesGatewaySession() || usesAppOAuth()) return false;
|
|
180
191
|
if (!shouldRedirectToCentralAuth()) return false;
|
|
181
192
|
if (Env.authTokenProvider) return false;
|
|
182
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
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const origin = 'https://standalone.vercel.app';
|
|
5
|
+
const issuer = 'https://sts.dev1.vertesia.io';
|
|
6
|
+
const clientId = `${origin}/.well-known/oauth-client/vertesia-app`;
|
|
7
|
+
const storage = new Map<string, string>();
|
|
8
|
+
const replace = vi.fn();
|
|
9
|
+
let browser: {
|
|
10
|
+
location: { href: string; origin: string; replace: typeof replace };
|
|
11
|
+
parent?: unknown;
|
|
12
|
+
history: { state: null; replaceState: ReturnType<typeof vi.fn> };
|
|
13
|
+
};
|
|
14
|
+
let requests: { url: string; init?: RequestInit }[];
|
|
15
|
+
function jwt(): string {
|
|
16
|
+
return `e30.${btoa(
|
|
17
|
+
JSON.stringify({
|
|
18
|
+
iss: issuer,
|
|
19
|
+
client_id: clientId,
|
|
20
|
+
exp: Date.now() / 1000 + 3600,
|
|
21
|
+
sub: 'user',
|
|
22
|
+
account: { id: 'a' },
|
|
23
|
+
project: { id: 'p' },
|
|
24
|
+
}),
|
|
25
|
+
)}.signature`;
|
|
26
|
+
}
|
|
27
|
+
async function setup() {
|
|
28
|
+
vi.resetModules();
|
|
29
|
+
const { Env } = await import('../../env');
|
|
30
|
+
Env.init({
|
|
31
|
+
name: 'test',
|
|
32
|
+
version: '1',
|
|
33
|
+
type: 'production',
|
|
34
|
+
isLocalDev: false,
|
|
35
|
+
isDocker: false,
|
|
36
|
+
endpoints: { studio: 'https://api.dev1.vertesia.io', zeno: 'https://api.dev1.vertesia.io', sts: issuer },
|
|
37
|
+
oauth: { clientId, redirectUri: `${origin}/app` },
|
|
38
|
+
});
|
|
39
|
+
return import('./oauth');
|
|
40
|
+
}
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
storage.clear();
|
|
43
|
+
requests = [];
|
|
44
|
+
browser = {
|
|
45
|
+
location: { href: `${origin}/app/report?a=a&p=p#chart`, origin, replace },
|
|
46
|
+
history: {
|
|
47
|
+
state: null,
|
|
48
|
+
replaceState: vi.fn((_state, _title, target: URL) => {
|
|
49
|
+
browser.location.href = target.toString();
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
browser.parent = browser;
|
|
54
|
+
vi.stubGlobal('window', browser);
|
|
55
|
+
vi.stubGlobal('sessionStorage', {
|
|
56
|
+
getItem: (key: string) => storage.get(key) ?? null,
|
|
57
|
+
setItem: (key: string, value: string) => storage.set(key, value),
|
|
58
|
+
removeItem: (key: string) => storage.delete(key),
|
|
59
|
+
});
|
|
60
|
+
vi.stubGlobal(
|
|
61
|
+
'fetch',
|
|
62
|
+
vi.fn(async (input: string | URL, init?: RequestInit) => {
|
|
63
|
+
const url = String(input);
|
|
64
|
+
requests.push({ url, init });
|
|
65
|
+
if (url.endsWith('/.well-known/oauth-authorization-server'))
|
|
66
|
+
return Response.json({
|
|
67
|
+
issuer,
|
|
68
|
+
authorization_endpoint: 'https://preview.cloud.dev1.vertesia.io/oauth/authorize',
|
|
69
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
70
|
+
});
|
|
71
|
+
if (url === clientId)
|
|
72
|
+
return Response.json({
|
|
73
|
+
redirect_uris: [`${origin}/app`],
|
|
74
|
+
scope: 'openid profile content:read offline_access',
|
|
75
|
+
});
|
|
76
|
+
if (url === `${issuer}/oauth/token`) return Response.json({ access_token: jwt(), token_type: 'Bearer' });
|
|
77
|
+
throw new Error(`Unexpected request: ${url}`);
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.unstubAllGlobals();
|
|
83
|
+
vi.clearAllMocks();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('uses CIMD and PKCE, exchanges the callback once and restores the app deep link', async () => {
|
|
87
|
+
const oauth = await setup();
|
|
88
|
+
expect(oauth.usesAppOAuth()).toBe(true);
|
|
89
|
+
void oauth.getAppOAuthToken();
|
|
90
|
+
await vi.waitFor(() => expect(replace).toHaveBeenCalledTimes(1));
|
|
91
|
+
const authorize = new URL(replace.mock.calls[0][0]);
|
|
92
|
+
expect(authorize.searchParams.get('client_id')).toBe(clientId);
|
|
93
|
+
expect(authorize.searchParams.get('redirect_uri')).toBe(`${origin}/app`);
|
|
94
|
+
expect(authorize.searchParams.get('scope')).toBe('openid profile content:read');
|
|
95
|
+
expect(authorize.searchParams.get('project_id')).toBe('p');
|
|
96
|
+
expect(authorize.searchParams.get('code_challenge_method')).toBe('S256');
|
|
97
|
+
const txn = JSON.parse(storage.get('vertesia.oauth.transaction') ?? '{}');
|
|
98
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(txn.verifier));
|
|
99
|
+
expect(authorize.searchParams.get('code_challenge')).toBe(Buffer.from(digest).toString('base64url'));
|
|
100
|
+
browser.location.href = `${origin}/app?code=one-time-code&state=${authorize.searchParams.get('state')}`;
|
|
101
|
+
const callback = await setup();
|
|
102
|
+
const token = await callback.getAppOAuthToken();
|
|
103
|
+
expect(token).toBeTruthy();
|
|
104
|
+
const exchange = requests.find((request) => request.url.endsWith('/oauth/token'));
|
|
105
|
+
if (!exchange) throw new Error('Expected a code exchange');
|
|
106
|
+
const body = exchange.init?.body as URLSearchParams;
|
|
107
|
+
expect(body.get('code_verifier')).toBe(txn.verifier);
|
|
108
|
+
expect(body.get('grant_type')).toBe('authorization_code');
|
|
109
|
+
expect(body.has('client_secret')).toBe(false);
|
|
110
|
+
expect(storage.has('vertesia.oauth.transaction')).toBe(false);
|
|
111
|
+
expect(browser.location.href).toBe(`${origin}/app/report?a=a&p=p#chart`);
|
|
112
|
+
expect(await callback.getAppOAuthToken()).toBe(token);
|
|
113
|
+
expect(requests.filter((request) => request.url.endsWith('/oauth/token'))).toHaveLength(1);
|
|
114
|
+
});
|
|
115
|
+
it('rejects a forged callback before contacting the token endpoint', async () => {
|
|
116
|
+
browser.location.href = `${origin}/app?code=forged&state=wrong`;
|
|
117
|
+
const oauth = await setup();
|
|
118
|
+
await expect(oauth.getAppOAuthToken()).rejects.toThrow('Invalid or expired');
|
|
119
|
+
expect(requests).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
it('rejects discovery for a different issuer', async () => {
|
|
122
|
+
vi.stubGlobal(
|
|
123
|
+
'fetch',
|
|
124
|
+
vi.fn(async () => Response.json({ issuer: 'https://wrong.test' })),
|
|
125
|
+
);
|
|
126
|
+
const oauth = await setup();
|
|
127
|
+
await expect(oauth.getAppOAuthToken()).rejects.toThrow('issuer mismatch');
|
|
128
|
+
expect(replace).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
it('does not override Firebase, embedded apps, gateway sessions, or unconfigured local apps', async () => {
|
|
131
|
+
const oauth = await setup();
|
|
132
|
+
const win = window as unknown as Record<string, unknown>;
|
|
133
|
+
win.AUTH_MODE = 'firebase';
|
|
134
|
+
expect(oauth.usesAppOAuth()).toBe(false);
|
|
135
|
+
win.AUTH_MODE = 'central';
|
|
136
|
+
win.parent = {};
|
|
137
|
+
expect(oauth.usesAppOAuth()).toBe(false);
|
|
138
|
+
win.parent = window;
|
|
139
|
+
win.__VERTESIA_RUNTIME_CONFIG__ = { authMode: 'central', gatewaySession: true };
|
|
140
|
+
expect(oauth.usesAppOAuth()).toBe(false);
|
|
141
|
+
win.__VERTESIA_RUNTIME_CONFIG__ = undefined;
|
|
142
|
+
const { Env } = await import('../../env');
|
|
143
|
+
Env.init({
|
|
144
|
+
name: 'local',
|
|
145
|
+
version: '1',
|
|
146
|
+
type: 'development',
|
|
147
|
+
isLocalDev: true,
|
|
148
|
+
isDocker: false,
|
|
149
|
+
endpoints: { studio: issuer, zeno: issuer, sts: issuer },
|
|
150
|
+
});
|
|
151
|
+
expect(oauth.usesAppOAuth()).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('supports a registered client ID without fetching a CIMD', async () => {
|
|
155
|
+
const oauth = await setup();
|
|
156
|
+
const { Env } = await import('../../env');
|
|
157
|
+
Env.init({
|
|
158
|
+
name: 'registered',
|
|
159
|
+
version: '1',
|
|
160
|
+
type: 'production',
|
|
161
|
+
isLocalDev: false,
|
|
162
|
+
isDocker: false,
|
|
163
|
+
endpoints: { studio: issuer, zeno: issuer, sts: issuer },
|
|
164
|
+
oauth: { clientId: 'registered-client-id', redirectUri: `${origin}/app`, scopes: ['openid', 'content:read'] },
|
|
165
|
+
});
|
|
166
|
+
void oauth.getAppOAuthToken();
|
|
167
|
+
await vi.waitFor(() => expect(replace).toHaveBeenCalledTimes(1));
|
|
168
|
+
const authorize = new URL(replace.mock.calls[0][0]);
|
|
169
|
+
expect(authorize.searchParams.get('client_id')).toBe('registered-client-id');
|
|
170
|
+
expect(authorize.searchParams.get('scope')).toBe('openid content:read');
|
|
171
|
+
expect(authorize.searchParams.has('consent_required')).toBe(false);
|
|
172
|
+
expect(requests).toHaveLength(1);
|
|
173
|
+
});
|