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

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 (288) hide show
  1. package/lib/boot/branding.d.ts +13 -0
  2. package/lib/boot/branding.d.ts.map +1 -1
  3. package/lib/boot/branding.js +7 -0
  4. package/lib/boot/branding.js.map +1 -1
  5. package/lib/boot/index.d.ts +1 -1
  6. package/lib/boot/index.d.ts.map +1 -1
  7. package/lib/boot/index.js +5 -23
  8. package/lib/boot/index.js.map +1 -1
  9. package/lib/boot/loading.d.ts +4 -0
  10. package/lib/boot/loading.d.ts.map +1 -0
  11. package/lib/boot/loading.js +28 -0
  12. package/lib/boot/loading.js.map +1 -0
  13. package/lib/core/components/SelectList.d.ts.map +1 -1
  14. package/lib/core/components/SelectList.js +3 -3
  15. package/lib/core/components/SelectList.js.map +1 -1
  16. package/lib/core/components/SidePanel.d.ts.map +1 -1
  17. package/lib/core/components/SidePanel.js +2 -1
  18. package/lib/core/components/SidePanel.js.map +1 -1
  19. package/lib/core/components/shadcn/theme/ThemeProvider.d.ts.map +1 -1
  20. package/lib/core/components/shadcn/theme/ThemeProvider.js +21 -5
  21. package/lib/core/components/shadcn/theme/ThemeProvider.js.map +1 -1
  22. package/lib/env/index.d.ts +11 -0
  23. package/lib/env/index.d.ts.map +1 -1
  24. package/lib/env/index.js +16 -3
  25. package/lib/env/index.js.map +1 -1
  26. package/lib/features/agent/chat/ModernAgentConversation.js +4 -1
  27. package/lib/features/agent/chat/ModernAgentConversation.js.map +1 -1
  28. package/lib/features/agent/chat/ModernAgentOutput/MessageInput.d.ts +5 -1
  29. package/lib/features/agent/chat/ModernAgentOutput/MessageInput.d.ts.map +1 -1
  30. package/lib/features/agent/chat/ModernAgentOutput/MessageInput.js +9 -2
  31. package/lib/features/agent/chat/ModernAgentOutput/MessageInput.js.map +1 -1
  32. package/lib/features/magic-pdf/GroundedExtractionView.js +3 -3
  33. package/lib/features/magic-pdf/GroundedExtractionView.js.map +1 -1
  34. package/lib/features/media-viewer/AudioPanel.d.ts +10 -2
  35. package/lib/features/media-viewer/AudioPanel.d.ts.map +1 -1
  36. package/lib/features/media-viewer/AudioPanel.js +78 -13
  37. package/lib/features/media-viewer/AudioPanel.js.map +1 -1
  38. package/lib/features/oauth/McpConnectionsButton.d.ts +2 -2
  39. package/lib/features/oauth/McpConnectionsButton.d.ts.map +1 -1
  40. package/lib/features/oauth/McpConnectionsButton.js +9 -7
  41. package/lib/features/oauth/McpConnectionsButton.js.map +1 -1
  42. package/lib/features/oauth/McpConnectionsDialog.d.ts.map +1 -1
  43. package/lib/features/oauth/McpConnectionsDialog.js +7 -3
  44. package/lib/features/oauth/McpConnectionsDialog.js.map +1 -1
  45. package/lib/features/oauth/useMcpConnections.d.ts +4 -2
  46. package/lib/features/oauth/useMcpConnections.d.ts.map +1 -1
  47. package/lib/features/oauth/useMcpConnections.js +15 -9
  48. package/lib/features/oauth/useMcpConnections.js.map +1 -1
  49. package/lib/features/store/objects/selection/ObjectsActionContext.d.ts.map +1 -1
  50. package/lib/features/store/objects/selection/ObjectsActionContext.js +2 -0
  51. package/lib/features/store/objects/selection/ObjectsActionContext.js.map +1 -1
  52. package/lib/features/store/objects/selection/SelectionActions.d.ts +4 -2
  53. package/lib/features/store/objects/selection/SelectionActions.d.ts.map +1 -1
  54. package/lib/features/store/objects/selection/SelectionActions.js +6 -3
  55. package/lib/features/store/objects/selection/SelectionActions.js.map +1 -1
  56. package/lib/features/store/objects/selection/actions/StartWorkflowAction.d.ts +4 -0
  57. package/lib/features/store/objects/selection/actions/StartWorkflowAction.d.ts.map +1 -0
  58. package/lib/features/store/objects/selection/actions/StartWorkflowAction.js +81 -0
  59. package/lib/features/store/objects/selection/actions/StartWorkflowAction.js.map +1 -0
  60. package/lib/features/store/objects/selection/actions/index.d.ts +1 -0
  61. package/lib/features/store/objects/selection/actions/index.d.ts.map +1 -1
  62. package/lib/features/store/objects/selection/actions/index.js +1 -0
  63. package/lib/features/store/objects/selection/actions/index.js.map +1 -1
  64. package/lib/features/store/types/TypeRegistry.d.ts +345 -155
  65. package/lib/features/store/types/TypeRegistry.d.ts.map +1 -1
  66. package/lib/features/store/types/search/ObjectTypeSearchContext.d.ts +345 -155
  67. package/lib/features/store/types/search/ObjectTypeSearchContext.d.ts.map +1 -1
  68. package/lib/i18n/locales/ar.json +10 -2
  69. package/lib/i18n/locales/de.json +6 -2
  70. package/lib/i18n/locales/en.json +6 -2
  71. package/lib/i18n/locales/es.json +6 -2
  72. package/lib/i18n/locales/fr.json +7 -2
  73. package/lib/i18n/locales/it.json +6 -2
  74. package/lib/i18n/locales/ja.json +6 -2
  75. package/lib/i18n/locales/ko.json +6 -2
  76. package/lib/i18n/locales/pt.json +6 -2
  77. package/lib/i18n/locales/ru.json +8 -2
  78. package/lib/i18n/locales/tr.json +6 -2
  79. package/lib/i18n/locales/zh-TW.json +6 -2
  80. package/lib/i18n/locales/zh.json +6 -2
  81. package/lib/session/UserSession.d.ts +3 -0
  82. package/lib/session/UserSession.d.ts.map +1 -1
  83. package/lib/session/UserSession.js +18 -8
  84. package/lib/session/UserSession.js.map +1 -1
  85. package/lib/session/UserSessionProvider.d.ts.map +1 -1
  86. package/lib/session/UserSessionProvider.js +34 -24
  87. package/lib/session/UserSessionProvider.js.map +1 -1
  88. package/lib/session/auth/authRoundTrip.d.ts +8 -0
  89. package/lib/session/auth/authRoundTrip.d.ts.map +1 -0
  90. package/lib/session/auth/authRoundTrip.js +22 -0
  91. package/lib/session/auth/authRoundTrip.js.map +1 -0
  92. package/lib/session/auth/composable.d.ts.map +1 -1
  93. package/lib/session/auth/composable.js +9 -7
  94. package/lib/session/auth/composable.js.map +1 -1
  95. package/lib/session/auth/domainRouting.d.ts +13 -6
  96. package/lib/session/auth/domainRouting.d.ts.map +1 -1
  97. package/lib/session/auth/domainRouting.js +2 -21
  98. package/lib/session/auth/domainRouting.js.map +1 -1
  99. package/lib/session/auth/oauth.d.ts +6 -1
  100. package/lib/session/auth/oauth.d.ts.map +1 -1
  101. package/lib/session/auth/oauth.js +145 -8
  102. package/lib/session/auth/oauth.js.map +1 -1
  103. package/lib/session/auth/tokenIssuer.d.ts +3 -0
  104. package/lib/session/auth/tokenIssuer.d.ts.map +1 -0
  105. package/lib/session/auth/tokenIssuer.js +27 -0
  106. package/lib/session/auth/tokenIssuer.js.map +1 -0
  107. package/lib/session/auth/vertesiaHosts.d.ts +47 -0
  108. package/lib/session/auth/vertesiaHosts.d.ts.map +1 -0
  109. package/lib/session/auth/vertesiaHosts.js +79 -0
  110. package/lib/session/auth/vertesiaHosts.js.map +1 -0
  111. package/lib/session/index.d.ts +1 -0
  112. package/lib/session/index.d.ts.map +1 -1
  113. package/lib/session/index.js +1 -0
  114. package/lib/session/index.js.map +1 -1
  115. package/lib/shell/AuthScreenPreview.d.ts +1 -1
  116. package/lib/shell/AuthScreenPreview.d.ts.map +1 -1
  117. package/lib/shell/AuthScreenPreview.js +2 -5
  118. package/lib/shell/AuthScreenPreview.js.map +1 -1
  119. package/lib/shell/BrandedAuthScreens.d.ts +1 -4
  120. package/lib/shell/BrandedAuthScreens.d.ts.map +1 -1
  121. package/lib/shell/BrandedAuthScreens.js +5 -26
  122. package/lib/shell/BrandedAuthScreens.js.map +1 -1
  123. package/lib/shell/BrandedLoadingIndicator.d.ts +11 -0
  124. package/lib/shell/BrandedLoadingIndicator.d.ts.map +1 -0
  125. package/lib/shell/BrandedLoadingIndicator.js +27 -0
  126. package/lib/shell/BrandedLoadingIndicator.js.map +1 -0
  127. package/lib/shell/SplashScreen.d.ts.map +1 -1
  128. package/lib/shell/SplashScreen.js +2 -1
  129. package/lib/shell/SplashScreen.js.map +1 -1
  130. package/lib/shell/apps/AppInstallationProvider.d.ts +16 -4
  131. package/lib/shell/apps/AppInstallationProvider.d.ts.map +1 -1
  132. package/lib/shell/apps/iframe-auth.d.ts +2 -0
  133. package/lib/shell/apps/iframe-auth.d.ts.map +1 -1
  134. package/lib/shell/apps/iframe-auth.js +6 -2
  135. package/lib/shell/apps/iframe-auth.js.map +1 -1
  136. package/lib/shell/index.d.ts +1 -0
  137. package/lib/shell/index.d.ts.map +1 -1
  138. package/lib/shell/index.js +1 -0
  139. package/lib/shell/index.js.map +1 -1
  140. package/lib/shell/login/SignInAuthPending.js +2 -2
  141. package/lib/shell/login/SignInAuthPending.js.map +1 -1
  142. package/lib/shell/login/SignInEmailStep.d.ts.map +1 -1
  143. package/lib/shell/login/SignInEmailStep.js +2 -2
  144. package/lib/shell/login/SignInEmailStep.js.map +1 -1
  145. package/lib/shell/login/TerminalLogin.d.ts.map +1 -1
  146. package/lib/shell/login/TerminalLogin.js +28 -21
  147. package/lib/shell/login/TerminalLogin.js.map +1 -1
  148. package/lib/vertesia-ui-boot.js +33 -29
  149. package/lib/vertesia-ui-boot.js.map +1 -1
  150. package/lib/vertesia-ui-core.js +34 -34
  151. package/lib/vertesia-ui-core.js.map +1 -1
  152. package/lib/vertesia-ui-env.js +1 -1
  153. package/lib/vertesia-ui-env.js.map +1 -1
  154. package/lib/vertesia-ui-features.js +163 -163
  155. package/lib/vertesia-ui-features.js.map +1 -1
  156. package/lib/vertesia-ui-i18n.Bl8KeZUK.chunk.js +5 -0
  157. package/lib/vertesia-ui-i18n.Bl8KeZUK.chunk.js.map +1 -0
  158. package/lib/vertesia-ui-i18n.BmT06XFI.chunk.js +5 -0
  159. package/lib/vertesia-ui-i18n.BmT06XFI.chunk.js.map +1 -0
  160. package/lib/vertesia-ui-i18n.Ch-Jjj0L.chunk.js +5 -0
  161. package/lib/vertesia-ui-i18n.Ch-Jjj0L.chunk.js.map +1 -0
  162. package/lib/vertesia-ui-i18n.CjhloW9s.chunk.js +5 -0
  163. package/lib/vertesia-ui-i18n.CjhloW9s.chunk.js.map +1 -0
  164. package/lib/vertesia-ui-i18n.CkfHdQzY.chunk.js +5 -0
  165. package/lib/vertesia-ui-i18n.CkfHdQzY.chunk.js.map +1 -0
  166. package/lib/vertesia-ui-i18n.DFuKfTe1.chunk.js +5 -0
  167. package/lib/vertesia-ui-i18n.DFuKfTe1.chunk.js.map +1 -0
  168. package/lib/vertesia-ui-i18n.DHqNVekZ.chunk.js +5 -0
  169. package/lib/vertesia-ui-i18n.DHqNVekZ.chunk.js.map +1 -0
  170. package/lib/vertesia-ui-i18n.DOaKbmaj.chunk.js +5 -0
  171. package/lib/vertesia-ui-i18n.DOaKbmaj.chunk.js.map +1 -0
  172. package/lib/vertesia-ui-i18n.Dge3Gl-h.chunk.js +5 -0
  173. package/lib/vertesia-ui-i18n.Dge3Gl-h.chunk.js.map +1 -0
  174. package/lib/vertesia-ui-i18n.GLt-10Lx.chunk.js +5 -0
  175. package/lib/vertesia-ui-i18n.GLt-10Lx.chunk.js.map +1 -0
  176. package/lib/vertesia-ui-i18n.T-XtAGbw.chunk.js +5 -0
  177. package/lib/vertesia-ui-i18n.T-XtAGbw.chunk.js.map +1 -0
  178. package/lib/vertesia-ui-i18n.VOJyTpMM.chunk.js +5 -0
  179. package/lib/vertesia-ui-i18n.VOJyTpMM.chunk.js.map +1 -0
  180. package/lib/vertesia-ui-i18n.js +3 -3
  181. package/lib/vertesia-ui-i18n.js.map +1 -1
  182. package/lib/vertesia-ui-layout.js +15 -15
  183. package/lib/vertesia-ui-layout.js.map +1 -1
  184. package/lib/vertesia-ui-rich-text.js +44 -44
  185. package/lib/vertesia-ui-rich-text.js.map +1 -1
  186. package/lib/vertesia-ui-session.js +1 -1
  187. package/lib/vertesia-ui-session.js.map +1 -1
  188. package/lib/vertesia-ui-shell.js +72 -48
  189. package/lib/vertesia-ui-shell.js.map +1 -1
  190. package/lib/vertesia-ui-widgets.js +51 -51
  191. package/lib/vertesia-ui-widgets.js.map +1 -1
  192. package/package.json +19 -19
  193. package/src/boot/branding.ts +8 -0
  194. package/src/boot/index.ts +6 -23
  195. package/src/boot/loading.ts +27 -0
  196. package/src/core/components/SelectList.tsx +5 -2
  197. package/src/core/components/SidePanel.tsx +65 -60
  198. package/src/core/components/shadcn/theme/ThemeProvider.test.tsx +101 -0
  199. package/src/core/components/shadcn/theme/ThemeProvider.tsx +21 -9
  200. package/src/env/index.test.ts +21 -0
  201. package/src/env/index.ts +33 -2
  202. package/src/features/agent/chat/ModernAgentConversation.test.tsx +81 -0
  203. package/src/features/agent/chat/ModernAgentConversation.tsx +5 -0
  204. package/src/features/agent/chat/ModernAgentOutput/MessageInput.tsx +17 -1
  205. package/src/features/magic-pdf/GroundedExtractionView.tsx +3 -3
  206. package/src/features/media-viewer/AudioPanel.test.ts +84 -0
  207. package/src/features/media-viewer/AudioPanel.tsx +97 -14
  208. package/src/features/oauth/McpConnectionsButton.test.tsx +56 -0
  209. package/src/features/oauth/McpConnectionsButton.tsx +9 -6
  210. package/src/features/oauth/McpConnectionsDialog.tsx +14 -5
  211. package/src/features/oauth/useMcpConnections.test.ts +10 -0
  212. package/src/features/oauth/useMcpConnections.ts +18 -10
  213. package/src/features/store/objects/selection/ObjectsActionContext.tsx +2 -0
  214. package/src/features/store/objects/selection/SelectionActions.tsx +13 -6
  215. package/src/features/store/objects/selection/actions/StartWorkflowAction.tsx +162 -0
  216. package/src/features/store/objects/selection/actions/index.ts +1 -0
  217. package/src/i18n/locales/ar.json +10 -2
  218. package/src/i18n/locales/de.json +6 -2
  219. package/src/i18n/locales/en.json +6 -2
  220. package/src/i18n/locales/es.json +6 -2
  221. package/src/i18n/locales/fr.json +7 -2
  222. package/src/i18n/locales/it.json +6 -2
  223. package/src/i18n/locales/ja.json +6 -2
  224. package/src/i18n/locales/ko.json +6 -2
  225. package/src/i18n/locales/pt.json +6 -2
  226. package/src/i18n/locales/ru.json +8 -2
  227. package/src/i18n/locales/tr.json +6 -2
  228. package/src/i18n/locales/zh-TW.json +6 -2
  229. package/src/i18n/locales/zh.json +6 -2
  230. package/src/session/UserSession.test.ts +48 -3
  231. package/src/session/UserSession.ts +20 -8
  232. package/src/session/UserSessionProvider.handoff.test.tsx +103 -0
  233. package/src/session/UserSessionProvider.iframe.test.tsx +178 -0
  234. package/src/session/UserSessionProvider.state-mismatch.test.tsx +43 -0
  235. package/src/session/UserSessionProvider.tsx +35 -23
  236. package/src/session/auth/auth-flow.md +18 -5
  237. package/src/session/auth/authRoundTrip.ts +24 -0
  238. package/src/session/auth/composable.test.ts +23 -1
  239. package/src/session/auth/composable.ts +10 -8
  240. package/src/session/auth/domainRouting.ts +4 -25
  241. package/src/session/auth/oauth.test.ts +121 -4
  242. package/src/session/auth/oauth.ts +172 -11
  243. package/src/session/auth/tokenIssuer.test.ts +25 -0
  244. package/src/session/auth/tokenIssuer.ts +25 -0
  245. package/src/session/auth/vertesiaHosts.test.ts +26 -0
  246. package/src/session/auth/vertesiaHosts.ts +85 -0
  247. package/src/session/index.ts +1 -0
  248. package/src/shell/AuthScreenPreview.tsx +2 -6
  249. package/src/shell/BrandedAuthScreens.test.tsx +26 -0
  250. package/src/shell/BrandedAuthScreens.tsx +4 -40
  251. package/src/shell/BrandedLoadingIndicator.tsx +42 -0
  252. package/src/shell/SplashScreen.tsx +4 -4
  253. package/src/shell/apps/iframe-auth.test.ts +8 -11
  254. package/src/shell/apps/iframe-auth.ts +10 -1
  255. package/src/shell/index.tsx +1 -0
  256. package/src/shell/login/SignInAuthPending.tsx +2 -2
  257. package/src/shell/login/SignInEmailStep.tsx +4 -2
  258. package/src/shell/login/TerminalLogin.test.tsx +111 -0
  259. package/src/shell/login/TerminalLogin.tsx +61 -37
  260. package/lib/features/store/objects/selection/actions/StartWorkflowButton.d.ts +0 -7
  261. package/lib/features/store/objects/selection/actions/StartWorkflowButton.d.ts.map +0 -1
  262. package/lib/features/store/objects/selection/actions/StartWorkflowButton.js +0 -51
  263. package/lib/features/store/objects/selection/actions/StartWorkflowButton.js.map +0 -1
  264. package/lib/vertesia-ui-i18n.B3NFclwl.chunk.js +0 -5
  265. package/lib/vertesia-ui-i18n.B3NFclwl.chunk.js.map +0 -1
  266. package/lib/vertesia-ui-i18n.BWSrNIxc.chunk.js +0 -5
  267. package/lib/vertesia-ui-i18n.BWSrNIxc.chunk.js.map +0 -1
  268. package/lib/vertesia-ui-i18n.BWvWeB1p.chunk.js +0 -5
  269. package/lib/vertesia-ui-i18n.BWvWeB1p.chunk.js.map +0 -1
  270. package/lib/vertesia-ui-i18n.BZCXvTC1.chunk.js +0 -5
  271. package/lib/vertesia-ui-i18n.BZCXvTC1.chunk.js.map +0 -1
  272. package/lib/vertesia-ui-i18n.BbJMvFZW.chunk.js +0 -5
  273. package/lib/vertesia-ui-i18n.BbJMvFZW.chunk.js.map +0 -1
  274. package/lib/vertesia-ui-i18n.CE5ldwpt.chunk.js +0 -5
  275. package/lib/vertesia-ui-i18n.CE5ldwpt.chunk.js.map +0 -1
  276. package/lib/vertesia-ui-i18n.CMC0IShY.chunk.js +0 -5
  277. package/lib/vertesia-ui-i18n.CMC0IShY.chunk.js.map +0 -1
  278. package/lib/vertesia-ui-i18n.CkcRqBKU.chunk.js +0 -5
  279. package/lib/vertesia-ui-i18n.CkcRqBKU.chunk.js.map +0 -1
  280. package/lib/vertesia-ui-i18n.D-Mq1sw_.chunk.js +0 -5
  281. package/lib/vertesia-ui-i18n.D-Mq1sw_.chunk.js.map +0 -1
  282. package/lib/vertesia-ui-i18n.DkK68-A-.chunk.js +0 -5
  283. package/lib/vertesia-ui-i18n.DkK68-A-.chunk.js.map +0 -1
  284. package/lib/vertesia-ui-i18n.XE_dcCg6.chunk.js +0 -5
  285. package/lib/vertesia-ui-i18n.XE_dcCg6.chunk.js.map +0 -1
  286. package/lib/vertesia-ui-i18n.qgNqMPvK.chunk.js +0 -5
  287. package/lib/vertesia-ui-i18n.qgNqMPvK.chunk.js.map +0 -1
  288. package/src/features/store/objects/selection/actions/StartWorkflowButton.tsx +0 -104
@@ -12,6 +12,8 @@ let browser: {
12
12
  history: { state: null; replaceState: ReturnType<typeof vi.fn> };
13
13
  };
14
14
  let requests: { url: string; init?: RequestInit }[];
15
+ let revocationEndpoint: string | undefined;
16
+ let tokenEndpoint: string;
15
17
  function jwt(): string {
16
18
  return `e30.${btoa(
17
19
  JSON.stringify({
@@ -24,7 +26,7 @@ function jwt(): string {
24
26
  }),
25
27
  )}.signature`;
26
28
  }
27
- async function setup() {
29
+ async function setup(offlineAccess = false) {
28
30
  vi.resetModules();
29
31
  const { Env } = await import('../../env');
30
32
  Env.init({
@@ -34,12 +36,14 @@ async function setup() {
34
36
  isLocalDev: false,
35
37
  isDocker: false,
36
38
  endpoints: { studio: 'https://api.dev1.vertesia.io', zeno: 'https://api.dev1.vertesia.io', sts: issuer },
37
- oauth: { clientId, redirectUri: `${origin}/app` },
39
+ oauth: { clientId, redirectUri: `${origin}/app`, offlineAccess },
38
40
  });
39
41
  return import('./oauth');
40
42
  }
41
43
  beforeEach(() => {
42
44
  storage.clear();
45
+ revocationEndpoint = undefined;
46
+ tokenEndpoint = `${issuer}/oauth/token`;
43
47
  requests = [];
44
48
  browser = {
45
49
  location: { href: `${origin}/app/report?a=a&p=p#chart`, origin, replace },
@@ -52,6 +56,7 @@ beforeEach(() => {
52
56
  };
53
57
  browser.parent = browser;
54
58
  vi.stubGlobal('window', browser);
59
+ vi.stubGlobal('document', { cookie: '' });
55
60
  vi.stubGlobal('sessionStorage', {
56
61
  getItem: (key: string) => storage.get(key) ?? null,
57
62
  setItem: (key: string, value: string) => storage.set(key, value),
@@ -66,14 +71,20 @@ beforeEach(() => {
66
71
  return Response.json({
67
72
  issuer,
68
73
  authorization_endpoint: 'https://preview.cloud.dev1.vertesia.io/oauth/authorize',
69
- token_endpoint: `${issuer}/oauth/token`,
74
+ token_endpoint: tokenEndpoint,
75
+ revocation_endpoint: revocationEndpoint,
70
76
  });
71
77
  if (url === clientId)
72
78
  return Response.json({
73
79
  redirect_uris: [`${origin}/app`],
74
80
  scope: 'openid profile content:read offline_access',
75
81
  });
76
- if (url === `${issuer}/oauth/token`) return Response.json({ access_token: jwt(), token_type: 'Bearer' });
82
+ if (url === tokenEndpoint)
83
+ return Response.json({
84
+ access_token: jwt(),
85
+ token_type: 'Bearer',
86
+ refresh_token: 'refresh-credential',
87
+ });
77
88
  throw new Error(`Unexpected request: ${url}`);
78
89
  }),
79
90
  );
@@ -90,6 +101,7 @@ it('uses CIMD and PKCE, exchanges the callback once and restores the app deep li
90
101
  await vi.waitFor(() => expect(replace).toHaveBeenCalledTimes(1));
91
102
  const authorize = new URL(replace.mock.calls[0][0]);
92
103
  expect(authorize.searchParams.get('client_id')).toBe(clientId);
104
+ expect(authorize.searchParams.get('resource')).toBe(`${issuer}/`);
93
105
  expect(authorize.searchParams.get('redirect_uri')).toBe(`${origin}/app`);
94
106
  expect(authorize.searchParams.get('scope')).toBe('openid profile content:read');
95
107
  expect(authorize.searchParams.get('project_id')).toBe('p');
@@ -106,6 +118,7 @@ it('uses CIMD and PKCE, exchanges the callback once and restores the app deep li
106
118
  const body = exchange.init?.body as URLSearchParams;
107
119
  expect(body.get('code_verifier')).toBe(txn.verifier);
108
120
  expect(body.get('grant_type')).toBe('authorization_code');
121
+ expect(body.get('resource')).toBe(authorize.searchParams.get('resource'));
109
122
  expect(body.has('client_secret')).toBe(false);
110
123
  expect(storage.has('vertesia.oauth.transaction')).toBe(false);
111
124
  expect(browser.location.href).toBe(`${origin}/app/report?a=a&p=p#chart`);
@@ -171,3 +184,107 @@ it('supports a registered client ID without fetching a CIMD', async () => {
171
184
  expect(authorize.searchParams.has('consent_required')).toBe(false);
172
185
  expect(requests).toHaveLength(1);
173
186
  });
187
+
188
+ it('prefers an STS fragment token over OAuth', async () => {
189
+ const oauth = await setup();
190
+ storage.set('auth_state', 'state');
191
+ storage.set('auth_state_expiry', String(Date.now() + 60000));
192
+ Object.assign(browser.location, { hash: `#token=${jwt()}&state=state` });
193
+ expect(oauth.usesAppOAuth()).toBe(false);
194
+ });
195
+ async function loginOffline() {
196
+ const initial = await setup(true);
197
+ void initial.getAppOAuthToken();
198
+ await vi.waitFor(() => expect(replace).toHaveBeenCalledOnce());
199
+ const authorize = new URL(replace.mock.calls[0][0]);
200
+ browser.location.href = `${origin}/app?code=code&state=${authorize.searchParams.get('state')}`;
201
+ const oauth = await setup(true);
202
+ await oauth.getAppOAuthToken();
203
+ requests = [];
204
+ replace.mockClear();
205
+ return oauth;
206
+ }
207
+ it('keeps refresh credentials only in memory and revokes the latest one on logout', async () => {
208
+ const oauth = await loginOffline();
209
+ expect(storage.get('vertesia.oauth.access')).not.toContain('refresh-credential');
210
+ expect(await oauth.getAppOAuthToken(true)).toBeTruthy();
211
+ const exchange = requests.find((request) => request.url.endsWith('/oauth/token'));
212
+ if (!(exchange?.init?.body instanceof URLSearchParams)) throw new Error('Missing refresh grant');
213
+ expect(exchange.init.body.get('grant_type')).toBe('refresh_token');
214
+ expect(exchange.init.body.get('refresh_token')).toBe('refresh-credential');
215
+ vi.stubGlobal(
216
+ 'fetch',
217
+ vi.fn(async () => new Response(null, { status: 200 })),
218
+ );
219
+ await oauth.revokeAppOAuthSession();
220
+ expect(String(vi.mocked(fetch).mock.calls[0][0])).toBe(`${issuer}/oauth/revoke`);
221
+ const body = vi.mocked(fetch).mock.calls[0][1]?.body as URLSearchParams;
222
+ expect(body.get('token')).toBe('refresh-credential');
223
+ expect(storage.has('vertesia.oauth.access')).toBe(false);
224
+ });
225
+ it('starts a fresh authorization when the refresh endpoint is unreachable', async () => {
226
+ const oauth = await loginOffline();
227
+ const original = fetch;
228
+ vi.stubGlobal(
229
+ 'fetch',
230
+ vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
231
+ if (String(input).endsWith('/oauth/token')) throw new TypeError('Network unavailable');
232
+ return original(input, init);
233
+ }),
234
+ );
235
+ void oauth.getAppOAuthToken(true);
236
+ await vi.waitFor(() => expect(replace).toHaveBeenCalledOnce());
237
+ expect(storage.has('vertesia.oauth.access')).toBe(false);
238
+ });
239
+ it('does not recover refresh credentials from browser storage after reload', async () => {
240
+ await loginOffline();
241
+ const reloaded = await setup(true);
242
+ void reloaded.getAppOAuthToken(true);
243
+ await vi.waitFor(() => expect(replace).toHaveBeenCalledOnce());
244
+ expect(requests.some(({ url }) => url.endsWith('/oauth/token'))).toBe(false);
245
+ });
246
+ it('does not let a forged fragment suppress OAuth without a matching state', async () => {
247
+ const oauth = await setup();
248
+ Object.assign(browser.location, { hash: `#token=${jwt()}&state=forged` });
249
+ expect(oauth.usesAppOAuth()).toBe(true);
250
+ });
251
+
252
+ it('routes a state-bound regional legacy token through branch STS without starting OAuth', async () => {
253
+ const oauth = await setup();
254
+ const { Env } = await import('../../env');
255
+ Env.endpoints.sts = 'https://token-server-dev-example.api.dev1.vertesia.io';
256
+ storage.set('auth_state', 'state');
257
+ storage.set('auth_state_expiry', String(Date.now() + 60000));
258
+ Object.assign(browser.location, { hash: `#token=${jwt()}&state=state` });
259
+ expect(oauth.usesAppOAuth()).toBe(false);
260
+ storage.delete('auth_state');
261
+ expect(oauth.usesAppOAuth()).toBe(true);
262
+ });
263
+
264
+ it.each([false, true])('uses the discovered revocation endpoint after refresh=%s', async (refresh) => {
265
+ tokenEndpoint = `${issuer}/custom/exchange`;
266
+ revocationEndpoint = `${issuer}/sessions/invalidate`;
267
+ const oauth = await loginOffline();
268
+ if (refresh) {
269
+ revocationEndpoint = `${issuer}/sessions/invalidate-v2`;
270
+ await oauth.getAppOAuthToken(true);
271
+ }
272
+ vi.stubGlobal(
273
+ 'fetch',
274
+ vi.fn(async () => new Response(null, { status: 200 })),
275
+ );
276
+ await oauth.revokeAppOAuthSession();
277
+ expect(String(vi.mocked(fetch).mock.calls[0][0])).toBe(revocationEndpoint);
278
+ const body = vi.mocked(fetch).mock.calls[0][1]?.body as URLSearchParams;
279
+ expect(body.get('token')).toBe('refresh-credential');
280
+ expect(body.get('client_id')).toBe(clientId);
281
+ expect(storage.has('vertesia.oauth.access')).toBe(false);
282
+ });
283
+
284
+ it('rejects an insecure discovered revocation endpoint', async () => {
285
+ revocationEndpoint = 'http://insecure.example/revoke';
286
+ const oauth = await setup(true);
287
+ await expect(oauth.getAppOAuthToken()).rejects.toThrow();
288
+ expect(replace).not.toHaveBeenCalled();
289
+ expect(requests.some(({ url }) => url === tokenEndpoint)).toBe(false);
290
+ });
@@ -1,15 +1,41 @@
1
1
  import type { AuthTokenPayload } from '@vertesia/common';
2
2
  import { Env } from '@vertesia/ui/env';
3
3
  import { jwtDecode } from 'jwt-decode';
4
+ import { markCentralAuthRoundTripStarted } from './authRoundTrip';
5
+ import { verifyAuthState } from './authState';
4
6
  import { usesGatewaySession } from './gateway';
7
+ import { isStsTokenIssuer } from './tokenIssuer';
5
8
 
6
9
  const TRANSACTION_KEY = 'vertesia.oauth.transaction';
7
10
  const TOKEN_KEY = 'vertesia.oauth.access';
8
11
  const MAX_TRANSACTION_AGE = 10 * 60_000;
9
12
  let pending: Promise<string> | undefined;
13
+ // Long-lived credentials never enter browser storage. A reload resumes via the broker when access expires.
14
+ let refreshSession:
15
+ | { token: string; clientId: string; issuer: string; tokenEndpoint: string; revocationEndpoint?: string }
16
+ | undefined;
17
+
18
+ export class OAuthLoginError extends Error {}
19
+
20
+ function hasVertesiaFragmentToken(): boolean {
21
+ try {
22
+ const fragment = new URLSearchParams(window.location.hash.slice(1));
23
+ const token = fragment.get('token');
24
+ if (!token || verifyAuthState(fragment.get('state'))) return false;
25
+ return isStsTokenIssuer(jwtDecode<AuthTokenPayload>(token).iss, Env.endpoints.sts);
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
10
30
 
11
31
  export function usesAppOAuth(): boolean {
12
- return Boolean(Env.oauth && window.parent === window && window.AUTH_MODE !== 'firebase' && !usesGatewaySession());
32
+ return Boolean(
33
+ Env.oauth &&
34
+ window.parent === window &&
35
+ window.AUTH_MODE !== 'firebase' &&
36
+ !usesGatewaySession() &&
37
+ !hasVertesiaFragmentToken(),
38
+ );
13
39
  }
14
40
 
15
41
  function base64url(bytes: Uint8Array): string {
@@ -38,12 +64,13 @@ interface Transaction {
38
64
  }
39
65
 
40
66
  export function clearAppOAuth(): void {
67
+ refreshSession = undefined;
41
68
  sessionStorage.removeItem(TOKEN_KEY);
42
69
  sessionStorage.removeItem(TRANSACTION_KEY);
43
70
  }
44
71
 
45
72
  /** Public-client PKCE for independently hosted apps. No Firebase identity token or client secret. */
46
- async function acquireToken(): Promise<string> {
73
+ async function acquireToken(forceRefresh = false): Promise<string> {
47
74
  const config = Env.oauth;
48
75
  if (!config) throw new Error('Application OAuth is not configured');
49
76
  const clientId = config.clientId.trim();
@@ -65,6 +92,7 @@ async function acquireToken(): Promise<string> {
65
92
  const state = callback ? current.searchParams.get('state') : null;
66
93
  const code = callback ? current.searchParams.get('code') : null;
67
94
  const error = callback ? current.searchParams.get('error') : null;
95
+ let refreshCredential: string | undefined;
68
96
  let transaction: Transaction | undefined;
69
97
  if (state || code || error) {
70
98
  const raw = sessionStorage.getItem(TRANSACTION_KEY);
@@ -85,12 +113,31 @@ async function acquireToken(): Promise<string> {
85
113
  } else {
86
114
  const raw = sessionStorage.getItem(TOKEN_KEY);
87
115
  if (raw) {
88
- const cached = JSON.parse(raw) as { token: string; clientId: string; issuer: string };
116
+ const cached = JSON.parse(raw) as {
117
+ token: string;
118
+ clientId: string;
119
+ issuer: string;
120
+ refreshToken?: string;
121
+ };
89
122
  const claims = jwtDecode<AuthTokenPayload>(cached.token);
123
+ if (
124
+ config.offlineAccess &&
125
+ cached.clientId === clientId &&
126
+ cached.issuer === issuer &&
127
+ (!current.searchParams.get('p') || claims.project?.id === current.searchParams.get('p')) &&
128
+ (!current.searchParams.get('a') || claims.account?.id === current.searchParams.get('a'))
129
+ )
130
+ refreshCredential =
131
+ refreshSession?.clientId === clientId && refreshSession.issuer === issuer
132
+ ? refreshSession.token
133
+ : undefined;
134
+ // Discard credentials persisted by older SDK versions rather than loading them into the new session.
135
+ if (cached.refreshToken) sessionStorage.removeItem(TOKEN_KEY);
90
136
  if (
91
137
  cached.clientId === clientId &&
92
138
  cached.issuer === issuer &&
93
- claims.exp > Date.now() / 1000 + 30 &&
139
+ !forceRefresh &&
140
+ claims.exp > Date.now() / 1000 + (config.offlineAccess ? 300 : 30) &&
94
141
  (!current.searchParams.get('p') || claims.project?.id === current.searchParams.get('p')) &&
95
142
  (!current.searchParams.get('a') || claims.account?.id === current.searchParams.get('a'))
96
143
  ) {
@@ -100,16 +147,77 @@ async function acquireToken(): Promise<string> {
100
147
  }
101
148
  const discovery = await fetch(`${issuer}/.well-known/oauth-authorization-server`);
102
149
  if (!discovery.ok) throw new Error(`OAuth discovery failed (${discovery.status})`);
103
- const metadata: { issuer: string; authorization_endpoint: string; token_endpoint: string } = await discovery.json();
150
+ const metadata: {
151
+ issuer: string;
152
+ authorization_endpoint: string;
153
+ token_endpoint: string;
154
+ revocation_endpoint?: string;
155
+ } = await discovery.json();
104
156
  if (metadata.issuer.replace(/\/+$/, '') !== issuer) throw new Error('OAuth issuer mismatch');
105
157
  const authorize = httpsUrl(metadata.authorization_endpoint);
106
158
  const tokenEndpoint = httpsUrl(metadata.token_endpoint);
159
+ const revocationEndpoint =
160
+ metadata.revocation_endpoint === undefined ? undefined : httpsUrl(metadata.revocation_endpoint).toString();
161
+ const resource = new URL(metadata.issuer).toString();
162
+ if (refreshCredential) {
163
+ try {
164
+ const response = await fetch(tokenEndpoint, {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
167
+ body: new URLSearchParams({
168
+ grant_type: 'refresh_token',
169
+ client_id: clientId,
170
+ refresh_token: refreshCredential,
171
+ resource,
172
+ }),
173
+ });
174
+ if (response.ok) {
175
+ const grant = (await response.json()) as {
176
+ access_token?: string;
177
+ refresh_token?: string;
178
+ token_type?: string;
179
+ };
180
+ if (!grant.access_token || grant.token_type?.toLowerCase() !== 'bearer')
181
+ throw new OAuthLoginError('Invalid OAuth refresh grant');
182
+ const claims = jwtDecode<AuthTokenPayload & { client_id?: string }>(grant.access_token);
183
+ if (
184
+ claims.iss.replace(/\/+$/, '') !== issuer ||
185
+ claims.client_id !== clientId ||
186
+ claims.exp <= Date.now() / 1000 ||
187
+ (!!current.searchParams.get('a') && claims.account?.id !== current.searchParams.get('a')) ||
188
+ (!!current.searchParams.get('p') && claims.project?.id !== current.searchParams.get('p'))
189
+ )
190
+ throw new OAuthLoginError('Invalid OAuth refresh identity');
191
+ sessionStorage.setItem(
192
+ TOKEN_KEY,
193
+ JSON.stringify({
194
+ token: grant.access_token,
195
+ clientId,
196
+ issuer,
197
+ }),
198
+ );
199
+ refreshSession = {
200
+ token: grant.refresh_token ?? refreshCredential,
201
+ clientId,
202
+ issuer,
203
+ tokenEndpoint: tokenEndpoint.toString(),
204
+ revocationEndpoint,
205
+ };
206
+ return grant.access_token;
207
+ }
208
+ } catch {
209
+ // A failed or invalid refresh starts a fresh authorization transaction.
210
+ }
211
+ refreshSession = undefined;
212
+ sessionStorage.removeItem(TOKEN_KEY);
213
+ }
107
214
  if (transaction && code) {
108
215
  const response = await fetch(tokenEndpoint, {
109
216
  method: 'POST',
110
217
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
111
218
  body: new URLSearchParams({
112
219
  grant_type: 'authorization_code',
220
+ resource,
113
221
  code,
114
222
  client_id: clientId,
115
223
  redirect_uri: redirectUri,
@@ -117,7 +225,7 @@ async function acquireToken(): Promise<string> {
117
225
  }),
118
226
  });
119
227
  if (!response.ok) throw new Error(`OAuth code exchange failed (${response.status})`);
120
- const grant: { access_token?: string; token_type?: string } = await response.json();
228
+ const grant: { access_token?: string; token_type?: string; refresh_token?: string } = await response.json();
121
229
  if (!grant.access_token || grant.token_type?.toLowerCase() !== 'bearer') throw new Error('Invalid OAuth grant');
122
230
  const claims = jwtDecode<AuthTokenPayload & { client_id?: string }>(grant.access_token);
123
231
  if (
@@ -127,9 +235,31 @@ async function acquireToken(): Promise<string> {
127
235
  ) {
128
236
  throw new Error('OAuth grant does not match this application');
129
237
  }
130
- sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ token: grant.access_token, clientId, issuer }));
238
+ sessionStorage.setItem(
239
+ TOKEN_KEY,
240
+ JSON.stringify({
241
+ token: grant.access_token,
242
+ clientId,
243
+ issuer,
244
+ }),
245
+ );
246
+ refreshSession =
247
+ config.offlineAccess && grant.refresh_token
248
+ ? {
249
+ token: grant.refresh_token,
250
+ clientId,
251
+ issuer,
252
+ tokenEndpoint: tokenEndpoint.toString(),
253
+ revocationEndpoint,
254
+ }
255
+ : undefined;
131
256
  const target = new URL(transaction.target);
132
257
  if (target.origin !== window.location.origin) throw new Error('Invalid OAuth return target');
258
+ if (claims.account?.id) target.searchParams.set('a', claims.account.id);
259
+ else target.searchParams.delete('a');
260
+ if (claims.project?.id) target.searchParams.set('p', claims.project.id);
261
+ else target.searchParams.delete('p');
262
+ if (target.pathname === '/auth/callback') target.pathname = '/';
133
263
  window.history.replaceState(window.history.state, '', target);
134
264
  return grant.access_token;
135
265
  }
@@ -156,21 +286,52 @@ async function acquireToken(): Promise<string> {
156
286
  authorize.searchParams.set('client_id', clientId);
157
287
  authorize.searchParams.set('redirect_uri', redirectUri);
158
288
  authorize.searchParams.set('response_type', 'code');
289
+ authorize.searchParams.set('resource', resource);
159
290
  authorize.searchParams.set('state', next.state);
160
291
  authorize.searchParams.set('code_challenge_method', 'S256');
161
292
  authorize.searchParams.set('code_challenge', base64url(new Uint8Array(digest)));
162
- // This browser client intentionally requests no refresh token.
163
- authorize.searchParams.set('scope', scopes.filter((s) => s !== 'offline_access').join(' '));
293
+ authorize.searchParams.set(
294
+ 'scope',
295
+ (config.offlineAccess
296
+ ? [...new Set([...scopes, 'offline_access'])]
297
+ : scopes.filter((s) => s !== 'offline_access')
298
+ ).join(' '),
299
+ );
300
+ const account = current.searchParams.get('a');
301
+ if (account) authorize.searchParams.set('account_id', account);
164
302
  const project = current.searchParams.get('p');
165
303
  if (project) authorize.searchParams.set('project_id', project);
166
304
  sessionStorage.setItem(TRANSACTION_KEY, JSON.stringify(next));
305
+ markCentralAuthRoundTripStarted();
167
306
  window.location.replace(authorize.toString());
168
307
  return new Promise(() => {});
169
308
  }
170
309
 
171
- export function getAppOAuthToken(): Promise<string> {
172
- pending ??= acquireToken().finally(() => {
310
+ export function getAppOAuthToken(forceRefresh = false): Promise<string> {
311
+ pending ??= acquireToken(forceRefresh).finally(() => {
173
312
  pending = undefined;
174
313
  });
175
314
  return pending;
176
315
  }
316
+
317
+ export function isAppOAuthLoginPending(): boolean {
318
+ return usesAppOAuth();
319
+ }
320
+ export const startAppOAuthLogin = getAppOAuthToken;
321
+
322
+ export async function revokeAppOAuthSession(): Promise<void> {
323
+ const cached = refreshSession;
324
+ clearAppOAuth();
325
+ if (!cached) return;
326
+ const revoke = httpsUrl(cached.revocationEndpoint ?? cached.tokenEndpoint);
327
+ if (cached.revocationEndpoint === undefined) revoke.pathname = revoke.pathname.replace(/\/token$/, '/revoke');
328
+ await fetch(revoke, {
329
+ method: 'POST',
330
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
331
+ body: new URLSearchParams({
332
+ client_id: cached.clientId,
333
+ token: cached.token,
334
+ token_type_hint: 'refresh_token',
335
+ }),
336
+ });
337
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isStsTokenIssuer } from './tokenIssuer';
3
+
4
+ const branch = 'https://token-server-dev-example.api.dev1.vertesia.io';
5
+ describe('legacy STS issuer routing', () => {
6
+ it('recognizes the configured endpoint and regional branch issuer', () => {
7
+ expect(isStsTokenIssuer(`${branch}/`, branch)).toBe(true);
8
+ expect(isStsTokenIssuer('https://sts.dev1.vertesia.io/', branch)).toBe(true);
9
+ });
10
+ it.each([
11
+ 'https://customer.example.com',
12
+ 'https://token-server-dev-example.api.dev1.vertesia.io.attacker.test',
13
+ `${branch}:444`,
14
+ `${branch}/env/dev1/other`,
15
+ branch.replace('https:', 'http:'),
16
+ branch.replace('https://', 'https://user@'),
17
+ ])('does not infer a regional issuer for %s', (endpoint) => {
18
+ expect(isStsTokenIssuer('https://sts.dev1.vertesia.io', endpoint)).toBe(false);
19
+ });
20
+ it('rejects other regions and providers', () => {
21
+ expect(isStsTokenIssuer('https://sts.dev2.vertesia.io', branch)).toBe(false);
22
+ expect(isStsTokenIssuer('https://securetoken.google.com/project', branch)).toBe(false);
23
+ expect(isStsTokenIssuer(undefined, branch)).toBe(false);
24
+ });
25
+ });
@@ -0,0 +1,25 @@
1
+ /** Classify legacy STS credentials for routing; API servers still verify their signatures and grants. */
2
+ export function isStsTokenIssuer(issuer: string | undefined, stsEndpoint: string): boolean {
3
+ if (!issuer) return false;
4
+ const normalized = issuer.replace(/\/+$/, '');
5
+ if (normalized === stsEndpoint.replace(/\/+$/, '')) return true;
6
+ try {
7
+ const endpoint = new URL(stsEndpoint);
8
+ // Branch STS uses a separate OAuth issuer but retains the regional issuer for legacy
9
+ // /token/issue and /token/sign credentials (including workload identity consumers).
10
+ const branch = /^token-server-dev-[a-z0-9-]+\.api\.(dev1|dev2)\.vertesia\.io$/.exec(endpoint.hostname);
11
+ return Boolean(
12
+ branch &&
13
+ endpoint.protocol === 'https:' &&
14
+ !endpoint.port &&
15
+ !endpoint.username &&
16
+ !endpoint.password &&
17
+ endpoint.pathname === '/' &&
18
+ !endpoint.search &&
19
+ !endpoint.hash &&
20
+ normalized === `https://sts.${branch[1]}.vertesia.io`,
21
+ );
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isTrustedAuthBrokerUrl } from './vertesiaHosts';
3
+
4
+ describe('runtime auth broker trust', () => {
5
+ it.each(['https://auth.vertesia.io/', 'https://auth.dev1.vertesia.io/', 'https://AUTH.US1.VERTESIA.IO./'])(
6
+ 'accepts %s',
7
+ (url) => {
8
+ expect(isTrustedAuthBrokerUrl(url)).toBe(true);
9
+ },
10
+ );
11
+ it.each([
12
+ 'https://evil.example/',
13
+ 'https://auth.vertesia.io.evil/',
14
+ 'https://apps.dev1.vertesia.io/',
15
+ 'https://auth.vertesia.io@evil.example/',
16
+ 'https://auth.vertesia.io/?redirect_uri=evil',
17
+ 'http://auth.vertesia.io/',
18
+ 'https://auth.vertesia.io:444/',
19
+ ])('rejects %s', (url) => {
20
+ expect(isTrustedAuthBrokerUrl(url)).toBe(false);
21
+ });
22
+ it('requires an explicit local development opt-in for loopback', () => {
23
+ expect(isTrustedAuthBrokerUrl('http://localhost:8080/')).toBe(false);
24
+ expect(isTrustedAuthBrokerUrl('http://localhost:8080/', { allowLoopback: true })).toBe(true);
25
+ });
26
+ });
@@ -0,0 +1,85 @@
1
+ const LOOPBACK_IPV4 = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
2
+ /** A single DNS label: alphanumeric, inner hyphens allowed. */
3
+ export const LABEL = '[a-z0-9](?:[a-z0-9-]*[a-z0-9])?';
4
+
5
+ /** The region label is optional throughout, because each regional host has a global US alias. */
6
+ export const REGION = `(?:${LABEL}\\.)?`;
7
+
8
+ /**
9
+ * Hostnames served by the UI backend — Vertesia's own code, not merely a Vertesia-owned name:
10
+ * `cloud.vertesia.io`, `cloud.us1.vertesia.io` — the Cloud UI
11
+ * `preprod.cloud.vertesia.io`, `preview.cloud.us1.vertesia.io` — its preview/preprod tiers
12
+ * `acme.cloud.us1.vertesia.io` — per-IdP-tenant branded hosts
13
+ * `dev-feat-x.ui.dev1.vertesia.io` — dynamic per-branch dev envs
14
+ *
15
+ * Mirrors the load-balancer host rules (region-lb/main.tf: `cloud.${var.domain}`,
16
+ * `*.cloud.${var.domain}`, `*.ui.${var.domain}`) rather than the DNS zone, so a name we merely own
17
+ * is not trusted for what serves it.
18
+ */
19
+ export const FIRST_PARTY_HOST_PATTERNS: readonly RegExp[] = [
20
+ new RegExp(`^cloud\\.${REGION}vertesia\\.io$`),
21
+ new RegExp(`^${LABEL}\\.cloud\\.${REGION}vertesia\\.io$`),
22
+ new RegExp(`^${LABEL}\\.ui\\.${LABEL}\\.vertesia\\.io$`),
23
+ ];
24
+
25
+ /**
26
+ * Public app-gateway origins. Stable tiers use the `apps*` browser-facing host, while development
27
+ * environments reach the gateway through its `*.api.*` Cloud Run route:
28
+ * `apps.us1.vertesia.io`, `apps-preview.us1.vertesia.io`
29
+ * `app-gateway-dev1.api.dev1.vertesia.io`, `app-gateway-us3.api.us3.vertesia.io`
30
+ *
31
+ * The second label is deliberately unconstrained. Branch environments do NOT each get a gateway —
32
+ * they share their region's (`detectGatewayUrl` in packages/server-common/src/env/ServerEnv.ts:444
33
+ * and `getAppgenServerBaseUrl` in packages/workflows/.../app_workspace/workspace-utils.ts), so the
34
+ * host published to browsers is region-scoped, while the per-branch `app-gateway-dev-<branch>`
35
+ * shape in the infrastructure README also exists. Matching `app-gateway-<anything>` covers both,
36
+ * and every Cloud Run service named that way *is* a gateway (region-compute/main.tf:373).
37
+ *
38
+ * These serve every tenant's published application from one shared origin, and published assets
39
+ * need no authentication — so this is generated, low-trust code sitting on a Vertesia-owned
40
+ * hostname. Owning the domain is not the test; which backend serves the origin is.
41
+ *
42
+ * Two load-balancer variables can map further hostnames onto the same backend —
43
+ * `appgen_host_aliases` and `appgen_preview_host_aliases` in
44
+ * infrastructure/terraform/modules/region-lb/variables.tf. Both are empty in every region today; if
45
+ * one is ever set, add the alias here too, or it silently falls through to the permissive branch of
46
+ * Policy L and becomes an origin that may receive a token at any path.
47
+ */
48
+ export const GATEWAY_HOST_PATTERNS: readonly RegExp[] = [
49
+ new RegExp(`^apps(?:-preview|-preprod)?\\.${REGION}vertesia\\.io$`),
50
+ new RegExp(`^app-gateway-${LABEL}\\.api\\.${LABEL}\\.vertesia\\.io$`),
51
+ ];
52
+
53
+ export function isLoopbackHostname(hostname: string): boolean {
54
+ if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
55
+ return true;
56
+ }
57
+ if (hostname === '::1' || hostname === '[::1]') {
58
+ return true;
59
+ }
60
+ return LOOPBACK_IPV4.test(hostname);
61
+ }
62
+
63
+ export function normalizeHostname(hostname: string): string {
64
+ return hostname.toLowerCase().replace(/\.$/, '');
65
+ }
66
+
67
+ export const AUTH_BROKER_HOST_PATTERNS: readonly RegExp[] = [new RegExp(`^auth\\.${REGION}vertesia\\.io$`)];
68
+
69
+ export function isTrustedAuthBrokerUrl(value: string, options: { allowLoopback?: boolean } = {}): boolean {
70
+ try {
71
+ const url = new URL(value);
72
+ const hostname = normalizeHostname(url.hostname);
73
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/') return false;
74
+ if (options.allowLoopback && isLoopbackHostname(hostname)) {
75
+ return url.protocol === 'http:' || url.protocol === 'https:';
76
+ }
77
+ return (
78
+ url.protocol === 'https:' &&
79
+ !url.port &&
80
+ AUTH_BROKER_HOST_PATTERNS.some((pattern) => pattern.test(hostname))
81
+ );
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
@@ -8,6 +8,7 @@ export {
8
8
  redirectToCentralAuth,
9
9
  } from './auth/domainRouting';
10
10
  export * from './auth/firebase';
11
+ export { isAppOAuthLoginPending, startAppOAuthLogin } from './auth/oauth';
11
12
  export * from './auth/useAuthState';
12
13
  export * from './auth/useCurrentTenant';
13
14
  export * from './DevSessionProvider';
@@ -1,7 +1,7 @@
1
1
  /** Developer-only visual fixtures. Mount outside the authenticated application tree. */
2
2
  import { I18nProvider, useUITranslation } from '@vertesia/ui/i18n';
3
3
  import type { ReactNode } from 'react';
4
- import type { AppBranding } from '../boot/branding.js';
4
+ import { type AppBranding, vertesiaBranding } from '../boot/branding.js';
5
5
  import {
6
6
  AppBrandingProvider,
7
7
  BrandedAuthLoadingScreen,
@@ -215,11 +215,7 @@ export function AuthScreenPreview({
215
215
  export async function mountAuthScreenPreview(
216
216
  container: HTMLElement,
217
217
  screen: string,
218
- branding: AppBranding = {
219
- name: 'Vertesia',
220
- logo: { light: '/logo-light.png', dark: '/logo-dark.png' },
221
- loadingIcon: { light: '/icon.svg' },
222
- },
218
+ branding: AppBranding = vertesiaBranding,
223
219
  screens: AuthScreens = {},
224
220
  ): Promise<void> {
225
221
  if (screen === 'boot') return;