@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.
- package/lib/boot/branding.d.ts +13 -0
- package/lib/boot/branding.d.ts.map +1 -1
- package/lib/boot/branding.js +7 -0
- package/lib/boot/branding.js.map +1 -1
- package/lib/boot/index.d.ts +1 -1
- package/lib/boot/index.d.ts.map +1 -1
- package/lib/boot/index.js +5 -23
- package/lib/boot/index.js.map +1 -1
- package/lib/boot/loading.d.ts +4 -0
- package/lib/boot/loading.d.ts.map +1 -0
- package/lib/boot/loading.js +28 -0
- package/lib/boot/loading.js.map +1 -0
- package/lib/core/components/SelectList.d.ts.map +1 -1
- package/lib/core/components/SelectList.js +3 -3
- package/lib/core/components/SelectList.js.map +1 -1
- package/lib/core/components/SidePanel.d.ts.map +1 -1
- package/lib/core/components/SidePanel.js +2 -1
- package/lib/core/components/SidePanel.js.map +1 -1
- package/lib/core/components/shadcn/theme/ThemeProvider.d.ts.map +1 -1
- package/lib/core/components/shadcn/theme/ThemeProvider.js +21 -5
- package/lib/core/components/shadcn/theme/ThemeProvider.js.map +1 -1
- package/lib/env/index.d.ts +11 -0
- package/lib/env/index.d.ts.map +1 -1
- package/lib/env/index.js +16 -3
- package/lib/env/index.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentConversation.js +4 -1
- package/lib/features/agent/chat/ModernAgentConversation.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/MessageInput.d.ts +5 -1
- package/lib/features/agent/chat/ModernAgentOutput/MessageInput.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/MessageInput.js +9 -2
- package/lib/features/agent/chat/ModernAgentOutput/MessageInput.js.map +1 -1
- package/lib/features/magic-pdf/GroundedExtractionView.js +3 -3
- package/lib/features/magic-pdf/GroundedExtractionView.js.map +1 -1
- package/lib/features/media-viewer/AudioPanel.d.ts +10 -2
- package/lib/features/media-viewer/AudioPanel.d.ts.map +1 -1
- package/lib/features/media-viewer/AudioPanel.js +78 -13
- package/lib/features/media-viewer/AudioPanel.js.map +1 -1
- package/lib/features/oauth/McpConnectionsButton.d.ts +2 -2
- package/lib/features/oauth/McpConnectionsButton.d.ts.map +1 -1
- package/lib/features/oauth/McpConnectionsButton.js +9 -7
- package/lib/features/oauth/McpConnectionsButton.js.map +1 -1
- package/lib/features/oauth/McpConnectionsDialog.d.ts.map +1 -1
- package/lib/features/oauth/McpConnectionsDialog.js +7 -3
- package/lib/features/oauth/McpConnectionsDialog.js.map +1 -1
- package/lib/features/oauth/useMcpConnections.d.ts +4 -2
- package/lib/features/oauth/useMcpConnections.d.ts.map +1 -1
- package/lib/features/oauth/useMcpConnections.js +15 -9
- package/lib/features/oauth/useMcpConnections.js.map +1 -1
- package/lib/features/store/objects/selection/ObjectsActionContext.d.ts.map +1 -1
- package/lib/features/store/objects/selection/ObjectsActionContext.js +2 -0
- package/lib/features/store/objects/selection/ObjectsActionContext.js.map +1 -1
- package/lib/features/store/objects/selection/SelectionActions.d.ts +4 -2
- package/lib/features/store/objects/selection/SelectionActions.d.ts.map +1 -1
- package/lib/features/store/objects/selection/SelectionActions.js +6 -3
- package/lib/features/store/objects/selection/SelectionActions.js.map +1 -1
- package/lib/features/store/objects/selection/actions/StartWorkflowAction.d.ts +4 -0
- package/lib/features/store/objects/selection/actions/StartWorkflowAction.d.ts.map +1 -0
- package/lib/features/store/objects/selection/actions/StartWorkflowAction.js +81 -0
- package/lib/features/store/objects/selection/actions/StartWorkflowAction.js.map +1 -0
- package/lib/features/store/objects/selection/actions/index.d.ts +1 -0
- package/lib/features/store/objects/selection/actions/index.d.ts.map +1 -1
- package/lib/features/store/objects/selection/actions/index.js +1 -0
- package/lib/features/store/objects/selection/actions/index.js.map +1 -1
- package/lib/features/store/types/TypeRegistry.d.ts +345 -155
- package/lib/features/store/types/TypeRegistry.d.ts.map +1 -1
- package/lib/features/store/types/search/ObjectTypeSearchContext.d.ts +345 -155
- package/lib/features/store/types/search/ObjectTypeSearchContext.d.ts.map +1 -1
- package/lib/i18n/locales/ar.json +10 -2
- package/lib/i18n/locales/de.json +6 -2
- package/lib/i18n/locales/en.json +6 -2
- package/lib/i18n/locales/es.json +6 -2
- package/lib/i18n/locales/fr.json +7 -2
- package/lib/i18n/locales/it.json +6 -2
- package/lib/i18n/locales/ja.json +6 -2
- package/lib/i18n/locales/ko.json +6 -2
- package/lib/i18n/locales/pt.json +6 -2
- package/lib/i18n/locales/ru.json +8 -2
- package/lib/i18n/locales/tr.json +6 -2
- package/lib/i18n/locales/zh-TW.json +6 -2
- package/lib/i18n/locales/zh.json +6 -2
- package/lib/session/UserSession.d.ts +3 -0
- package/lib/session/UserSession.d.ts.map +1 -1
- package/lib/session/UserSession.js +18 -8
- package/lib/session/UserSession.js.map +1 -1
- package/lib/session/UserSessionProvider.d.ts.map +1 -1
- package/lib/session/UserSessionProvider.js +34 -24
- package/lib/session/UserSessionProvider.js.map +1 -1
- package/lib/session/auth/authRoundTrip.d.ts +8 -0
- package/lib/session/auth/authRoundTrip.d.ts.map +1 -0
- package/lib/session/auth/authRoundTrip.js +22 -0
- package/lib/session/auth/authRoundTrip.js.map +1 -0
- package/lib/session/auth/composable.d.ts.map +1 -1
- package/lib/session/auth/composable.js +9 -7
- package/lib/session/auth/composable.js.map +1 -1
- package/lib/session/auth/domainRouting.d.ts +13 -6
- package/lib/session/auth/domainRouting.d.ts.map +1 -1
- package/lib/session/auth/domainRouting.js +2 -21
- package/lib/session/auth/domainRouting.js.map +1 -1
- package/lib/session/auth/oauth.d.ts +6 -1
- package/lib/session/auth/oauth.d.ts.map +1 -1
- package/lib/session/auth/oauth.js +145 -8
- package/lib/session/auth/oauth.js.map +1 -1
- package/lib/session/auth/tokenIssuer.d.ts +3 -0
- package/lib/session/auth/tokenIssuer.d.ts.map +1 -0
- package/lib/session/auth/tokenIssuer.js +27 -0
- package/lib/session/auth/tokenIssuer.js.map +1 -0
- package/lib/session/auth/vertesiaHosts.d.ts +47 -0
- package/lib/session/auth/vertesiaHosts.d.ts.map +1 -0
- package/lib/session/auth/vertesiaHosts.js +79 -0
- package/lib/session/auth/vertesiaHosts.js.map +1 -0
- package/lib/session/index.d.ts +1 -0
- package/lib/session/index.d.ts.map +1 -1
- package/lib/session/index.js +1 -0
- package/lib/session/index.js.map +1 -1
- package/lib/shell/AuthScreenPreview.d.ts +1 -1
- package/lib/shell/AuthScreenPreview.d.ts.map +1 -1
- package/lib/shell/AuthScreenPreview.js +2 -5
- package/lib/shell/AuthScreenPreview.js.map +1 -1
- package/lib/shell/BrandedAuthScreens.d.ts +1 -4
- package/lib/shell/BrandedAuthScreens.d.ts.map +1 -1
- package/lib/shell/BrandedAuthScreens.js +5 -26
- package/lib/shell/BrandedAuthScreens.js.map +1 -1
- package/lib/shell/BrandedLoadingIndicator.d.ts +11 -0
- package/lib/shell/BrandedLoadingIndicator.d.ts.map +1 -0
- package/lib/shell/BrandedLoadingIndicator.js +27 -0
- package/lib/shell/BrandedLoadingIndicator.js.map +1 -0
- package/lib/shell/SplashScreen.d.ts.map +1 -1
- package/lib/shell/SplashScreen.js +2 -1
- package/lib/shell/SplashScreen.js.map +1 -1
- package/lib/shell/apps/AppInstallationProvider.d.ts +16 -4
- package/lib/shell/apps/AppInstallationProvider.d.ts.map +1 -1
- package/lib/shell/apps/iframe-auth.d.ts +2 -0
- package/lib/shell/apps/iframe-auth.d.ts.map +1 -1
- package/lib/shell/apps/iframe-auth.js +6 -2
- package/lib/shell/apps/iframe-auth.js.map +1 -1
- package/lib/shell/index.d.ts +1 -0
- package/lib/shell/index.d.ts.map +1 -1
- package/lib/shell/index.js +1 -0
- package/lib/shell/index.js.map +1 -1
- package/lib/shell/login/SignInAuthPending.js +2 -2
- package/lib/shell/login/SignInAuthPending.js.map +1 -1
- package/lib/shell/login/SignInEmailStep.d.ts.map +1 -1
- package/lib/shell/login/SignInEmailStep.js +2 -2
- package/lib/shell/login/SignInEmailStep.js.map +1 -1
- package/lib/shell/login/TerminalLogin.d.ts.map +1 -1
- package/lib/shell/login/TerminalLogin.js +28 -21
- package/lib/shell/login/TerminalLogin.js.map +1 -1
- package/lib/vertesia-ui-boot.js +33 -29
- package/lib/vertesia-ui-boot.js.map +1 -1
- package/lib/vertesia-ui-core.js +34 -34
- package/lib/vertesia-ui-core.js.map +1 -1
- package/lib/vertesia-ui-env.js +1 -1
- package/lib/vertesia-ui-env.js.map +1 -1
- package/lib/vertesia-ui-features.js +163 -163
- package/lib/vertesia-ui-features.js.map +1 -1
- package/lib/vertesia-ui-i18n.Bl8KeZUK.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.Bl8KeZUK.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.BmT06XFI.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.BmT06XFI.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.Ch-Jjj0L.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.Ch-Jjj0L.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.CjhloW9s.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.CjhloW9s.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.CkfHdQzY.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.CkfHdQzY.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.DFuKfTe1.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.DFuKfTe1.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.DHqNVekZ.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.DHqNVekZ.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.DOaKbmaj.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.DOaKbmaj.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.Dge3Gl-h.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.Dge3Gl-h.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.GLt-10Lx.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.GLt-10Lx.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.T-XtAGbw.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.T-XtAGbw.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.VOJyTpMM.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.VOJyTpMM.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.js +3 -3
- package/lib/vertesia-ui-i18n.js.map +1 -1
- package/lib/vertesia-ui-layout.js +15 -15
- package/lib/vertesia-ui-layout.js.map +1 -1
- package/lib/vertesia-ui-rich-text.js +44 -44
- package/lib/vertesia-ui-rich-text.js.map +1 -1
- package/lib/vertesia-ui-session.js +1 -1
- package/lib/vertesia-ui-session.js.map +1 -1
- package/lib/vertesia-ui-shell.js +72 -48
- package/lib/vertesia-ui-shell.js.map +1 -1
- package/lib/vertesia-ui-widgets.js +51 -51
- package/lib/vertesia-ui-widgets.js.map +1 -1
- package/package.json +19 -19
- package/src/boot/branding.ts +8 -0
- package/src/boot/index.ts +6 -23
- package/src/boot/loading.ts +27 -0
- package/src/core/components/SelectList.tsx +5 -2
- package/src/core/components/SidePanel.tsx +65 -60
- package/src/core/components/shadcn/theme/ThemeProvider.test.tsx +101 -0
- package/src/core/components/shadcn/theme/ThemeProvider.tsx +21 -9
- package/src/env/index.test.ts +21 -0
- package/src/env/index.ts +33 -2
- package/src/features/agent/chat/ModernAgentConversation.test.tsx +81 -0
- package/src/features/agent/chat/ModernAgentConversation.tsx +5 -0
- package/src/features/agent/chat/ModernAgentOutput/MessageInput.tsx +17 -1
- package/src/features/magic-pdf/GroundedExtractionView.tsx +3 -3
- package/src/features/media-viewer/AudioPanel.test.ts +84 -0
- package/src/features/media-viewer/AudioPanel.tsx +97 -14
- package/src/features/oauth/McpConnectionsButton.test.tsx +56 -0
- package/src/features/oauth/McpConnectionsButton.tsx +9 -6
- package/src/features/oauth/McpConnectionsDialog.tsx +14 -5
- package/src/features/oauth/useMcpConnections.test.ts +10 -0
- package/src/features/oauth/useMcpConnections.ts +18 -10
- package/src/features/store/objects/selection/ObjectsActionContext.tsx +2 -0
- package/src/features/store/objects/selection/SelectionActions.tsx +13 -6
- package/src/features/store/objects/selection/actions/StartWorkflowAction.tsx +162 -0
- package/src/features/store/objects/selection/actions/index.ts +1 -0
- package/src/i18n/locales/ar.json +10 -2
- package/src/i18n/locales/de.json +6 -2
- package/src/i18n/locales/en.json +6 -2
- package/src/i18n/locales/es.json +6 -2
- package/src/i18n/locales/fr.json +7 -2
- package/src/i18n/locales/it.json +6 -2
- package/src/i18n/locales/ja.json +6 -2
- package/src/i18n/locales/ko.json +6 -2
- package/src/i18n/locales/pt.json +6 -2
- package/src/i18n/locales/ru.json +8 -2
- package/src/i18n/locales/tr.json +6 -2
- package/src/i18n/locales/zh-TW.json +6 -2
- package/src/i18n/locales/zh.json +6 -2
- package/src/session/UserSession.test.ts +48 -3
- package/src/session/UserSession.ts +20 -8
- package/src/session/UserSessionProvider.handoff.test.tsx +103 -0
- package/src/session/UserSessionProvider.iframe.test.tsx +178 -0
- package/src/session/UserSessionProvider.state-mismatch.test.tsx +43 -0
- package/src/session/UserSessionProvider.tsx +35 -23
- package/src/session/auth/auth-flow.md +18 -5
- package/src/session/auth/authRoundTrip.ts +24 -0
- package/src/session/auth/composable.test.ts +23 -1
- package/src/session/auth/composable.ts +10 -8
- package/src/session/auth/domainRouting.ts +4 -25
- package/src/session/auth/oauth.test.ts +121 -4
- package/src/session/auth/oauth.ts +172 -11
- package/src/session/auth/tokenIssuer.test.ts +25 -0
- package/src/session/auth/tokenIssuer.ts +25 -0
- package/src/session/auth/vertesiaHosts.test.ts +26 -0
- package/src/session/auth/vertesiaHosts.ts +85 -0
- package/src/session/index.ts +1 -0
- package/src/shell/AuthScreenPreview.tsx +2 -6
- package/src/shell/BrandedAuthScreens.test.tsx +26 -0
- package/src/shell/BrandedAuthScreens.tsx +4 -40
- package/src/shell/BrandedLoadingIndicator.tsx +42 -0
- package/src/shell/SplashScreen.tsx +4 -4
- package/src/shell/apps/iframe-auth.test.ts +8 -11
- package/src/shell/apps/iframe-auth.ts +10 -1
- package/src/shell/index.tsx +1 -0
- package/src/shell/login/SignInAuthPending.tsx +2 -2
- package/src/shell/login/SignInEmailStep.tsx +4 -2
- package/src/shell/login/TerminalLogin.test.tsx +111 -0
- package/src/shell/login/TerminalLogin.tsx +61 -37
- package/lib/features/store/objects/selection/actions/StartWorkflowButton.d.ts +0 -7
- package/lib/features/store/objects/selection/actions/StartWorkflowButton.d.ts.map +0 -1
- package/lib/features/store/objects/selection/actions/StartWorkflowButton.js +0 -51
- package/lib/features/store/objects/selection/actions/StartWorkflowButton.js.map +0 -1
- package/lib/vertesia-ui-i18n.B3NFclwl.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.B3NFclwl.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.BWSrNIxc.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.BWSrNIxc.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.BWvWeB1p.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.BWvWeB1p.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.BZCXvTC1.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.BZCXvTC1.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.BbJMvFZW.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.BbJMvFZW.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.CE5ldwpt.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.CE5ldwpt.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.CMC0IShY.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.CMC0IShY.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.CkcRqBKU.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.CkcRqBKU.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.D-Mq1sw_.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.D-Mq1sw_.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.DkK68-A-.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.DkK68-A-.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.XE_dcCg6.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.XE_dcCg6.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.qgNqMPvK.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.qgNqMPvK.chunk.js.map +0 -1
- 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:
|
|
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 ===
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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: {
|
|
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(
|
|
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
|
-
|
|
163
|
-
|
|
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
|
+
}
|
package/src/session/index.ts
CHANGED
|
@@ -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
|
|
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;
|