@oxyhq/core 3.8.2 → 3.9.0
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/README.md +10 -0
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/OxyServices.base.js +15 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/OxyServices.base.js +15 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.base.d.ts +12 -0
- package/package.json +1 -1
- package/src/OxyServices.base.ts +15 -1
- package/src/__tests__/linkedClient.test.ts +61 -0
- package/dist/cjs/mixins/OxyServices.popup.js +0 -263
- package/dist/esm/mixins/OxyServices.popup.js +0 -261
- package/dist/types/mixins/OxyServices.popup.d.ts +0 -170
|
@@ -52,6 +52,18 @@ export declare class OxyServicesBase {
|
|
|
52
52
|
* OxyServices instance mounted in OxyProvider. The returned client has its own
|
|
53
53
|
* base URL, cache and request queue, but its bearer token is kept in lockstep
|
|
54
54
|
* with this session and its 401 refresh path delegates back to this session.
|
|
55
|
+
*
|
|
56
|
+
* **GET response caching is OFF by default for linked clients.** The SDK's
|
|
57
|
+
* per-instance GET cache is only safe where the SDK OWNS invalidation: on the
|
|
58
|
+
* canonical OxyServices client, every mutation (`updateProfile`, `followUser`,
|
|
59
|
+
* `blockUser`, …) busts the matching cached GET. A linked client targets the
|
|
60
|
+
* consuming app's OWN backend (`api.mention.earth`, `api.syra.fm`, …), whose
|
|
61
|
+
* resources and write endpoints the SDK has no knowledge of — so it cannot
|
|
62
|
+
* invalidate them, and a cached GET there would silently serve stale data
|
|
63
|
+
* after the app mutates its own data. Caching is therefore unsafe-by-construction
|
|
64
|
+
* here and is left to the consumer's own layer (React Query / stores), which
|
|
65
|
+
* owns its invalidation. Pass `createLinkedClient({ baseURL, enableCache: true })`
|
|
66
|
+
* to explicitly opt back in when the consumer accepts that responsibility.
|
|
55
67
|
*/
|
|
56
68
|
createLinkedClient(config: OxyConfig): LinkedHttpClient;
|
|
57
69
|
/**
|
package/package.json
CHANGED
package/src/OxyServices.base.ts
CHANGED
|
@@ -129,9 +129,23 @@ export class OxyServicesBase {
|
|
|
129
129
|
* OxyServices instance mounted in OxyProvider. The returned client has its own
|
|
130
130
|
* base URL, cache and request queue, but its bearer token is kept in lockstep
|
|
131
131
|
* with this session and its 401 refresh path delegates back to this session.
|
|
132
|
+
*
|
|
133
|
+
* **GET response caching is OFF by default for linked clients.** The SDK's
|
|
134
|
+
* per-instance GET cache is only safe where the SDK OWNS invalidation: on the
|
|
135
|
+
* canonical OxyServices client, every mutation (`updateProfile`, `followUser`,
|
|
136
|
+
* `blockUser`, …) busts the matching cached GET. A linked client targets the
|
|
137
|
+
* consuming app's OWN backend (`api.mention.earth`, `api.syra.fm`, …), whose
|
|
138
|
+
* resources and write endpoints the SDK has no knowledge of — so it cannot
|
|
139
|
+
* invalidate them, and a cached GET there would silently serve stale data
|
|
140
|
+
* after the app mutates its own data. Caching is therefore unsafe-by-construction
|
|
141
|
+
* here and is left to the consumer's own layer (React Query / stores), which
|
|
142
|
+
* owns its invalidation. Pass `createLinkedClient({ baseURL, enableCache: true })`
|
|
143
|
+
* to explicitly opt back in when the consumer accepts that responsibility.
|
|
132
144
|
*/
|
|
133
145
|
public createLinkedClient(config: OxyConfig): LinkedHttpClient {
|
|
134
|
-
|
|
146
|
+
// Default the GET cache OFF unless the caller explicitly opts in (see the
|
|
147
|
+
// method doc): the SDK cannot invalidate the consumer backend's resources.
|
|
148
|
+
const client = new HttpService({ ...config, enableCache: config.enableCache ?? false });
|
|
135
149
|
|
|
136
150
|
const syncToken = (accessToken: string | null): void => {
|
|
137
151
|
const currentAccessToken = client.getAccessToken();
|
|
@@ -206,4 +206,65 @@ describe('OxyServices.createLinkedClient', () => {
|
|
|
206
206
|
|
|
207
207
|
linked.dispose();
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* GET response caching is OFF by default for linked clients: the SDK cannot
|
|
212
|
+
* invalidate the consumer backend's resources, so a cached GET there would
|
|
213
|
+
* serve stale data after the app mutates its own data. The consumer's own
|
|
214
|
+
* layer (React Query / stores) owns caching. An explicit `enableCache: true`
|
|
215
|
+
* opts back in.
|
|
216
|
+
*/
|
|
217
|
+
describe('linked client GET cache default', () => {
|
|
218
|
+
it('does NOT cache GETs by default — every read hits the network', async () => {
|
|
219
|
+
const fetchMock = jest.fn();
|
|
220
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
221
|
+
|
|
222
|
+
const oxy = createServices();
|
|
223
|
+
const accessToken = createJwt({
|
|
224
|
+
userId: 'user_1',
|
|
225
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
226
|
+
});
|
|
227
|
+
oxy.setTokens(accessToken);
|
|
228
|
+
const linked = oxy.createLinkedClient({ baseURL: 'https://api.mention.earth' });
|
|
229
|
+
|
|
230
|
+
// Two identical GETs with cache:true — both MUST hit the network because
|
|
231
|
+
// the linked client's cache is disabled by default.
|
|
232
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ v: 1 }));
|
|
233
|
+
const first = await linked.client.get<{ v: number }>('/feed/mtn', { cache: true });
|
|
234
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ v: 2 }));
|
|
235
|
+
const second = await linked.client.get<{ v: number }>('/feed/mtn', { cache: true });
|
|
236
|
+
|
|
237
|
+
expect(first.v).toBe(1);
|
|
238
|
+
expect(second.v).toBe(2);
|
|
239
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
240
|
+
|
|
241
|
+
linked.dispose();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('caches GETs when the caller explicitly opts in with enableCache: true', async () => {
|
|
245
|
+
const fetchMock = jest.fn();
|
|
246
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
247
|
+
|
|
248
|
+
const oxy = createServices();
|
|
249
|
+
const accessToken = createJwt({
|
|
250
|
+
userId: 'user_1',
|
|
251
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
252
|
+
});
|
|
253
|
+
oxy.setTokens(accessToken);
|
|
254
|
+
const linked = oxy.createLinkedClient({
|
|
255
|
+
baseURL: 'https://api.mention.earth',
|
|
256
|
+
enableCache: true,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Second identical GET is a warm cache hit — only one network call.
|
|
260
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ v: 1 }));
|
|
261
|
+
const first = await linked.client.get<{ v: number }>('/feed/mtn', { cache: true });
|
|
262
|
+
const second = await linked.client.get<{ v: number }>('/feed/mtn', { cache: true });
|
|
263
|
+
|
|
264
|
+
expect(first).toEqual(second);
|
|
265
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
266
|
+
|
|
267
|
+
linked.dispose();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
209
270
|
});
|
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.OxyServicesPopupAuthMixin = OxyServicesPopupAuthMixin;
|
|
4
|
-
exports.PopupAuthMixin = OxyServicesPopupAuthMixin;
|
|
5
|
-
const OxyServices_errors_1 = require("../OxyServices.errors");
|
|
6
|
-
const debugUtils_1 = require("../shared/utils/debugUtils");
|
|
7
|
-
const debug = (0, debugUtils_1.createDebugLogger)("PopupAuth");
|
|
8
|
-
/**
|
|
9
|
-
* Cross-domain browser auth helpers.
|
|
10
|
-
*
|
|
11
|
-
* Popup sign-in is intentionally fail-closed in the clean session model because
|
|
12
|
-
* the historical implementation required bearer-token callback URLs. FedCM,
|
|
13
|
-
* redirect SSO, and silent iframe SSO are the supported browser paths.
|
|
14
|
-
*/
|
|
15
|
-
function OxyServicesPopupAuthMixin(Base) {
|
|
16
|
-
var _a;
|
|
17
|
-
return _a = class extends Base {
|
|
18
|
-
constructor(...args) {
|
|
19
|
-
super(...args);
|
|
20
|
-
}
|
|
21
|
-
/** Resolve auth URL from config or static default (method, not getter — getters break in TS mixins) */
|
|
22
|
-
resolveAuthUrl() {
|
|
23
|
-
return (this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL);
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Removed popup sign-in. Closes a caller-supplied popup handle and throws.
|
|
27
|
-
*/
|
|
28
|
-
async signInWithPopup(options = {}) {
|
|
29
|
-
if (typeof window === "undefined") {
|
|
30
|
-
throw new OxyServices_errors_1.OxyAuthenticationError("Popup authentication requires browser environment");
|
|
31
|
-
}
|
|
32
|
-
if (options.popup && !options.popup.closed) {
|
|
33
|
-
options.popup.close();
|
|
34
|
-
}
|
|
35
|
-
throw new OxyServices_errors_1.OxyAuthenticationError("Popup authentication has been removed because it required access-token callback URLs. Use FedCM or redirect authentication.");
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Removed popup signup. Closes a caller-supplied popup handle and throws.
|
|
39
|
-
*/
|
|
40
|
-
async signUpWithPopup(options = {}) {
|
|
41
|
-
return this.signInWithPopup({ ...options, mode: "signup" });
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Silent sign-in using hidden iframe
|
|
45
|
-
*
|
|
46
|
-
* Attempts to automatically re-authenticate the user without any UI.
|
|
47
|
-
* This is what enables seamless SSO across all Oxy domains.
|
|
48
|
-
*
|
|
49
|
-
* How it works:
|
|
50
|
-
* 1. Creates hidden iframe pointing to auth.oxy.so/silent-auth
|
|
51
|
-
* 2. If user has valid session at auth.oxy.so, it exchanges an opaque SSO code
|
|
52
|
-
* 3. If not, iframe responds with null (no error thrown)
|
|
53
|
-
*
|
|
54
|
-
* This should be called on app startup to check for existing sessions.
|
|
55
|
-
*
|
|
56
|
-
* @param options - Silent auth options
|
|
57
|
-
* @returns Session if user is signed in, null otherwise
|
|
58
|
-
*
|
|
59
|
-
* @example
|
|
60
|
-
* ```typescript
|
|
61
|
-
* useEffect(() => {
|
|
62
|
-
* const checkAuth = async () => {
|
|
63
|
-
* const session = await oxyServices.silentSignIn();
|
|
64
|
-
* if (session) {
|
|
65
|
-
* setUser(session.user);
|
|
66
|
-
* }
|
|
67
|
-
* };
|
|
68
|
-
* checkAuth();
|
|
69
|
-
* }, []);
|
|
70
|
-
* ```
|
|
71
|
-
*/
|
|
72
|
-
async silentSignIn(options = {}) {
|
|
73
|
-
if (typeof window === "undefined") {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
const timeout = options.timeout || this.constructor.SILENT_TIMEOUT;
|
|
77
|
-
const nonce = this.generateNonce();
|
|
78
|
-
const clientId = window.location.origin;
|
|
79
|
-
// Resolve the IdP origin for the iframe. An explicit per-apex override (the
|
|
80
|
-
// durable cross-domain reload path — see `SilentAuthOptions.authWebUrlOverride`)
|
|
81
|
-
// wins over the instance's configured central auth URL. The SAME origin is
|
|
82
|
-
// handed to `waitForIframeAuth` so the postMessage origin check matches the
|
|
83
|
-
// exact host the iframe was loaded from.
|
|
84
|
-
const authOrigin = options.authWebUrlOverride && options.authWebUrlOverride.length > 0
|
|
85
|
-
? options.authWebUrlOverride
|
|
86
|
-
: this.resolveAuthUrl();
|
|
87
|
-
const iframe = document.createElement("iframe");
|
|
88
|
-
iframe.style.display = "none";
|
|
89
|
-
iframe.style.position = "absolute";
|
|
90
|
-
iframe.style.width = "0";
|
|
91
|
-
iframe.style.height = "0";
|
|
92
|
-
iframe.style.border = "none";
|
|
93
|
-
const silentUrl = `${authOrigin}/auth/silent?` +
|
|
94
|
-
`client_id=${encodeURIComponent(clientId)}&` +
|
|
95
|
-
`nonce=${nonce}`;
|
|
96
|
-
iframe.src = silentUrl;
|
|
97
|
-
document.body.appendChild(iframe);
|
|
98
|
-
try {
|
|
99
|
-
const session = await this.waitForIframeAuth(iframe, timeout, authOrigin);
|
|
100
|
-
// Bail early on incomplete responses. The iframe contract requires
|
|
101
|
-
// both an access token and a session id; anything less is unusable.
|
|
102
|
-
// Returning `null` here (without installing the token) prevents a
|
|
103
|
-
// stale credential from being committed to HttpService when the
|
|
104
|
-
// user is actually signed out — that pattern caused a `getCurrentUser`
|
|
105
|
-
// -> 401 -> token-clear loop in consumer apps because callers gated
|
|
106
|
-
// on `session?.user` and never installed the user via
|
|
107
|
-
// `handleAuthSuccess`, while HttpService quietly held the token.
|
|
108
|
-
const accessToken = session
|
|
109
|
-
? session.accessToken
|
|
110
|
-
: undefined;
|
|
111
|
-
if (!session || !accessToken || !session.sessionId) {
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
// Snapshot the previous token so we can roll back if the user
|
|
115
|
-
// lookup below fails — this avoids leaving a half-committed session
|
|
116
|
-
// (token installed, user missing) which would let the next
|
|
117
|
-
// authenticated request 401 with no way to recover.
|
|
118
|
-
const previousAccessToken = this.httpService.getAccessToken();
|
|
119
|
-
this.httpService.setTokens(accessToken);
|
|
120
|
-
// The iframe typically returns `{ sessionId, accessToken }` without
|
|
121
|
-
// user data. Fetch the user explicitly so callers receive a
|
|
122
|
-
// fully-formed session and never need a second `/users/me` round
|
|
123
|
-
// trip. If this fails the session is unusable — revert the token
|
|
124
|
-
// and return null so the caller treats this exactly like a
|
|
125
|
-
// missing-session response.
|
|
126
|
-
if (!session.user) {
|
|
127
|
-
try {
|
|
128
|
-
const userData = await this.makeRequest("GET", `/session/user/${session.sessionId}`, undefined, { cache: false, retry: false });
|
|
129
|
-
if (!userData) {
|
|
130
|
-
throw new Error("Empty user response");
|
|
131
|
-
}
|
|
132
|
-
session.user = userData;
|
|
133
|
-
}
|
|
134
|
-
catch (userError) {
|
|
135
|
-
debug.warn("silentSignIn: failed to fetch user data, rolling back token", userError);
|
|
136
|
-
if (previousAccessToken) {
|
|
137
|
-
this.httpService.setTokens(previousAccessToken);
|
|
138
|
-
}
|
|
139
|
-
else {
|
|
140
|
-
this.httpService.clearTokens();
|
|
141
|
-
}
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return session;
|
|
146
|
-
}
|
|
147
|
-
catch (error) {
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
finally {
|
|
151
|
-
document.body.removeChild(iframe);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* Open a blank, centered popup window SYNCHRONOUSLY.
|
|
156
|
-
*
|
|
157
|
-
* Kept only so legacy callers can pass a handle to the removed popup method,
|
|
158
|
-
* which closes it before throwing. New auth code should use FedCM or redirect.
|
|
159
|
-
*/
|
|
160
|
-
openBlankPopup(width, height) {
|
|
161
|
-
if (typeof window === "undefined") {
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
const ctor = this.constructor;
|
|
165
|
-
const w = width ?? ctor.POPUP_WIDTH;
|
|
166
|
-
const h = height ?? ctor.POPUP_HEIGHT;
|
|
167
|
-
return this.openCenteredPopup("about:blank", "Oxy Sign In", w, h);
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Open a centered popup window
|
|
171
|
-
*
|
|
172
|
-
* @private
|
|
173
|
-
*/
|
|
174
|
-
openCenteredPopup(url, title, width, height) {
|
|
175
|
-
const left = window.screenX + (window.outerWidth - width) / 2;
|
|
176
|
-
const top = window.screenY + (window.outerHeight - height) / 2;
|
|
177
|
-
const features = [
|
|
178
|
-
`width=${width}`,
|
|
179
|
-
`height=${height}`,
|
|
180
|
-
`left=${left}`,
|
|
181
|
-
`top=${top}`,
|
|
182
|
-
"toolbar=no",
|
|
183
|
-
"menubar=no",
|
|
184
|
-
"scrollbars=yes",
|
|
185
|
-
"resizable=yes",
|
|
186
|
-
"status=no",
|
|
187
|
-
"location=no",
|
|
188
|
-
].join(",");
|
|
189
|
-
return window.open(url, title, features);
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Wait for authentication response from iframe
|
|
193
|
-
*
|
|
194
|
-
* @private
|
|
195
|
-
*/
|
|
196
|
-
async waitForIframeAuth(iframe, timeout, expectedOrigin) {
|
|
197
|
-
return new Promise((resolve) => {
|
|
198
|
-
const timeoutId = setTimeout(() => {
|
|
199
|
-
cleanup();
|
|
200
|
-
resolve(null); // Silent failure - don't throw
|
|
201
|
-
}, timeout);
|
|
202
|
-
const messageHandler = (event) => {
|
|
203
|
-
// Verify origin against the EXACT host the iframe was loaded from
|
|
204
|
-
// (`expectedOrigin`). For the per-apex durable-restore path this is
|
|
205
|
-
// `auth.<rp-apex>`, not the instance's central `resolveAuthUrl()` — so
|
|
206
|
-
// we must honour the caller-supplied origin, never re-derive it here.
|
|
207
|
-
if (event.origin !== expectedOrigin) {
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
const { type, session } = event.data;
|
|
211
|
-
if (type !== "oxy_silent_auth") {
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
cleanup();
|
|
215
|
-
resolve(session || null);
|
|
216
|
-
};
|
|
217
|
-
// Fail-fast on a load failure. When the per-apex `/auth/silent` host is
|
|
218
|
-
// unreachable, blocked by CSP `frame-ancestors`/`X-Frame-Options`, or the
|
|
219
|
-
// network drops, the iframe never posts a message — without this handler
|
|
220
|
-
// the silent restore would block for the FULL `timeout` (dead latency in
|
|
221
|
-
// the cold-boot critical path). `onerror`/`onabort` fire on a failed load,
|
|
222
|
-
// so resolve `null` immediately and let the next cold-boot step run. The
|
|
223
|
-
// success path posts a message and is handled above; these only catch the
|
|
224
|
-
// no-message failure modes.
|
|
225
|
-
const failFast = () => {
|
|
226
|
-
cleanup();
|
|
227
|
-
resolve(null);
|
|
228
|
-
};
|
|
229
|
-
iframe.onerror = failFast;
|
|
230
|
-
iframe.onabort = failFast;
|
|
231
|
-
const cleanup = () => {
|
|
232
|
-
clearTimeout(timeoutId);
|
|
233
|
-
iframe.onerror = null;
|
|
234
|
-
iframe.onabort = null;
|
|
235
|
-
window.removeEventListener("message", messageHandler);
|
|
236
|
-
};
|
|
237
|
-
window.addEventListener("message", messageHandler);
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
/**
|
|
241
|
-
* Generate nonce for replay attack prevention
|
|
242
|
-
*
|
|
243
|
-
* @private
|
|
244
|
-
*/
|
|
245
|
-
generateNonce() {
|
|
246
|
-
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
247
|
-
return crypto.randomUUID();
|
|
248
|
-
}
|
|
249
|
-
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
250
|
-
const bytes = new Uint8Array(16);
|
|
251
|
-
crypto.getRandomValues(bytes);
|
|
252
|
-
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
253
|
-
}
|
|
254
|
-
throw new Error("No secure random source available for nonce generation");
|
|
255
|
-
}
|
|
256
|
-
},
|
|
257
|
-
_a.DEFAULT_AUTH_URL = "https://auth.oxy.so",
|
|
258
|
-
_a.POPUP_WIDTH = 500,
|
|
259
|
-
_a.POPUP_HEIGHT = 700,
|
|
260
|
-
_a.SILENT_TIMEOUT = 5000 // 5 seconds
|
|
261
|
-
,
|
|
262
|
-
_a;
|
|
263
|
-
}
|
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
import { OxyAuthenticationError } from "../OxyServices.errors.js";
|
|
2
|
-
import { createDebugLogger } from "../shared/utils/debugUtils.js";
|
|
3
|
-
const debug = createDebugLogger("PopupAuth");
|
|
4
|
-
/**
|
|
5
|
-
* Cross-domain browser auth helpers.
|
|
6
|
-
*
|
|
7
|
-
* Popup sign-in is intentionally fail-closed in the clean session model because
|
|
8
|
-
* the historical implementation required bearer-token callback URLs. FedCM,
|
|
9
|
-
* redirect SSO, and silent iframe SSO are the supported browser paths.
|
|
10
|
-
*/
|
|
11
|
-
export function OxyServicesPopupAuthMixin(Base) {
|
|
12
|
-
var _a;
|
|
13
|
-
return _a = class extends Base {
|
|
14
|
-
constructor(...args) {
|
|
15
|
-
super(...args);
|
|
16
|
-
}
|
|
17
|
-
/** Resolve auth URL from config or static default (method, not getter — getters break in TS mixins) */
|
|
18
|
-
resolveAuthUrl() {
|
|
19
|
-
return (this.config.authWebUrl || this.constructor.DEFAULT_AUTH_URL);
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Removed popup sign-in. Closes a caller-supplied popup handle and throws.
|
|
23
|
-
*/
|
|
24
|
-
async signInWithPopup(options = {}) {
|
|
25
|
-
if (typeof window === "undefined") {
|
|
26
|
-
throw new OxyAuthenticationError("Popup authentication requires browser environment");
|
|
27
|
-
}
|
|
28
|
-
if (options.popup && !options.popup.closed) {
|
|
29
|
-
options.popup.close();
|
|
30
|
-
}
|
|
31
|
-
throw new OxyAuthenticationError("Popup authentication has been removed because it required access-token callback URLs. Use FedCM or redirect authentication.");
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Removed popup signup. Closes a caller-supplied popup handle and throws.
|
|
35
|
-
*/
|
|
36
|
-
async signUpWithPopup(options = {}) {
|
|
37
|
-
return this.signInWithPopup({ ...options, mode: "signup" });
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Silent sign-in using hidden iframe
|
|
41
|
-
*
|
|
42
|
-
* Attempts to automatically re-authenticate the user without any UI.
|
|
43
|
-
* This is what enables seamless SSO across all Oxy domains.
|
|
44
|
-
*
|
|
45
|
-
* How it works:
|
|
46
|
-
* 1. Creates hidden iframe pointing to auth.oxy.so/silent-auth
|
|
47
|
-
* 2. If user has valid session at auth.oxy.so, it exchanges an opaque SSO code
|
|
48
|
-
* 3. If not, iframe responds with null (no error thrown)
|
|
49
|
-
*
|
|
50
|
-
* This should be called on app startup to check for existing sessions.
|
|
51
|
-
*
|
|
52
|
-
* @param options - Silent auth options
|
|
53
|
-
* @returns Session if user is signed in, null otherwise
|
|
54
|
-
*
|
|
55
|
-
* @example
|
|
56
|
-
* ```typescript
|
|
57
|
-
* useEffect(() => {
|
|
58
|
-
* const checkAuth = async () => {
|
|
59
|
-
* const session = await oxyServices.silentSignIn();
|
|
60
|
-
* if (session) {
|
|
61
|
-
* setUser(session.user);
|
|
62
|
-
* }
|
|
63
|
-
* };
|
|
64
|
-
* checkAuth();
|
|
65
|
-
* }, []);
|
|
66
|
-
* ```
|
|
67
|
-
*/
|
|
68
|
-
async silentSignIn(options = {}) {
|
|
69
|
-
if (typeof window === "undefined") {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
const timeout = options.timeout || this.constructor.SILENT_TIMEOUT;
|
|
73
|
-
const nonce = this.generateNonce();
|
|
74
|
-
const clientId = window.location.origin;
|
|
75
|
-
// Resolve the IdP origin for the iframe. An explicit per-apex override (the
|
|
76
|
-
// durable cross-domain reload path — see `SilentAuthOptions.authWebUrlOverride`)
|
|
77
|
-
// wins over the instance's configured central auth URL. The SAME origin is
|
|
78
|
-
// handed to `waitForIframeAuth` so the postMessage origin check matches the
|
|
79
|
-
// exact host the iframe was loaded from.
|
|
80
|
-
const authOrigin = options.authWebUrlOverride && options.authWebUrlOverride.length > 0
|
|
81
|
-
? options.authWebUrlOverride
|
|
82
|
-
: this.resolveAuthUrl();
|
|
83
|
-
const iframe = document.createElement("iframe");
|
|
84
|
-
iframe.style.display = "none";
|
|
85
|
-
iframe.style.position = "absolute";
|
|
86
|
-
iframe.style.width = "0";
|
|
87
|
-
iframe.style.height = "0";
|
|
88
|
-
iframe.style.border = "none";
|
|
89
|
-
const silentUrl = `${authOrigin}/auth/silent?` +
|
|
90
|
-
`client_id=${encodeURIComponent(clientId)}&` +
|
|
91
|
-
`nonce=${nonce}`;
|
|
92
|
-
iframe.src = silentUrl;
|
|
93
|
-
document.body.appendChild(iframe);
|
|
94
|
-
try {
|
|
95
|
-
const session = await this.waitForIframeAuth(iframe, timeout, authOrigin);
|
|
96
|
-
// Bail early on incomplete responses. The iframe contract requires
|
|
97
|
-
// both an access token and a session id; anything less is unusable.
|
|
98
|
-
// Returning `null` here (without installing the token) prevents a
|
|
99
|
-
// stale credential from being committed to HttpService when the
|
|
100
|
-
// user is actually signed out — that pattern caused a `getCurrentUser`
|
|
101
|
-
// -> 401 -> token-clear loop in consumer apps because callers gated
|
|
102
|
-
// on `session?.user` and never installed the user via
|
|
103
|
-
// `handleAuthSuccess`, while HttpService quietly held the token.
|
|
104
|
-
const accessToken = session
|
|
105
|
-
? session.accessToken
|
|
106
|
-
: undefined;
|
|
107
|
-
if (!session || !accessToken || !session.sessionId) {
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
// Snapshot the previous token so we can roll back if the user
|
|
111
|
-
// lookup below fails — this avoids leaving a half-committed session
|
|
112
|
-
// (token installed, user missing) which would let the next
|
|
113
|
-
// authenticated request 401 with no way to recover.
|
|
114
|
-
const previousAccessToken = this.httpService.getAccessToken();
|
|
115
|
-
this.httpService.setTokens(accessToken);
|
|
116
|
-
// The iframe typically returns `{ sessionId, accessToken }` without
|
|
117
|
-
// user data. Fetch the user explicitly so callers receive a
|
|
118
|
-
// fully-formed session and never need a second `/users/me` round
|
|
119
|
-
// trip. If this fails the session is unusable — revert the token
|
|
120
|
-
// and return null so the caller treats this exactly like a
|
|
121
|
-
// missing-session response.
|
|
122
|
-
if (!session.user) {
|
|
123
|
-
try {
|
|
124
|
-
const userData = await this.makeRequest("GET", `/session/user/${session.sessionId}`, undefined, { cache: false, retry: false });
|
|
125
|
-
if (!userData) {
|
|
126
|
-
throw new Error("Empty user response");
|
|
127
|
-
}
|
|
128
|
-
session.user = userData;
|
|
129
|
-
}
|
|
130
|
-
catch (userError) {
|
|
131
|
-
debug.warn("silentSignIn: failed to fetch user data, rolling back token", userError);
|
|
132
|
-
if (previousAccessToken) {
|
|
133
|
-
this.httpService.setTokens(previousAccessToken);
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
this.httpService.clearTokens();
|
|
137
|
-
}
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return session;
|
|
142
|
-
}
|
|
143
|
-
catch (error) {
|
|
144
|
-
return null;
|
|
145
|
-
}
|
|
146
|
-
finally {
|
|
147
|
-
document.body.removeChild(iframe);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Open a blank, centered popup window SYNCHRONOUSLY.
|
|
152
|
-
*
|
|
153
|
-
* Kept only so legacy callers can pass a handle to the removed popup method,
|
|
154
|
-
* which closes it before throwing. New auth code should use FedCM or redirect.
|
|
155
|
-
*/
|
|
156
|
-
openBlankPopup(width, height) {
|
|
157
|
-
if (typeof window === "undefined") {
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
const ctor = this.constructor;
|
|
161
|
-
const w = width ?? ctor.POPUP_WIDTH;
|
|
162
|
-
const h = height ?? ctor.POPUP_HEIGHT;
|
|
163
|
-
return this.openCenteredPopup("about:blank", "Oxy Sign In", w, h);
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Open a centered popup window
|
|
167
|
-
*
|
|
168
|
-
* @private
|
|
169
|
-
*/
|
|
170
|
-
openCenteredPopup(url, title, width, height) {
|
|
171
|
-
const left = window.screenX + (window.outerWidth - width) / 2;
|
|
172
|
-
const top = window.screenY + (window.outerHeight - height) / 2;
|
|
173
|
-
const features = [
|
|
174
|
-
`width=${width}`,
|
|
175
|
-
`height=${height}`,
|
|
176
|
-
`left=${left}`,
|
|
177
|
-
`top=${top}`,
|
|
178
|
-
"toolbar=no",
|
|
179
|
-
"menubar=no",
|
|
180
|
-
"scrollbars=yes",
|
|
181
|
-
"resizable=yes",
|
|
182
|
-
"status=no",
|
|
183
|
-
"location=no",
|
|
184
|
-
].join(",");
|
|
185
|
-
return window.open(url, title, features);
|
|
186
|
-
}
|
|
187
|
-
/**
|
|
188
|
-
* Wait for authentication response from iframe
|
|
189
|
-
*
|
|
190
|
-
* @private
|
|
191
|
-
*/
|
|
192
|
-
async waitForIframeAuth(iframe, timeout, expectedOrigin) {
|
|
193
|
-
return new Promise((resolve) => {
|
|
194
|
-
const timeoutId = setTimeout(() => {
|
|
195
|
-
cleanup();
|
|
196
|
-
resolve(null); // Silent failure - don't throw
|
|
197
|
-
}, timeout);
|
|
198
|
-
const messageHandler = (event) => {
|
|
199
|
-
// Verify origin against the EXACT host the iframe was loaded from
|
|
200
|
-
// (`expectedOrigin`). For the per-apex durable-restore path this is
|
|
201
|
-
// `auth.<rp-apex>`, not the instance's central `resolveAuthUrl()` — so
|
|
202
|
-
// we must honour the caller-supplied origin, never re-derive it here.
|
|
203
|
-
if (event.origin !== expectedOrigin) {
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
const { type, session } = event.data;
|
|
207
|
-
if (type !== "oxy_silent_auth") {
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
cleanup();
|
|
211
|
-
resolve(session || null);
|
|
212
|
-
};
|
|
213
|
-
// Fail-fast on a load failure. When the per-apex `/auth/silent` host is
|
|
214
|
-
// unreachable, blocked by CSP `frame-ancestors`/`X-Frame-Options`, or the
|
|
215
|
-
// network drops, the iframe never posts a message — without this handler
|
|
216
|
-
// the silent restore would block for the FULL `timeout` (dead latency in
|
|
217
|
-
// the cold-boot critical path). `onerror`/`onabort` fire on a failed load,
|
|
218
|
-
// so resolve `null` immediately and let the next cold-boot step run. The
|
|
219
|
-
// success path posts a message and is handled above; these only catch the
|
|
220
|
-
// no-message failure modes.
|
|
221
|
-
const failFast = () => {
|
|
222
|
-
cleanup();
|
|
223
|
-
resolve(null);
|
|
224
|
-
};
|
|
225
|
-
iframe.onerror = failFast;
|
|
226
|
-
iframe.onabort = failFast;
|
|
227
|
-
const cleanup = () => {
|
|
228
|
-
clearTimeout(timeoutId);
|
|
229
|
-
iframe.onerror = null;
|
|
230
|
-
iframe.onabort = null;
|
|
231
|
-
window.removeEventListener("message", messageHandler);
|
|
232
|
-
};
|
|
233
|
-
window.addEventListener("message", messageHandler);
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* Generate nonce for replay attack prevention
|
|
238
|
-
*
|
|
239
|
-
* @private
|
|
240
|
-
*/
|
|
241
|
-
generateNonce() {
|
|
242
|
-
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
243
|
-
return crypto.randomUUID();
|
|
244
|
-
}
|
|
245
|
-
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
246
|
-
const bytes = new Uint8Array(16);
|
|
247
|
-
crypto.getRandomValues(bytes);
|
|
248
|
-
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
249
|
-
}
|
|
250
|
-
throw new Error("No secure random source available for nonce generation");
|
|
251
|
-
}
|
|
252
|
-
},
|
|
253
|
-
_a.DEFAULT_AUTH_URL = "https://auth.oxy.so",
|
|
254
|
-
_a.POPUP_WIDTH = 500,
|
|
255
|
-
_a.POPUP_HEIGHT = 700,
|
|
256
|
-
_a.SILENT_TIMEOUT = 5000 // 5 seconds
|
|
257
|
-
,
|
|
258
|
-
_a;
|
|
259
|
-
}
|
|
260
|
-
// Export the mixin function as both named and default
|
|
261
|
-
export { OxyServicesPopupAuthMixin as PopupAuthMixin };
|