@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.
Files changed (36) hide show
  1. package/lib/env/index.d.ts +15 -3
  2. package/lib/env/index.d.ts.map +1 -1
  3. package/lib/env/index.js +3 -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.map +1 -1
  13. package/lib/session/auth/domainRouting.js +12 -0
  14. package/lib/session/auth/domainRouting.js.map +1 -1
  15. package/lib/session/auth/gateway.d.ts +8 -0
  16. package/lib/session/auth/gateway.d.ts.map +1 -0
  17. package/lib/session/auth/gateway.js +47 -0
  18. package/lib/session/auth/gateway.js.map +1 -0
  19. package/lib/session/auth/oauth.d.ts +4 -0
  20. package/lib/session/auth/oauth.d.ts.map +1 -0
  21. package/lib/session/auth/oauth.js +167 -0
  22. package/lib/session/auth/oauth.js.map +1 -0
  23. package/lib/vertesia-ui-env.js +1 -1
  24. package/lib/vertesia-ui-env.js.map +1 -1
  25. package/lib/vertesia-ui-session.js +1 -1
  26. package/lib/vertesia-ui-session.js.map +1 -1
  27. package/package.json +7 -7
  28. package/src/env/index.ts +10 -3
  29. package/src/session/UserSession.ts +58 -2
  30. package/src/session/UserSessionProvider.strict-mode.test.tsx +36 -0
  31. package/src/session/UserSessionProvider.tsx +24 -0
  32. package/src/session/auth/domainRouting.ts +11 -0
  33. package/src/session/auth/gateway.test.ts +82 -0
  34. package/src/session/auth/gateway.ts +49 -0
  35. package/src/session/auth/oauth.test.ts +173 -0
  36. 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: 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)
@@ -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
+ });