@oxyhq/core 3.4.0 → 3.4.2
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +91 -319
- package/dist/cjs/CrossDomainAuth.js +19 -106
- package/dist/cjs/HttpService.js +49 -73
- package/dist/cjs/OxyServices.base.js +2 -2
- package/dist/cjs/i18n/index.js +7 -1
- package/dist/cjs/i18n/locales/ar-SA.json +18 -2
- package/dist/cjs/i18n/locales/ca-ES.json +18 -2
- package/dist/cjs/i18n/locales/de-DE.json +18 -2
- package/dist/cjs/i18n/locales/en-US.json +16 -2
- package/dist/cjs/i18n/locales/es-ES.json +16 -2
- package/dist/cjs/i18n/locales/fr-FR.json +18 -2
- package/dist/cjs/i18n/locales/it-IT.json +18 -2
- package/dist/cjs/i18n/locales/ja-JP.json +18 -2
- package/dist/cjs/i18n/locales/ko-KR.json +18 -2
- package/dist/cjs/i18n/locales/locales/ar-SA.json +18 -2
- package/dist/cjs/i18n/locales/locales/ca-ES.json +18 -2
- package/dist/cjs/i18n/locales/locales/de-DE.json +18 -2
- package/dist/cjs/i18n/locales/locales/en-US.json +17 -3
- package/dist/cjs/i18n/locales/locales/es-ES.json +16 -2
- package/dist/cjs/i18n/locales/locales/fr-FR.json +18 -2
- package/dist/cjs/i18n/locales/locales/it-IT.json +18 -2
- package/dist/cjs/i18n/locales/locales/ja-JP.json +18 -2
- package/dist/cjs/i18n/locales/locales/ko-KR.json +18 -2
- package/dist/cjs/i18n/locales/locales/pt-PT.json +18 -2
- package/dist/cjs/i18n/locales/locales/zh-CN.json +18 -2
- package/dist/cjs/i18n/locales/pt-PT.json +18 -2
- package/dist/cjs/i18n/locales/zh-CN.json +18 -2
- package/dist/cjs/mixins/OxyServices.auth.js +20 -63
- package/dist/cjs/mixins/OxyServices.fedcm.js +10 -12
- package/dist/cjs/mixins/OxyServices.popup.js +50 -299
- package/dist/cjs/mixins/OxyServices.redirect.js +84 -348
- package/dist/cjs/mixins/OxyServices.silent.js +204 -0
- package/dist/cjs/mixins/OxyServices.sso.js +4 -5
- package/dist/cjs/mixins/OxyServices.utility.js +6 -15
- package/dist/cjs/mixins/index.js +5 -6
- package/dist/cjs/server/index.js +21 -0
- package/dist/cjs/server/rateLimit.js +77 -0
- package/dist/cjs/shared/utils/debugUtils.js +1 -1
- package/dist/cjs/utils/accountUtils.js +4 -4
- package/dist/cjs/utils/authHelpers.js +21 -15
- package/dist/cjs/utils/coldBoot.js +3 -3
- package/dist/cjs/utils/fapiAutoDetect.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +91 -319
- package/dist/esm/CrossDomainAuth.js +19 -106
- package/dist/esm/HttpService.js +49 -73
- package/dist/esm/OxyServices.base.js +2 -2
- package/dist/esm/i18n/index.js +7 -1
- package/dist/esm/i18n/locales/ar-SA.json +18 -2
- package/dist/esm/i18n/locales/ca-ES.json +18 -2
- package/dist/esm/i18n/locales/de-DE.json +18 -2
- package/dist/esm/i18n/locales/en-US.json +16 -2
- package/dist/esm/i18n/locales/es-ES.json +16 -2
- package/dist/esm/i18n/locales/fr-FR.json +18 -2
- package/dist/esm/i18n/locales/it-IT.json +18 -2
- package/dist/esm/i18n/locales/ja-JP.json +18 -2
- package/dist/esm/i18n/locales/ko-KR.json +18 -2
- package/dist/esm/i18n/locales/locales/ar-SA.json +18 -2
- package/dist/esm/i18n/locales/locales/ca-ES.json +18 -2
- package/dist/esm/i18n/locales/locales/de-DE.json +18 -2
- package/dist/esm/i18n/locales/locales/en-US.json +17 -3
- package/dist/esm/i18n/locales/locales/es-ES.json +16 -2
- package/dist/esm/i18n/locales/locales/fr-FR.json +18 -2
- package/dist/esm/i18n/locales/locales/it-IT.json +18 -2
- package/dist/esm/i18n/locales/locales/ja-JP.json +18 -2
- package/dist/esm/i18n/locales/locales/ko-KR.json +18 -2
- package/dist/esm/i18n/locales/locales/pt-PT.json +18 -2
- package/dist/esm/i18n/locales/locales/zh-CN.json +18 -2
- package/dist/esm/i18n/locales/pt-PT.json +18 -2
- package/dist/esm/i18n/locales/zh-CN.json +18 -2
- package/dist/esm/mixins/OxyServices.auth.js +20 -63
- package/dist/esm/mixins/OxyServices.fedcm.js +10 -12
- package/dist/esm/mixins/OxyServices.popup.js +52 -301
- package/dist/esm/mixins/OxyServices.redirect.js +84 -349
- package/dist/esm/mixins/OxyServices.silent.js +202 -0
- package/dist/esm/mixins/OxyServices.sso.js +4 -5
- package/dist/esm/mixins/OxyServices.utility.js +6 -15
- package/dist/esm/mixins/index.js +5 -6
- package/dist/esm/server/index.js +17 -0
- package/dist/esm/server/rateLimit.js +71 -0
- package/dist/esm/shared/utils/debugUtils.js +1 -1
- package/dist/esm/utils/accountUtils.js +4 -4
- package/dist/esm/utils/authHelpers.js +21 -15
- package/dist/esm/utils/coldBoot.js +3 -3
- package/dist/esm/utils/fapiAutoDetect.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/AuthManager.d.ts +26 -53
- package/dist/types/AuthManagerTypes.d.ts +5 -9
- package/dist/types/CrossDomainAuth.d.ts +13 -52
- package/dist/types/HttpService.d.ts +9 -8
- package/dist/types/OxyServices.base.d.ts +1 -1
- package/dist/types/OxyServices.d.ts +4 -10
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -1
- package/dist/types/mixins/OxyServices.appData.d.ts +1 -1
- package/dist/types/mixins/OxyServices.applications.d.ts +1 -1
- package/dist/types/mixins/OxyServices.assets.d.ts +1 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +10 -31
- package/dist/types/mixins/OxyServices.contacts.d.ts +1 -1
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -1
- package/dist/types/mixins/OxyServices.features.d.ts +1 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +5 -5
- package/dist/types/mixins/OxyServices.language.d.ts +1 -1
- package/dist/types/mixins/OxyServices.location.d.ts +1 -1
- package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -1
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -1
- package/dist/types/mixins/OxyServices.popup.d.ts +18 -120
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -1
- package/dist/types/mixins/OxyServices.redirect.d.ts +13 -174
- package/dist/types/mixins/OxyServices.reputation.d.ts +1 -1
- package/dist/types/mixins/OxyServices.security.d.ts +1 -1
- package/dist/types/mixins/OxyServices.silent.d.ts +131 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +4 -5
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -1
- package/dist/types/mixins/OxyServices.user.d.ts +1 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -8
- package/dist/types/mixins/OxyServices.workspaces.d.ts +1 -1
- package/dist/types/mixins/index.d.ts +3 -3
- package/dist/types/models/interfaces.d.ts +5 -16
- package/dist/types/models/session.d.ts +0 -2
- package/dist/types/server/index.d.ts +18 -0
- package/dist/types/server/rateLimit.d.ts +40 -0
- package/dist/types/shared/utils/debugUtils.d.ts +1 -1
- package/dist/types/utils/authHelpers.d.ts +4 -3
- package/dist/types/utils/coldBoot.d.ts +2 -2
- package/dist/types/utils/fapiAutoDetect.d.ts +1 -1
- package/package.json +25 -3
- package/src/AuthManager.ts +100 -370
- package/src/AuthManagerTypes.ts +5 -9
- package/src/CrossDomainAuth.ts +22 -129
- package/src/HttpService.ts +55 -73
- package/src/OxyServices.base.ts +2 -3
- package/src/OxyServices.ts +9 -11
- package/src/__tests__/authManager.cookiePath.test.ts +19 -17
- package/src/__tests__/authManager.security.test.ts +7 -3
- package/src/__tests__/crossDomainAuth.test.ts +26 -118
- package/src/i18n/index.ts +7 -1
- package/src/i18n/locales/ar-SA.json +18 -2
- package/src/i18n/locales/ca-ES.json +18 -2
- package/src/i18n/locales/de-DE.json +18 -2
- package/src/i18n/locales/en-US.json +17 -3
- package/src/i18n/locales/es-ES.json +16 -2
- package/src/i18n/locales/fr-FR.json +18 -2
- package/src/i18n/locales/it-IT.json +18 -2
- package/src/i18n/locales/ja-JP.json +18 -2
- package/src/i18n/locales/ko-KR.json +18 -2
- package/src/i18n/locales/pt-PT.json +18 -2
- package/src/i18n/locales/zh-CN.json +18 -2
- package/src/index.ts +1 -1
- package/src/mixins/OxyServices.auth.ts +23 -75
- package/src/mixins/OxyServices.fedcm.ts +10 -12
- package/src/mixins/OxyServices.redirect.ts +82 -371
- package/src/mixins/OxyServices.silent.ts +272 -0
- package/src/mixins/OxyServices.sso.ts +5 -6
- package/src/mixins/OxyServices.utility.ts +9 -22
- package/src/mixins/__tests__/appData.test.ts +1 -1
- package/src/mixins/__tests__/onTokensChanged.test.ts +1 -1
- package/src/mixins/__tests__/reputation.test.ts +1 -1
- package/src/mixins/__tests__/serviceAuth.test.ts +7 -5
- package/src/mixins/__tests__/silent.test.ts +102 -0
- package/src/mixins/__tests__/verifyChallenge.test.ts +9 -14
- package/src/mixins/index.ts +6 -8
- package/src/models/interfaces.ts +5 -16
- package/src/models/session.ts +1 -3
- package/src/server/index.ts +19 -0
- package/src/server/rateLimit.ts +170 -0
- package/src/shared/utils/debugUtils.ts +1 -1
- package/src/utils/accountUtils.ts +4 -4
- package/src/utils/authHelpers.ts +23 -15
- package/src/utils/coldBoot.ts +4 -4
- package/src/utils/fapiAutoDetect.ts +1 -1
- package/src/mixins/OxyServices.popup.ts +0 -631
- package/src/mixins/__tests__/popup.test.ts +0 -374
|
@@ -1,631 +0,0 @@
|
|
|
1
|
-
import type { OxyServicesBase } from '../OxyServices.base';
|
|
2
|
-
import { OxyAuthenticationError } from '../OxyServices.errors';
|
|
3
|
-
import type { SessionLoginResponse } from '../models/session';
|
|
4
|
-
import { createDebugLogger } from '../shared/utils/debugUtils';
|
|
5
|
-
|
|
6
|
-
const debug = createDebugLogger('PopupAuth');
|
|
7
|
-
|
|
8
|
-
export interface PopupAuthOptions {
|
|
9
|
-
width?: number;
|
|
10
|
-
height?: number;
|
|
11
|
-
timeout?: number;
|
|
12
|
-
mode?: 'login' | 'signup';
|
|
13
|
-
/**
|
|
14
|
-
* A popup window handle the caller already opened SYNCHRONOUSLY inside the
|
|
15
|
-
* user-gesture event handler (e.g. an `onClick`). The mixin will navigate
|
|
16
|
-
* this window to the auth URL instead of calling `window.open` itself.
|
|
17
|
-
*
|
|
18
|
-
* Why this exists: Chrome (and other modern browsers) only honor
|
|
19
|
-
* `window.open` while a transient user-activation is in scope. The
|
|
20
|
-
* activation is consumed by the FIRST `await` in the click handler — so any
|
|
21
|
-
* caller that awaits FedCM / silent SSO before reaching `signInWithPopup`
|
|
22
|
-
* loses the activation and sees the popup blocked. The caller can dodge
|
|
23
|
-
* this by opening a blank popup on the raw click via `openBlankPopup()`,
|
|
24
|
-
* then passing the handle in here.
|
|
25
|
-
*
|
|
26
|
-
* `null` is accepted (and is the same as omitting the option) so consumers
|
|
27
|
-
* can pass through the result of `openBlankPopup()` without an extra guard.
|
|
28
|
-
*/
|
|
29
|
-
popup?: Window | null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface SilentAuthOptions {
|
|
33
|
-
timeout?: number;
|
|
34
|
-
/**
|
|
35
|
-
* Override the auth-web (IdP) origin used for the silent iframe, instead of
|
|
36
|
-
* the instance's configured `resolveAuthUrl()`.
|
|
37
|
-
*
|
|
38
|
-
* Why this exists: an instance configured with the CENTRAL IdP
|
|
39
|
-
* (`authWebUrl=https://auth.oxy.so`, for the opaque-code `/sso` bounce and
|
|
40
|
-
* FedCM) cannot read the DURABLE per-apex `fedcm_session` cookie via the
|
|
41
|
-
* central host — that cookie is first-party only on `auth.<rp-apex>` (e.g.
|
|
42
|
-
* `auth.mention.earth`). The cross-domain reload-restore path must point the
|
|
43
|
-
* `/auth/silent` iframe at the PER-APEX host so the cookie is same-site to
|
|
44
|
-
* the RP page (first-party under Safari ITP / Firefox TCP) and the restore
|
|
45
|
-
* is NOT a top-level navigation (no flash, works in a backgrounded tab).
|
|
46
|
-
*
|
|
47
|
-
* When provided this value is used BOTH for the iframe `src` AND for the
|
|
48
|
-
* `postMessage` origin validation in {@link waitForIframeAuth}, so the
|
|
49
|
-
* security check still matches the exact origin the iframe was loaded from.
|
|
50
|
-
* Must be an absolute origin (`https://auth.<apex>`); ignored if empty.
|
|
51
|
-
*/
|
|
52
|
-
authWebUrlOverride?: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Popup-based Cross-Domain Authentication Mixin
|
|
57
|
-
*
|
|
58
|
-
* Implements OAuth2-style authentication using popup windows and postMessage.
|
|
59
|
-
* This is the primary authentication method for modern browsers, providing a
|
|
60
|
-
* Google-like experience without full page redirects.
|
|
61
|
-
*
|
|
62
|
-
* Flow:
|
|
63
|
-
* 1. Opens small popup window to auth.oxy.so
|
|
64
|
-
* 2. User signs in (auth.oxy.so sets its own first-party cookie)
|
|
65
|
-
* 3. auth.oxy.so sends token back via postMessage
|
|
66
|
-
* 4. Popup closes, parent app has the session
|
|
67
|
-
*
|
|
68
|
-
* Features:
|
|
69
|
-
* - No full page redirect (preserves app state)
|
|
70
|
-
* - Works across different domains (homiio.com, mention.earth, etc.)
|
|
71
|
-
* - Silent refresh using hidden iframe for seamless SSO
|
|
72
|
-
* - CSRF protection via state parameter
|
|
73
|
-
* - XSS protection via origin validation
|
|
74
|
-
*
|
|
75
|
-
* Browser Support: All modern browsers (IE11+)
|
|
76
|
-
*/
|
|
77
|
-
export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
78
|
-
return class extends Base {
|
|
79
|
-
constructor(...args: any[]) {
|
|
80
|
-
super(...(args as [any]));
|
|
81
|
-
}
|
|
82
|
-
public static readonly DEFAULT_AUTH_URL = 'https://auth.oxy.so';
|
|
83
|
-
|
|
84
|
-
/** Resolve auth URL from config or static default (method, not getter — getters break in TS mixins) */
|
|
85
|
-
public resolveAuthUrl(): string {
|
|
86
|
-
return this.config.authWebUrl || (this.constructor as any).DEFAULT_AUTH_URL;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
public static readonly POPUP_WIDTH = 500;
|
|
90
|
-
public static readonly POPUP_HEIGHT = 700;
|
|
91
|
-
public static readonly POPUP_TIMEOUT = 60000; // 1 minute
|
|
92
|
-
public static readonly SILENT_TIMEOUT = 5000; // 5 seconds
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Sign in using popup window
|
|
96
|
-
*
|
|
97
|
-
* Opens a centered popup window to auth.oxy.so where the user can sign in.
|
|
98
|
-
* The popup automatically closes after successful authentication and the
|
|
99
|
-
* session is returned to the parent window.
|
|
100
|
-
*
|
|
101
|
-
* @param options - Popup configuration options
|
|
102
|
-
* @returns Session with access token and user data
|
|
103
|
-
* @throws {OxyAuthenticationError} If popup is blocked or auth fails
|
|
104
|
-
*
|
|
105
|
-
* @example
|
|
106
|
-
* ```typescript
|
|
107
|
-
* const handleSignIn = async () => {
|
|
108
|
-
* try {
|
|
109
|
-
* const session = await oxyServices.signInWithPopup();
|
|
110
|
-
* console.log('Signed in:', session.user);
|
|
111
|
-
* } catch (error) {
|
|
112
|
-
* if (error.message.includes('blocked')) {
|
|
113
|
-
* alert('Please allow popups for this site');
|
|
114
|
-
* }
|
|
115
|
-
* }
|
|
116
|
-
* };
|
|
117
|
-
* ```
|
|
118
|
-
*/
|
|
119
|
-
async signInWithPopup(options: PopupAuthOptions = {}): Promise<SessionLoginResponse> {
|
|
120
|
-
if (typeof window === 'undefined') {
|
|
121
|
-
throw new OxyAuthenticationError('Popup authentication requires browser environment');
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const state = this.generateState();
|
|
125
|
-
const nonce = this.generateNonce();
|
|
126
|
-
|
|
127
|
-
// Store state for CSRF protection
|
|
128
|
-
this.storeAuthState(state, nonce);
|
|
129
|
-
|
|
130
|
-
const width = options.width || (this.constructor as any).POPUP_WIDTH;
|
|
131
|
-
const height = options.height || (this.constructor as any).POPUP_HEIGHT;
|
|
132
|
-
const timeout = options.timeout || (this.constructor as any).POPUP_TIMEOUT;
|
|
133
|
-
const mode = options.mode || 'login';
|
|
134
|
-
|
|
135
|
-
const authUrl = this.buildAuthUrl({
|
|
136
|
-
mode,
|
|
137
|
-
state,
|
|
138
|
-
nonce,
|
|
139
|
-
clientId: window.location.origin,
|
|
140
|
-
redirectUri: `${this.resolveAuthUrl()}/auth/callback`,
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
// If the caller pre-opened a popup on the raw user gesture (recommended
|
|
144
|
-
// path — see `openBlankPopup` and `PopupAuthOptions.popup`), navigate it
|
|
145
|
-
// to the auth URL instead of issuing a fresh `window.open` (which would
|
|
146
|
-
// be blocked once any prior `await` has consumed the user activation).
|
|
147
|
-
let popup: Window | null;
|
|
148
|
-
const preOpened = options.popup ?? null;
|
|
149
|
-
if (preOpened) {
|
|
150
|
-
if (preOpened.closed) {
|
|
151
|
-
// The pre-opened popup is gone — distinguish a user cancel (they
|
|
152
|
-
// closed the blank window before sign-in could navigate it) from a
|
|
153
|
-
// blocker rejection. Lumping these together as "Popup blocked" is
|
|
154
|
-
// misleading: the popup was NOT blocked, it was opened successfully
|
|
155
|
-
// and then dismissed.
|
|
156
|
-
throw new OxyAuthenticationError(
|
|
157
|
-
'Sign-in window was closed before authentication could start.'
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
try {
|
|
161
|
-
preOpened.location.replace(authUrl);
|
|
162
|
-
} catch (replaceError) {
|
|
163
|
-
// `location.replace` can throw in sandboxed / cross-origin-locked
|
|
164
|
-
// environments. Fall back to `href` assignment, which is more
|
|
165
|
-
// permissive. Logged at debug-level so consumers can correlate
|
|
166
|
-
// unusual sign-in behaviour without producing noise in normal flows.
|
|
167
|
-
debug.warn('location.replace failed, falling back to location.href', replaceError);
|
|
168
|
-
preOpened.location.href = authUrl;
|
|
169
|
-
}
|
|
170
|
-
popup = preOpened;
|
|
171
|
-
} else {
|
|
172
|
-
popup = this.openCenteredPopup(authUrl, 'Oxy Sign In', width, height);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (!popup) {
|
|
176
|
-
throw new OxyAuthenticationError(
|
|
177
|
-
'Popup blocked. Please allow popups for this site and try again.'
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
const session = await this.waitForPopupAuth(popup, state, timeout);
|
|
183
|
-
|
|
184
|
-
// Store access token if present
|
|
185
|
-
if (session && (session as any).accessToken) {
|
|
186
|
-
this.httpService.setTokens((session as any).accessToken);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Fetch user data using the session ID
|
|
190
|
-
// The callback page only sends sessionId/accessToken, not user data
|
|
191
|
-
if (session && session.sessionId && !session.user) {
|
|
192
|
-
try {
|
|
193
|
-
const userData = await this.makeRequest<any>(
|
|
194
|
-
'GET',
|
|
195
|
-
`/session/user/${session.sessionId}`,
|
|
196
|
-
undefined,
|
|
197
|
-
{ cache: false }
|
|
198
|
-
);
|
|
199
|
-
if (userData) {
|
|
200
|
-
(session as any).user = userData;
|
|
201
|
-
}
|
|
202
|
-
} catch (userError) {
|
|
203
|
-
debug.warn('Failed to fetch user data:', userError);
|
|
204
|
-
// Continue without user data - caller can fetch separately
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
return session;
|
|
209
|
-
} catch (error) {
|
|
210
|
-
throw error;
|
|
211
|
-
} finally {
|
|
212
|
-
this.clearAuthState(state);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Sign up using popup window
|
|
218
|
-
*
|
|
219
|
-
* Same as signInWithPopup but opens the signup page by default.
|
|
220
|
-
*
|
|
221
|
-
* @param options - Popup configuration options
|
|
222
|
-
* @returns Session with access token and user data
|
|
223
|
-
*/
|
|
224
|
-
async signUpWithPopup(options: PopupAuthOptions = {}): Promise<SessionLoginResponse> {
|
|
225
|
-
return this.signInWithPopup({ ...options, mode: 'signup' });
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Silent sign-in using hidden iframe
|
|
230
|
-
*
|
|
231
|
-
* Attempts to automatically re-authenticate the user without any UI.
|
|
232
|
-
* This is what enables seamless SSO across all Oxy domains.
|
|
233
|
-
*
|
|
234
|
-
* How it works:
|
|
235
|
-
* 1. Creates hidden iframe pointing to auth.oxy.so/silent-auth
|
|
236
|
-
* 2. If user has valid session at auth.oxy.so, it sends token via postMessage
|
|
237
|
-
* 3. If not, iframe responds with null (no error thrown)
|
|
238
|
-
*
|
|
239
|
-
* This should be called on app startup to check for existing sessions.
|
|
240
|
-
*
|
|
241
|
-
* @param options - Silent auth options
|
|
242
|
-
* @returns Session if user is signed in, null otherwise
|
|
243
|
-
*
|
|
244
|
-
* @example
|
|
245
|
-
* ```typescript
|
|
246
|
-
* useEffect(() => {
|
|
247
|
-
* const checkAuth = async () => {
|
|
248
|
-
* const session = await oxyServices.silentSignIn();
|
|
249
|
-
* if (session) {
|
|
250
|
-
* setUser(session.user);
|
|
251
|
-
* }
|
|
252
|
-
* };
|
|
253
|
-
* checkAuth();
|
|
254
|
-
* }, []);
|
|
255
|
-
* ```
|
|
256
|
-
*/
|
|
257
|
-
async silentSignIn(options: SilentAuthOptions = {}): Promise<SessionLoginResponse | null> {
|
|
258
|
-
if (typeof window === 'undefined') {
|
|
259
|
-
return null;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const timeout = options.timeout || (this.constructor as any).SILENT_TIMEOUT;
|
|
263
|
-
const nonce = this.generateNonce();
|
|
264
|
-
const clientId = window.location.origin;
|
|
265
|
-
|
|
266
|
-
// Resolve the IdP origin for the iframe. An explicit per-apex override (the
|
|
267
|
-
// durable cross-domain reload path — see `SilentAuthOptions.authWebUrlOverride`)
|
|
268
|
-
// wins over the instance's configured central auth URL. The SAME origin is
|
|
269
|
-
// handed to `waitForIframeAuth` so the postMessage origin check matches the
|
|
270
|
-
// exact host the iframe was loaded from.
|
|
271
|
-
const authOrigin =
|
|
272
|
-
options.authWebUrlOverride && options.authWebUrlOverride.length > 0
|
|
273
|
-
? options.authWebUrlOverride
|
|
274
|
-
: this.resolveAuthUrl();
|
|
275
|
-
|
|
276
|
-
const iframe = document.createElement('iframe');
|
|
277
|
-
iframe.style.display = 'none';
|
|
278
|
-
iframe.style.position = 'absolute';
|
|
279
|
-
iframe.style.width = '0';
|
|
280
|
-
iframe.style.height = '0';
|
|
281
|
-
iframe.style.border = 'none';
|
|
282
|
-
|
|
283
|
-
const silentUrl = `${authOrigin}/auth/silent?` + `client_id=${encodeURIComponent(clientId)}&` + `nonce=${nonce}`;
|
|
284
|
-
|
|
285
|
-
iframe.src = silentUrl;
|
|
286
|
-
document.body.appendChild(iframe);
|
|
287
|
-
|
|
288
|
-
try {
|
|
289
|
-
const session = await this.waitForIframeAuth(iframe, timeout, authOrigin);
|
|
290
|
-
|
|
291
|
-
// Bail early on incomplete responses. The iframe contract requires
|
|
292
|
-
// both an access token and a session id; anything less is unusable.
|
|
293
|
-
// Returning `null` here (without installing the token) prevents a
|
|
294
|
-
// stale credential from being committed to HttpService when the
|
|
295
|
-
// user is actually signed out — that pattern caused a `getCurrentUser`
|
|
296
|
-
// -> 401 -> token-clear loop in consumer apps because callers gated
|
|
297
|
-
// on `session?.user` and never installed the user via
|
|
298
|
-
// `handleAuthSuccess`, while HttpService quietly held the token.
|
|
299
|
-
const accessToken = session ? (session as { accessToken?: string }).accessToken : undefined;
|
|
300
|
-
if (!session || !accessToken || !session.sessionId) {
|
|
301
|
-
return null;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Snapshot the previous token so we can roll back if the user
|
|
305
|
-
// lookup below fails — this avoids leaving a half-committed session
|
|
306
|
-
// (token installed, user missing) which would let the next
|
|
307
|
-
// authenticated request 401 with no way to recover.
|
|
308
|
-
const previousAccessToken = this.httpService.getAccessToken();
|
|
309
|
-
this.httpService.setTokens(accessToken);
|
|
310
|
-
|
|
311
|
-
// The iframe typically returns `{ sessionId, accessToken }` without
|
|
312
|
-
// user data. Fetch the user explicitly so callers receive a
|
|
313
|
-
// fully-formed session and never need a second `/users/me` round
|
|
314
|
-
// trip. If this fails the session is unusable — revert the token
|
|
315
|
-
// and return null so the caller treats this exactly like a
|
|
316
|
-
// missing-session response.
|
|
317
|
-
if (!session.user) {
|
|
318
|
-
try {
|
|
319
|
-
const userData = await this.makeRequest<unknown>(
|
|
320
|
-
'GET',
|
|
321
|
-
`/session/user/${session.sessionId}`,
|
|
322
|
-
undefined,
|
|
323
|
-
{ cache: false, retry: false }
|
|
324
|
-
);
|
|
325
|
-
if (!userData) {
|
|
326
|
-
throw new Error('Empty user response');
|
|
327
|
-
}
|
|
328
|
-
(session as { user?: unknown }).user = userData;
|
|
329
|
-
} catch (userError) {
|
|
330
|
-
debug.warn('silentSignIn: failed to fetch user data, rolling back token', userError);
|
|
331
|
-
if (previousAccessToken) {
|
|
332
|
-
this.httpService.setTokens(previousAccessToken);
|
|
333
|
-
} else {
|
|
334
|
-
this.httpService.clearTokens();
|
|
335
|
-
}
|
|
336
|
-
return null;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return session;
|
|
341
|
-
} catch (error) {
|
|
342
|
-
return null;
|
|
343
|
-
} finally {
|
|
344
|
-
document.body.removeChild(iframe);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Open a blank, centered popup window SYNCHRONOUSLY.
|
|
350
|
-
*
|
|
351
|
-
* Use this in a click (or other user-gesture) handler BEFORE any `await`
|
|
352
|
-
* to capture the transient user-activation. Pass the returned handle into
|
|
353
|
-
* `signInWithPopup({ popup })` once the async portion of the flow runs.
|
|
354
|
-
*
|
|
355
|
-
* Returns `null` if the browser's popup blocker rejected the open.
|
|
356
|
-
*
|
|
357
|
-
* @example
|
|
358
|
-
* ```typescript
|
|
359
|
-
* const onSignInClick = () => {
|
|
360
|
-
* const popup = oxyServices.openBlankPopup();
|
|
361
|
-
* (async () => {
|
|
362
|
-
* const silent = await oxyServices.silentSignInWithFedCM();
|
|
363
|
-
* if (silent) { popup?.close(); return; }
|
|
364
|
-
* await oxyServices.signInWithPopup({ popup });
|
|
365
|
-
* })();
|
|
366
|
-
* };
|
|
367
|
-
* ```
|
|
368
|
-
*/
|
|
369
|
-
public openBlankPopup(width?: number, height?: number): Window | null {
|
|
370
|
-
if (typeof window === 'undefined') {
|
|
371
|
-
return null;
|
|
372
|
-
}
|
|
373
|
-
const ctor = this.constructor as unknown as { POPUP_WIDTH: number; POPUP_HEIGHT: number };
|
|
374
|
-
const w = width ?? ctor.POPUP_WIDTH;
|
|
375
|
-
const h = height ?? ctor.POPUP_HEIGHT;
|
|
376
|
-
return this.openCenteredPopup('about:blank', 'Oxy Sign In', w, h);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Open a centered popup window
|
|
381
|
-
*
|
|
382
|
-
* @private
|
|
383
|
-
*/
|
|
384
|
-
public openCenteredPopup(url: string, title: string, width: number, height: number): Window | null {
|
|
385
|
-
const left = window.screenX + (window.outerWidth - width) / 2;
|
|
386
|
-
const top = window.screenY + (window.outerHeight - height) / 2;
|
|
387
|
-
|
|
388
|
-
const features = [
|
|
389
|
-
`width=${width}`,
|
|
390
|
-
`height=${height}`,
|
|
391
|
-
`left=${left}`,
|
|
392
|
-
`top=${top}`,
|
|
393
|
-
'toolbar=no',
|
|
394
|
-
'menubar=no',
|
|
395
|
-
'scrollbars=yes',
|
|
396
|
-
'resizable=yes',
|
|
397
|
-
'status=no',
|
|
398
|
-
'location=no',
|
|
399
|
-
].join(',');
|
|
400
|
-
|
|
401
|
-
return window.open(url, title, features);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Wait for authentication response from popup
|
|
406
|
-
*
|
|
407
|
-
* @private
|
|
408
|
-
*/
|
|
409
|
-
public async waitForPopupAuth(
|
|
410
|
-
popup: Window,
|
|
411
|
-
expectedState: string,
|
|
412
|
-
timeout: number
|
|
413
|
-
): Promise<SessionLoginResponse> {
|
|
414
|
-
return new Promise((resolve, reject) => {
|
|
415
|
-
const timeoutId = setTimeout(() => {
|
|
416
|
-
cleanup();
|
|
417
|
-
reject(new OxyAuthenticationError('Authentication timeout'));
|
|
418
|
-
}, timeout);
|
|
419
|
-
|
|
420
|
-
const messageHandler = (event: MessageEvent) => {
|
|
421
|
-
const authUrl = this.resolveAuthUrl();
|
|
422
|
-
|
|
423
|
-
// Log all messages for debugging
|
|
424
|
-
if (event.data && typeof event.data === 'object' && event.data.type) {
|
|
425
|
-
debug.log('Message received:', {
|
|
426
|
-
origin: event.origin,
|
|
427
|
-
expectedOrigin: authUrl,
|
|
428
|
-
type: event.data.type,
|
|
429
|
-
hasSession: !!event.data.session,
|
|
430
|
-
hasError: !!event.data.error,
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// CRITICAL: Verify origin to prevent XSS attacks
|
|
435
|
-
if (event.origin !== authUrl) {
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const { type, state, session, error } = event.data;
|
|
440
|
-
|
|
441
|
-
if (type !== 'oxy_auth_response') {
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
debug.log('Valid auth response:', { state, expectedState, hasSession: !!session, error });
|
|
446
|
-
|
|
447
|
-
// Verify state parameter to prevent CSRF attacks
|
|
448
|
-
if (state !== expectedState) {
|
|
449
|
-
cleanup();
|
|
450
|
-
debug.error('State mismatch');
|
|
451
|
-
reject(new OxyAuthenticationError('Invalid state parameter. Possible CSRF attack.'));
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
cleanup();
|
|
456
|
-
|
|
457
|
-
if (error) {
|
|
458
|
-
debug.error('Auth error:', error);
|
|
459
|
-
reject(new OxyAuthenticationError(error));
|
|
460
|
-
} else if (session) {
|
|
461
|
-
debug.log('Session received successfully');
|
|
462
|
-
resolve(session);
|
|
463
|
-
} else {
|
|
464
|
-
debug.error('No session in response');
|
|
465
|
-
reject(new OxyAuthenticationError('No session received from authentication server'));
|
|
466
|
-
}
|
|
467
|
-
};
|
|
468
|
-
|
|
469
|
-
// Poll to detect if user closed the popup
|
|
470
|
-
const pollInterval = setInterval(() => {
|
|
471
|
-
if (popup.closed) {
|
|
472
|
-
cleanup();
|
|
473
|
-
reject(new OxyAuthenticationError('Authentication cancelled by user'));
|
|
474
|
-
}
|
|
475
|
-
}, 500);
|
|
476
|
-
|
|
477
|
-
const cleanup = () => {
|
|
478
|
-
clearTimeout(timeoutId);
|
|
479
|
-
clearInterval(pollInterval);
|
|
480
|
-
window.removeEventListener('message', messageHandler);
|
|
481
|
-
if (!popup.closed) {
|
|
482
|
-
popup.close();
|
|
483
|
-
}
|
|
484
|
-
};
|
|
485
|
-
|
|
486
|
-
window.addEventListener('message', messageHandler);
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* Wait for authentication response from iframe
|
|
492
|
-
*
|
|
493
|
-
* @private
|
|
494
|
-
*/
|
|
495
|
-
public async waitForIframeAuth(
|
|
496
|
-
iframe: HTMLIFrameElement,
|
|
497
|
-
timeout: number,
|
|
498
|
-
expectedOrigin: string
|
|
499
|
-
): Promise<SessionLoginResponse | null> {
|
|
500
|
-
return new Promise((resolve) => {
|
|
501
|
-
const timeoutId = setTimeout(() => {
|
|
502
|
-
cleanup();
|
|
503
|
-
resolve(null); // Silent failure - don't throw
|
|
504
|
-
}, timeout);
|
|
505
|
-
|
|
506
|
-
const messageHandler = (event: MessageEvent) => {
|
|
507
|
-
// Verify origin against the EXACT host the iframe was loaded from
|
|
508
|
-
// (`expectedOrigin`). For the per-apex durable-restore path this is
|
|
509
|
-
// `auth.<rp-apex>`, not the instance's central `resolveAuthUrl()` — so
|
|
510
|
-
// we must honour the caller-supplied origin, never re-derive it here.
|
|
511
|
-
if (event.origin !== expectedOrigin) {
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
const { type, session } = event.data;
|
|
516
|
-
|
|
517
|
-
if (type !== 'oxy_silent_auth') {
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
cleanup();
|
|
522
|
-
resolve(session || null);
|
|
523
|
-
};
|
|
524
|
-
|
|
525
|
-
// Fail-fast on a load failure. When the per-apex `/auth/silent` host is
|
|
526
|
-
// unreachable, blocked by CSP `frame-ancestors`/`X-Frame-Options`, or the
|
|
527
|
-
// network drops, the iframe never posts a message — without this handler
|
|
528
|
-
// the silent restore would block for the FULL `timeout` (dead latency in
|
|
529
|
-
// the cold-boot critical path). `onerror`/`onabort` fire on a failed load,
|
|
530
|
-
// so resolve `null` immediately and let the next cold-boot step run. The
|
|
531
|
-
// success path posts a message and is handled above; these only catch the
|
|
532
|
-
// no-message failure modes.
|
|
533
|
-
const failFast = () => {
|
|
534
|
-
cleanup();
|
|
535
|
-
resolve(null);
|
|
536
|
-
};
|
|
537
|
-
iframe.onerror = failFast;
|
|
538
|
-
iframe.onabort = failFast;
|
|
539
|
-
|
|
540
|
-
const cleanup = () => {
|
|
541
|
-
clearTimeout(timeoutId);
|
|
542
|
-
iframe.onerror = null;
|
|
543
|
-
iframe.onabort = null;
|
|
544
|
-
window.removeEventListener('message', messageHandler);
|
|
545
|
-
};
|
|
546
|
-
|
|
547
|
-
window.addEventListener('message', messageHandler);
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
/**
|
|
552
|
-
* Build authentication URL with query parameters
|
|
553
|
-
*
|
|
554
|
-
* @private
|
|
555
|
-
*/
|
|
556
|
-
public buildAuthUrl(params: {
|
|
557
|
-
mode: string;
|
|
558
|
-
state: string;
|
|
559
|
-
nonce: string;
|
|
560
|
-
clientId: string;
|
|
561
|
-
redirectUri: string;
|
|
562
|
-
}): string {
|
|
563
|
-
const url = new URL(`${this.resolveAuthUrl()}/${params.mode}`);
|
|
564
|
-
url.searchParams.set('response_type', 'token');
|
|
565
|
-
url.searchParams.set('client_id', params.clientId);
|
|
566
|
-
url.searchParams.set('redirect_uri', params.redirectUri);
|
|
567
|
-
url.searchParams.set('state', params.state);
|
|
568
|
-
url.searchParams.set('nonce', params.nonce);
|
|
569
|
-
return url.toString();
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/**
|
|
573
|
-
* Generate cryptographically secure state for CSRF protection
|
|
574
|
-
*
|
|
575
|
-
* @private
|
|
576
|
-
*/
|
|
577
|
-
public generateState(): string {
|
|
578
|
-
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
579
|
-
return crypto.randomUUID();
|
|
580
|
-
}
|
|
581
|
-
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
582
|
-
const bytes = new Uint8Array(16);
|
|
583
|
-
crypto.getRandomValues(bytes);
|
|
584
|
-
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
585
|
-
}
|
|
586
|
-
throw new Error('No secure random source available for state generation');
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Generate nonce for replay attack prevention
|
|
591
|
-
*
|
|
592
|
-
* @private
|
|
593
|
-
*/
|
|
594
|
-
public generateNonce(): string {
|
|
595
|
-
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
596
|
-
return crypto.randomUUID();
|
|
597
|
-
}
|
|
598
|
-
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
599
|
-
const bytes = new Uint8Array(16);
|
|
600
|
-
crypto.getRandomValues(bytes);
|
|
601
|
-
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
602
|
-
}
|
|
603
|
-
throw new Error('No secure random source available for nonce generation');
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/**
|
|
607
|
-
* Store auth state in session storage
|
|
608
|
-
*
|
|
609
|
-
* @private
|
|
610
|
-
*/
|
|
611
|
-
public storeAuthState(state: string, nonce: string): void {
|
|
612
|
-
if (typeof window !== 'undefined' && window.sessionStorage) {
|
|
613
|
-
sessionStorage.setItem(`oxy_auth_state_${state}`, JSON.stringify({ nonce, timestamp: Date.now() }));
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
/**
|
|
618
|
-
* Clear auth state from session storage
|
|
619
|
-
*
|
|
620
|
-
* @private
|
|
621
|
-
*/
|
|
622
|
-
public clearAuthState(state: string): void {
|
|
623
|
-
if (typeof window !== 'undefined' && window.sessionStorage) {
|
|
624
|
-
sessionStorage.removeItem(`oxy_auth_state_${state}`);
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// Export the mixin function as both named and default
|
|
631
|
-
export { OxyServicesPopupAuthMixin as PopupAuthMixin };
|