@vertesia/ui 1.6.0-dev.20260918.061604Z → 1.6.0-dev.20260918.090710Z
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/env/index.d.ts +21 -7
- package/lib/env/index.d.ts.map +1 -1
- package/lib/env/index.js +7 -0
- package/lib/env/index.js.map +1 -1
- package/lib/session/UserSession.d.ts +1 -0
- package/lib/session/UserSession.d.ts.map +1 -1
- package/lib/session/UserSession.js +57 -2
- package/lib/session/UserSession.js.map +1 -1
- package/lib/session/UserSessionProvider.d.ts.map +1 -1
- package/lib/session/UserSessionProvider.js +25 -0
- package/lib/session/UserSessionProvider.js.map +1 -1
- package/lib/session/auth/domainRouting.d.ts +3 -4
- package/lib/session/auth/domainRouting.d.ts.map +1 -1
- package/lib/session/auth/domainRouting.js +15 -4
- package/lib/session/auth/domainRouting.js.map +1 -1
- package/lib/session/auth/gateway.d.ts +8 -0
- package/lib/session/auth/gateway.d.ts.map +1 -0
- package/lib/session/auth/gateway.js +47 -0
- package/lib/session/auth/gateway.js.map +1 -0
- package/lib/session/auth/oauth.d.ts +4 -0
- package/lib/session/auth/oauth.d.ts.map +1 -0
- package/lib/session/auth/oauth.js +167 -0
- package/lib/session/auth/oauth.js.map +1 -0
- package/lib/vertesia-ui-env.js +1 -1
- package/lib/vertesia-ui-env.js.map +1 -1
- package/lib/vertesia-ui-session.js +1 -1
- package/lib/vertesia-ui-session.js.map +1 -1
- package/package.json +7 -7
- package/src/env/index.test.ts +21 -0
- package/src/env/index.ts +18 -5
- package/src/session/UserSession.ts +58 -2
- package/src/session/UserSessionProvider.strict-mode.test.tsx +36 -0
- package/src/session/UserSessionProvider.tsx +24 -0
- package/src/session/auth/auth-flow.md +21 -9
- package/src/session/auth/domainRouting.test.ts +1 -1
- package/src/session/auth/domainRouting.ts +14 -4
- package/src/session/auth/gateway.test.ts +82 -0
- package/src/session/auth/gateway.ts +49 -0
- package/src/session/auth/oauth.test.ts +173 -0
- package/src/session/auth/oauth.ts +176 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const origin = 'https://standalone.vercel.app';
|
|
5
|
+
const issuer = 'https://sts.dev1.vertesia.io';
|
|
6
|
+
const clientId = `${origin}/.well-known/oauth-client/vertesia-app`;
|
|
7
|
+
const storage = new Map<string, string>();
|
|
8
|
+
const replace = vi.fn();
|
|
9
|
+
let browser: {
|
|
10
|
+
location: { href: string; origin: string; replace: typeof replace };
|
|
11
|
+
parent?: unknown;
|
|
12
|
+
history: { state: null; replaceState: ReturnType<typeof vi.fn> };
|
|
13
|
+
};
|
|
14
|
+
let requests: { url: string; init?: RequestInit }[];
|
|
15
|
+
function jwt(): string {
|
|
16
|
+
return `e30.${btoa(
|
|
17
|
+
JSON.stringify({
|
|
18
|
+
iss: issuer,
|
|
19
|
+
client_id: clientId,
|
|
20
|
+
exp: Date.now() / 1000 + 3600,
|
|
21
|
+
sub: 'user',
|
|
22
|
+
account: { id: 'a' },
|
|
23
|
+
project: { id: 'p' },
|
|
24
|
+
}),
|
|
25
|
+
)}.signature`;
|
|
26
|
+
}
|
|
27
|
+
async function setup() {
|
|
28
|
+
vi.resetModules();
|
|
29
|
+
const { Env } = await import('../../env');
|
|
30
|
+
Env.init({
|
|
31
|
+
name: 'test',
|
|
32
|
+
version: '1',
|
|
33
|
+
type: 'production',
|
|
34
|
+
isLocalDev: false,
|
|
35
|
+
isDocker: false,
|
|
36
|
+
endpoints: { studio: 'https://api.dev1.vertesia.io', zeno: 'https://api.dev1.vertesia.io', sts: issuer },
|
|
37
|
+
oauth: { clientId, redirectUri: `${origin}/app` },
|
|
38
|
+
});
|
|
39
|
+
return import('./oauth');
|
|
40
|
+
}
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
storage.clear();
|
|
43
|
+
requests = [];
|
|
44
|
+
browser = {
|
|
45
|
+
location: { href: `${origin}/app/report?a=a&p=p#chart`, origin, replace },
|
|
46
|
+
history: {
|
|
47
|
+
state: null,
|
|
48
|
+
replaceState: vi.fn((_state, _title, target: URL) => {
|
|
49
|
+
browser.location.href = target.toString();
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
browser.parent = browser;
|
|
54
|
+
vi.stubGlobal('window', browser);
|
|
55
|
+
vi.stubGlobal('sessionStorage', {
|
|
56
|
+
getItem: (key: string) => storage.get(key) ?? null,
|
|
57
|
+
setItem: (key: string, value: string) => storage.set(key, value),
|
|
58
|
+
removeItem: (key: string) => storage.delete(key),
|
|
59
|
+
});
|
|
60
|
+
vi.stubGlobal(
|
|
61
|
+
'fetch',
|
|
62
|
+
vi.fn(async (input: string | URL, init?: RequestInit) => {
|
|
63
|
+
const url = String(input);
|
|
64
|
+
requests.push({ url, init });
|
|
65
|
+
if (url.endsWith('/.well-known/oauth-authorization-server'))
|
|
66
|
+
return Response.json({
|
|
67
|
+
issuer,
|
|
68
|
+
authorization_endpoint: 'https://preview.cloud.dev1.vertesia.io/oauth/authorize',
|
|
69
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
70
|
+
});
|
|
71
|
+
if (url === clientId)
|
|
72
|
+
return Response.json({
|
|
73
|
+
redirect_uris: [`${origin}/app`],
|
|
74
|
+
scope: 'openid profile content:read offline_access',
|
|
75
|
+
});
|
|
76
|
+
if (url === `${issuer}/oauth/token`) return Response.json({ access_token: jwt(), token_type: 'Bearer' });
|
|
77
|
+
throw new Error(`Unexpected request: ${url}`);
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.unstubAllGlobals();
|
|
83
|
+
vi.clearAllMocks();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('uses CIMD and PKCE, exchanges the callback once and restores the app deep link', async () => {
|
|
87
|
+
const oauth = await setup();
|
|
88
|
+
expect(oauth.usesAppOAuth()).toBe(true);
|
|
89
|
+
void oauth.getAppOAuthToken();
|
|
90
|
+
await vi.waitFor(() => expect(replace).toHaveBeenCalledTimes(1));
|
|
91
|
+
const authorize = new URL(replace.mock.calls[0][0]);
|
|
92
|
+
expect(authorize.searchParams.get('client_id')).toBe(clientId);
|
|
93
|
+
expect(authorize.searchParams.get('redirect_uri')).toBe(`${origin}/app`);
|
|
94
|
+
expect(authorize.searchParams.get('scope')).toBe('openid profile content:read');
|
|
95
|
+
expect(authorize.searchParams.get('project_id')).toBe('p');
|
|
96
|
+
expect(authorize.searchParams.get('code_challenge_method')).toBe('S256');
|
|
97
|
+
const txn = JSON.parse(storage.get('vertesia.oauth.transaction') ?? '{}');
|
|
98
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(txn.verifier));
|
|
99
|
+
expect(authorize.searchParams.get('code_challenge')).toBe(Buffer.from(digest).toString('base64url'));
|
|
100
|
+
browser.location.href = `${origin}/app?code=one-time-code&state=${authorize.searchParams.get('state')}`;
|
|
101
|
+
const callback = await setup();
|
|
102
|
+
const token = await callback.getAppOAuthToken();
|
|
103
|
+
expect(token).toBeTruthy();
|
|
104
|
+
const exchange = requests.find((request) => request.url.endsWith('/oauth/token'));
|
|
105
|
+
if (!exchange) throw new Error('Expected a code exchange');
|
|
106
|
+
const body = exchange.init?.body as URLSearchParams;
|
|
107
|
+
expect(body.get('code_verifier')).toBe(txn.verifier);
|
|
108
|
+
expect(body.get('grant_type')).toBe('authorization_code');
|
|
109
|
+
expect(body.has('client_secret')).toBe(false);
|
|
110
|
+
expect(storage.has('vertesia.oauth.transaction')).toBe(false);
|
|
111
|
+
expect(browser.location.href).toBe(`${origin}/app/report?a=a&p=p#chart`);
|
|
112
|
+
expect(await callback.getAppOAuthToken()).toBe(token);
|
|
113
|
+
expect(requests.filter((request) => request.url.endsWith('/oauth/token'))).toHaveLength(1);
|
|
114
|
+
});
|
|
115
|
+
it('rejects a forged callback before contacting the token endpoint', async () => {
|
|
116
|
+
browser.location.href = `${origin}/app?code=forged&state=wrong`;
|
|
117
|
+
const oauth = await setup();
|
|
118
|
+
await expect(oauth.getAppOAuthToken()).rejects.toThrow('Invalid or expired');
|
|
119
|
+
expect(requests).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
it('rejects discovery for a different issuer', async () => {
|
|
122
|
+
vi.stubGlobal(
|
|
123
|
+
'fetch',
|
|
124
|
+
vi.fn(async () => Response.json({ issuer: 'https://wrong.test' })),
|
|
125
|
+
);
|
|
126
|
+
const oauth = await setup();
|
|
127
|
+
await expect(oauth.getAppOAuthToken()).rejects.toThrow('issuer mismatch');
|
|
128
|
+
expect(replace).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
it('does not override Firebase, embedded apps, gateway sessions, or unconfigured local apps', async () => {
|
|
131
|
+
const oauth = await setup();
|
|
132
|
+
const win = window as unknown as Record<string, unknown>;
|
|
133
|
+
win.AUTH_MODE = 'firebase';
|
|
134
|
+
expect(oauth.usesAppOAuth()).toBe(false);
|
|
135
|
+
win.AUTH_MODE = 'central';
|
|
136
|
+
win.parent = {};
|
|
137
|
+
expect(oauth.usesAppOAuth()).toBe(false);
|
|
138
|
+
win.parent = window;
|
|
139
|
+
win.__VERTESIA_RUNTIME_CONFIG__ = { authMode: 'central', gatewaySession: true };
|
|
140
|
+
expect(oauth.usesAppOAuth()).toBe(false);
|
|
141
|
+
win.__VERTESIA_RUNTIME_CONFIG__ = undefined;
|
|
142
|
+
const { Env } = await import('../../env');
|
|
143
|
+
Env.init({
|
|
144
|
+
name: 'local',
|
|
145
|
+
version: '1',
|
|
146
|
+
type: 'development',
|
|
147
|
+
isLocalDev: true,
|
|
148
|
+
isDocker: false,
|
|
149
|
+
endpoints: { studio: issuer, zeno: issuer, sts: issuer },
|
|
150
|
+
});
|
|
151
|
+
expect(oauth.usesAppOAuth()).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('supports a registered client ID without fetching a CIMD', async () => {
|
|
155
|
+
const oauth = await setup();
|
|
156
|
+
const { Env } = await import('../../env');
|
|
157
|
+
Env.init({
|
|
158
|
+
name: 'registered',
|
|
159
|
+
version: '1',
|
|
160
|
+
type: 'production',
|
|
161
|
+
isLocalDev: false,
|
|
162
|
+
isDocker: false,
|
|
163
|
+
endpoints: { studio: issuer, zeno: issuer, sts: issuer },
|
|
164
|
+
oauth: { clientId: 'registered-client-id', redirectUri: `${origin}/app`, scopes: ['openid', 'content:read'] },
|
|
165
|
+
});
|
|
166
|
+
void oauth.getAppOAuthToken();
|
|
167
|
+
await vi.waitFor(() => expect(replace).toHaveBeenCalledTimes(1));
|
|
168
|
+
const authorize = new URL(replace.mock.calls[0][0]);
|
|
169
|
+
expect(authorize.searchParams.get('client_id')).toBe('registered-client-id');
|
|
170
|
+
expect(authorize.searchParams.get('scope')).toBe('openid content:read');
|
|
171
|
+
expect(authorize.searchParams.has('consent_required')).toBe(false);
|
|
172
|
+
expect(requests).toHaveLength(1);
|
|
173
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { AuthTokenPayload } from '@vertesia/common';
|
|
2
|
+
import { Env } from '@vertesia/ui/env';
|
|
3
|
+
import { jwtDecode } from 'jwt-decode';
|
|
4
|
+
import { usesGatewaySession } from './gateway';
|
|
5
|
+
|
|
6
|
+
const TRANSACTION_KEY = 'vertesia.oauth.transaction';
|
|
7
|
+
const TOKEN_KEY = 'vertesia.oauth.access';
|
|
8
|
+
const MAX_TRANSACTION_AGE = 10 * 60_000;
|
|
9
|
+
let pending: Promise<string> | undefined;
|
|
10
|
+
|
|
11
|
+
export function usesAppOAuth(): boolean {
|
|
12
|
+
return Boolean(Env.oauth && window.parent === window && window.AUTH_MODE !== 'firebase' && !usesGatewaySession());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function base64url(bytes: Uint8Array): string {
|
|
16
|
+
return btoa(String.fromCharCode(...bytes))
|
|
17
|
+
.replace(/\+/g, '-')
|
|
18
|
+
.replace(/\//g, '_')
|
|
19
|
+
.replace(/=+$/, '');
|
|
20
|
+
}
|
|
21
|
+
function randomValue(): string {
|
|
22
|
+
return base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
23
|
+
}
|
|
24
|
+
function httpsUrl(value: string): URL {
|
|
25
|
+
const url = new URL(value);
|
|
26
|
+
if (url.protocol !== 'https:' || url.username || url.password) throw new Error('OAuth endpoint must use HTTPS');
|
|
27
|
+
return url;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Transaction {
|
|
31
|
+
state: string;
|
|
32
|
+
verifier: string;
|
|
33
|
+
created: number;
|
|
34
|
+
clientId: string;
|
|
35
|
+
redirectUri: string;
|
|
36
|
+
issuer: string;
|
|
37
|
+
target: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function clearAppOAuth(): void {
|
|
41
|
+
sessionStorage.removeItem(TOKEN_KEY);
|
|
42
|
+
sessionStorage.removeItem(TRANSACTION_KEY);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Public-client PKCE for independently hosted apps. No Firebase identity token or client secret. */
|
|
46
|
+
async function acquireToken(): Promise<string> {
|
|
47
|
+
const config = Env.oauth;
|
|
48
|
+
if (!config) throw new Error('Application OAuth is not configured');
|
|
49
|
+
const clientId = config.clientId.trim();
|
|
50
|
+
if (!clientId) throw new Error('OAuth client ID is required');
|
|
51
|
+
const redirect = new URL(config.redirectUri);
|
|
52
|
+
const loopback =
|
|
53
|
+
redirect.hostname === 'localhost' ||
|
|
54
|
+
redirect.hostname.endsWith('.localhost') ||
|
|
55
|
+
redirect.hostname === '[::1]' ||
|
|
56
|
+
/^127\.\d+\.\d+\.\d+$/.test(redirect.hostname);
|
|
57
|
+
if (redirect.protocol !== 'https:' && !(loopback && redirect.protocol === 'http:')) {
|
|
58
|
+
throw new Error('OAuth callback must use HTTPS or loopback HTTP');
|
|
59
|
+
}
|
|
60
|
+
const redirectUri = redirect.toString();
|
|
61
|
+
if (new URL(redirectUri).origin !== window.location.origin) throw new Error('OAuth callback must be same-origin');
|
|
62
|
+
const issuer = Env.endpoints.sts.replace(/\/+$/, '');
|
|
63
|
+
const current = new URL(window.location.href);
|
|
64
|
+
const callback = current.origin + current.pathname === new URL(redirectUri).origin + new URL(redirectUri).pathname;
|
|
65
|
+
const state = callback ? current.searchParams.get('state') : null;
|
|
66
|
+
const code = callback ? current.searchParams.get('code') : null;
|
|
67
|
+
const error = callback ? current.searchParams.get('error') : null;
|
|
68
|
+
let transaction: Transaction | undefined;
|
|
69
|
+
if (state || code || error) {
|
|
70
|
+
const raw = sessionStorage.getItem(TRANSACTION_KEY);
|
|
71
|
+
sessionStorage.removeItem(TRANSACTION_KEY);
|
|
72
|
+
transaction = raw ? (JSON.parse(raw) as Transaction) : undefined;
|
|
73
|
+
if (
|
|
74
|
+
!transaction ||
|
|
75
|
+
transaction.state !== state ||
|
|
76
|
+
transaction.clientId !== clientId ||
|
|
77
|
+
transaction.redirectUri !== redirectUri ||
|
|
78
|
+
transaction.issuer !== issuer ||
|
|
79
|
+
Date.now() - transaction.created > MAX_TRANSACTION_AGE ||
|
|
80
|
+
transaction.created > Date.now()
|
|
81
|
+
) {
|
|
82
|
+
throw new Error('Invalid or expired OAuth login state');
|
|
83
|
+
}
|
|
84
|
+
if (error || !code) throw new Error(`Application sign-in was not completed (${error || 'missing code'})`);
|
|
85
|
+
} else {
|
|
86
|
+
const raw = sessionStorage.getItem(TOKEN_KEY);
|
|
87
|
+
if (raw) {
|
|
88
|
+
const cached = JSON.parse(raw) as { token: string; clientId: string; issuer: string };
|
|
89
|
+
const claims = jwtDecode<AuthTokenPayload>(cached.token);
|
|
90
|
+
if (
|
|
91
|
+
cached.clientId === clientId &&
|
|
92
|
+
cached.issuer === issuer &&
|
|
93
|
+
claims.exp > Date.now() / 1000 + 30 &&
|
|
94
|
+
(!current.searchParams.get('p') || claims.project?.id === current.searchParams.get('p')) &&
|
|
95
|
+
(!current.searchParams.get('a') || claims.account?.id === current.searchParams.get('a'))
|
|
96
|
+
) {
|
|
97
|
+
return cached.token;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const discovery = await fetch(`${issuer}/.well-known/oauth-authorization-server`);
|
|
102
|
+
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();
|
|
104
|
+
if (metadata.issuer.replace(/\/+$/, '') !== issuer) throw new Error('OAuth issuer mismatch');
|
|
105
|
+
const authorize = httpsUrl(metadata.authorization_endpoint);
|
|
106
|
+
const tokenEndpoint = httpsUrl(metadata.token_endpoint);
|
|
107
|
+
if (transaction && code) {
|
|
108
|
+
const response = await fetch(tokenEndpoint, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
111
|
+
body: new URLSearchParams({
|
|
112
|
+
grant_type: 'authorization_code',
|
|
113
|
+
code,
|
|
114
|
+
client_id: clientId,
|
|
115
|
+
redirect_uri: redirectUri,
|
|
116
|
+
code_verifier: transaction.verifier,
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
if (!response.ok) throw new Error(`OAuth code exchange failed (${response.status})`);
|
|
120
|
+
const grant: { access_token?: string; token_type?: string } = await response.json();
|
|
121
|
+
if (!grant.access_token || grant.token_type?.toLowerCase() !== 'bearer') throw new Error('Invalid OAuth grant');
|
|
122
|
+
const claims = jwtDecode<AuthTokenPayload & { client_id?: string }>(grant.access_token);
|
|
123
|
+
if (
|
|
124
|
+
claims.iss.replace(/\/+$/, '') !== issuer ||
|
|
125
|
+
claims.client_id !== clientId ||
|
|
126
|
+
claims.exp <= Date.now() / 1000
|
|
127
|
+
) {
|
|
128
|
+
throw new Error('OAuth grant does not match this application');
|
|
129
|
+
}
|
|
130
|
+
sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ token: grant.access_token, clientId, issuer }));
|
|
131
|
+
const target = new URL(transaction.target);
|
|
132
|
+
if (target.origin !== window.location.origin) throw new Error('Invalid OAuth return target');
|
|
133
|
+
window.history.replaceState(window.history.state, '', target);
|
|
134
|
+
return grant.access_token;
|
|
135
|
+
}
|
|
136
|
+
let scopes = config.scopes ?? ['openid', 'profile'];
|
|
137
|
+
if (clientId.startsWith('https://')) {
|
|
138
|
+
const documentResponse = await fetch(httpsUrl(clientId), { cache: 'no-store' });
|
|
139
|
+
if (!documentResponse.ok) throw new Error(`Application CIMD unavailable (${documentResponse.status})`);
|
|
140
|
+
const document: { scope?: string; redirect_uris?: string[] } = await documentResponse.json();
|
|
141
|
+
if (!document.redirect_uris?.includes(redirectUri))
|
|
142
|
+
throw new Error('Application CIMD does not allow this callback');
|
|
143
|
+
if (!config.scopes && document.scope) scopes = document.scope.split(/\s+/);
|
|
144
|
+
}
|
|
145
|
+
const verifier = randomValue();
|
|
146
|
+
const next: Transaction = {
|
|
147
|
+
state: randomValue(),
|
|
148
|
+
verifier,
|
|
149
|
+
clientId,
|
|
150
|
+
redirectUri,
|
|
151
|
+
issuer,
|
|
152
|
+
target: current.toString(),
|
|
153
|
+
created: Date.now(),
|
|
154
|
+
};
|
|
155
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
156
|
+
authorize.searchParams.set('client_id', clientId);
|
|
157
|
+
authorize.searchParams.set('redirect_uri', redirectUri);
|
|
158
|
+
authorize.searchParams.set('response_type', 'code');
|
|
159
|
+
authorize.searchParams.set('state', next.state);
|
|
160
|
+
authorize.searchParams.set('code_challenge_method', 'S256');
|
|
161
|
+
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(' '));
|
|
164
|
+
const project = current.searchParams.get('p');
|
|
165
|
+
if (project) authorize.searchParams.set('project_id', project);
|
|
166
|
+
sessionStorage.setItem(TRANSACTION_KEY, JSON.stringify(next));
|
|
167
|
+
window.location.replace(authorize.toString());
|
|
168
|
+
return new Promise(() => {});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function getAppOAuthToken(): Promise<string> {
|
|
172
|
+
pending ??= acquireToken().finally(() => {
|
|
173
|
+
pending = undefined;
|
|
174
|
+
});
|
|
175
|
+
return pending;
|
|
176
|
+
}
|