@oxyhq/core 3.4.1 → 3.4.3
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 +24 -2
- 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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { OxyServicesBase } from "../OxyServices.base";
|
|
2
|
+
import type { SessionLoginResponse } from "../models/session";
|
|
3
|
+
import { createDebugLogger } from "../shared/utils/debugUtils";
|
|
4
|
+
|
|
5
|
+
const debug = createDebugLogger("SilentAuth");
|
|
6
|
+
|
|
7
|
+
export interface SilentAuthOptions {
|
|
8
|
+
timeout?: number;
|
|
9
|
+
/**
|
|
10
|
+
* Override the auth-web (IdP) origin used for the silent iframe, instead of
|
|
11
|
+
* the instance's configured `resolveAuthUrl()`.
|
|
12
|
+
*
|
|
13
|
+
* Why this exists: an instance configured with the CENTRAL IdP
|
|
14
|
+
* (`authWebUrl=https://auth.oxy.so`, for the opaque-code `/sso` bounce and
|
|
15
|
+
* FedCM) cannot read the DURABLE per-apex `fedcm_session` cookie via the
|
|
16
|
+
* central host — that cookie is first-party only on `auth.<rp-apex>` (e.g.
|
|
17
|
+
* `auth.mention.earth`). The cross-domain reload-restore path must point the
|
|
18
|
+
* `/auth/silent` iframe at the PER-APEX host so the cookie is same-site to
|
|
19
|
+
* the RP page (first-party under Safari ITP / Firefox TCP) and the restore
|
|
20
|
+
* is NOT a top-level navigation (no flash, works in a backgrounded tab).
|
|
21
|
+
*
|
|
22
|
+
* When provided this value is used BOTH for the iframe `src` AND for the
|
|
23
|
+
* `postMessage` origin validation in {@link waitForIframeAuth}, so the
|
|
24
|
+
* security check still matches the exact origin the iframe was loaded from.
|
|
25
|
+
* Must be an absolute origin (`https://auth.<apex>`); ignored if empty.
|
|
26
|
+
*/
|
|
27
|
+
authWebUrlOverride?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Cross-domain silent browser auth helpers.
|
|
32
|
+
*
|
|
33
|
+
* The clean session model supports FedCM, tokenless redirect SSO, and silent
|
|
34
|
+
* iframe SSO. Bearer-token callback URLs are not part of this surface.
|
|
35
|
+
*/
|
|
36
|
+
export function OxyServicesSilentAuthMixin<T extends typeof OxyServicesBase>(
|
|
37
|
+
Base: T,
|
|
38
|
+
) {
|
|
39
|
+
return class extends Base {
|
|
40
|
+
constructor(...args: any[]) {
|
|
41
|
+
super(...(args as [any]));
|
|
42
|
+
}
|
|
43
|
+
public static readonly DEFAULT_AUTH_URL = "https://auth.oxy.so";
|
|
44
|
+
|
|
45
|
+
/** Resolve auth URL from config or static default (method, not getter — getters break in TS mixins) */
|
|
46
|
+
public resolveAuthUrl(): string {
|
|
47
|
+
return (
|
|
48
|
+
this.config.authWebUrl || (this.constructor as any).DEFAULT_AUTH_URL
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public static readonly SILENT_TIMEOUT = 5000; // 5 seconds
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Silent sign-in using hidden iframe
|
|
56
|
+
*
|
|
57
|
+
* Attempts to automatically re-authenticate the user without any UI.
|
|
58
|
+
* This is what enables seamless SSO across all Oxy domains.
|
|
59
|
+
*
|
|
60
|
+
* How it works:
|
|
61
|
+
* 1. Creates hidden iframe pointing to auth.oxy.so/silent-auth
|
|
62
|
+
* 2. If user has valid session at auth.oxy.so, it exchanges an opaque SSO code
|
|
63
|
+
* 3. If not, iframe responds with null (no error thrown)
|
|
64
|
+
*
|
|
65
|
+
* This should be called on app startup to check for existing sessions.
|
|
66
|
+
*
|
|
67
|
+
* @param options - Silent auth options
|
|
68
|
+
* @returns Session if user is signed in, null otherwise
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```typescript
|
|
72
|
+
* useEffect(() => {
|
|
73
|
+
* const checkAuth = async () => {
|
|
74
|
+
* const session = await oxyServices.silentSignIn();
|
|
75
|
+
* if (session) {
|
|
76
|
+
* setUser(session.user);
|
|
77
|
+
* }
|
|
78
|
+
* };
|
|
79
|
+
* checkAuth();
|
|
80
|
+
* }, []);
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
async silentSignIn(
|
|
84
|
+
options: SilentAuthOptions = {},
|
|
85
|
+
): Promise<SessionLoginResponse | null> {
|
|
86
|
+
if (typeof window === "undefined") {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const timeout =
|
|
91
|
+
options.timeout || (this.constructor as any).SILENT_TIMEOUT;
|
|
92
|
+
const nonce = this.generateNonce();
|
|
93
|
+
const clientId = window.location.origin;
|
|
94
|
+
|
|
95
|
+
// Resolve the IdP origin for the iframe. An explicit per-apex override (the
|
|
96
|
+
// durable cross-domain reload path — see `SilentAuthOptions.authWebUrlOverride`)
|
|
97
|
+
// wins over the instance's configured central auth URL. The SAME origin is
|
|
98
|
+
// handed to `waitForIframeAuth` so the postMessage origin check matches the
|
|
99
|
+
// exact host the iframe was loaded from.
|
|
100
|
+
const authOrigin =
|
|
101
|
+
options.authWebUrlOverride && options.authWebUrlOverride.length > 0
|
|
102
|
+
? options.authWebUrlOverride
|
|
103
|
+
: this.resolveAuthUrl();
|
|
104
|
+
|
|
105
|
+
const iframe = document.createElement("iframe");
|
|
106
|
+
iframe.style.display = "none";
|
|
107
|
+
iframe.style.position = "absolute";
|
|
108
|
+
iframe.style.width = "0";
|
|
109
|
+
iframe.style.height = "0";
|
|
110
|
+
iframe.style.border = "none";
|
|
111
|
+
|
|
112
|
+
const silentUrl =
|
|
113
|
+
`${authOrigin}/auth/silent?` +
|
|
114
|
+
`client_id=${encodeURIComponent(clientId)}&` +
|
|
115
|
+
`nonce=${nonce}`;
|
|
116
|
+
|
|
117
|
+
iframe.src = silentUrl;
|
|
118
|
+
document.body.appendChild(iframe);
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const session = await this.waitForIframeAuth(
|
|
122
|
+
iframe,
|
|
123
|
+
timeout,
|
|
124
|
+
authOrigin,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Bail early on incomplete responses. The iframe contract requires
|
|
128
|
+
// both an access token and a session id; anything less is unusable.
|
|
129
|
+
// Returning `null` here (without installing the token) prevents a
|
|
130
|
+
// stale credential from being committed to HttpService when the
|
|
131
|
+
// user is actually signed out — that pattern caused a `getCurrentUser`
|
|
132
|
+
// -> 401 -> token-clear loop in consumer apps because callers gated
|
|
133
|
+
// on `session?.user` and never installed the user via
|
|
134
|
+
// `handleAuthSuccess`, while HttpService quietly held the token.
|
|
135
|
+
const accessToken = session
|
|
136
|
+
? (session as { accessToken?: string }).accessToken
|
|
137
|
+
: undefined;
|
|
138
|
+
if (!session || !accessToken || !session.sessionId) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Snapshot the previous token so we can roll back if the user
|
|
143
|
+
// lookup below fails — this avoids leaving a half-committed session
|
|
144
|
+
// (token installed, user missing) which would let the next
|
|
145
|
+
// authenticated request 401 with no way to recover.
|
|
146
|
+
const previousAccessToken = this.httpService.getAccessToken();
|
|
147
|
+
this.httpService.setTokens(accessToken);
|
|
148
|
+
|
|
149
|
+
// The iframe typically returns `{ sessionId, accessToken }` without
|
|
150
|
+
// user data. Fetch the user explicitly so callers receive a
|
|
151
|
+
// fully-formed session and never need a second `/users/me` round
|
|
152
|
+
// trip. If this fails the session is unusable — revert the token
|
|
153
|
+
// and return null so the caller treats this exactly like a
|
|
154
|
+
// missing-session response.
|
|
155
|
+
if (!session.user) {
|
|
156
|
+
try {
|
|
157
|
+
const userData = await this.makeRequest<unknown>(
|
|
158
|
+
"GET",
|
|
159
|
+
`/session/user/${session.sessionId}`,
|
|
160
|
+
undefined,
|
|
161
|
+
{ cache: false, retry: false },
|
|
162
|
+
);
|
|
163
|
+
if (!userData) {
|
|
164
|
+
throw new Error("Empty user response");
|
|
165
|
+
}
|
|
166
|
+
(session as { user?: unknown }).user = userData;
|
|
167
|
+
} catch (userError) {
|
|
168
|
+
debug.warn(
|
|
169
|
+
"silentSignIn: failed to fetch user data, rolling back token",
|
|
170
|
+
userError,
|
|
171
|
+
);
|
|
172
|
+
if (previousAccessToken) {
|
|
173
|
+
this.httpService.setTokens(previousAccessToken);
|
|
174
|
+
} else {
|
|
175
|
+
this.httpService.clearTokens();
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return session;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return null;
|
|
184
|
+
} finally {
|
|
185
|
+
document.body.removeChild(iframe);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Wait for authentication response from iframe
|
|
191
|
+
*
|
|
192
|
+
* @private
|
|
193
|
+
*/
|
|
194
|
+
public async waitForIframeAuth(
|
|
195
|
+
iframe: HTMLIFrameElement,
|
|
196
|
+
timeout: number,
|
|
197
|
+
expectedOrigin: string,
|
|
198
|
+
): Promise<SessionLoginResponse | null> {
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
const timeoutId = setTimeout(() => {
|
|
201
|
+
cleanup();
|
|
202
|
+
resolve(null); // Silent failure - don't throw
|
|
203
|
+
}, timeout);
|
|
204
|
+
|
|
205
|
+
const messageHandler = (event: MessageEvent) => {
|
|
206
|
+
// Verify origin against the EXACT host the iframe was loaded from
|
|
207
|
+
// (`expectedOrigin`). For the per-apex durable-restore path this is
|
|
208
|
+
// `auth.<rp-apex>`, not the instance's central `resolveAuthUrl()` — so
|
|
209
|
+
// we must honour the caller-supplied origin, never re-derive it here.
|
|
210
|
+
if (event.origin !== expectedOrigin) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const { type, session } = event.data;
|
|
215
|
+
|
|
216
|
+
if (type !== "oxy_silent_auth") {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
cleanup();
|
|
221
|
+
resolve(session || null);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Fail-fast on a load failure. When the per-apex `/auth/silent` host is
|
|
225
|
+
// unreachable, blocked by CSP `frame-ancestors`/`X-Frame-Options`, or the
|
|
226
|
+
// network drops, the iframe never posts a message — without this handler
|
|
227
|
+
// the silent restore would block for the FULL `timeout` (dead latency in
|
|
228
|
+
// the cold-boot critical path). `onerror`/`onabort` fire on a failed load,
|
|
229
|
+
// so resolve `null` immediately and let the next cold-boot step run. The
|
|
230
|
+
// success path posts a message and is handled above; these only catch the
|
|
231
|
+
// no-message failure modes.
|
|
232
|
+
const failFast = () => {
|
|
233
|
+
cleanup();
|
|
234
|
+
resolve(null);
|
|
235
|
+
};
|
|
236
|
+
iframe.onerror = failFast;
|
|
237
|
+
iframe.onabort = failFast;
|
|
238
|
+
|
|
239
|
+
const cleanup = () => {
|
|
240
|
+
clearTimeout(timeoutId);
|
|
241
|
+
iframe.onerror = null;
|
|
242
|
+
iframe.onabort = null;
|
|
243
|
+
window.removeEventListener("message", messageHandler);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
window.addEventListener("message", messageHandler);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Generate nonce for replay attack prevention
|
|
252
|
+
*
|
|
253
|
+
* @private
|
|
254
|
+
*/
|
|
255
|
+
public generateNonce(): string {
|
|
256
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
257
|
+
return crypto.randomUUID();
|
|
258
|
+
}
|
|
259
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
260
|
+
const bytes = new Uint8Array(16);
|
|
261
|
+
crypto.getRandomValues(bytes);
|
|
262
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
|
|
263
|
+
"",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
throw new Error("No secure random source available for nonce generation");
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Export the mixin function as both named and default
|
|
272
|
+
export { OxyServicesSilentAuthMixin as SilentAuthMixin };
|
|
@@ -31,7 +31,7 @@ const debug = createDebugLogger('SSO');
|
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Wire shape of `POST /sso/exchange`. `expiresAt` and `authuser` are optional
|
|
34
|
-
*
|
|
34
|
+
* because the central SSO store may omit them.
|
|
35
35
|
*/
|
|
36
36
|
interface SsoExchangeWireResponse {
|
|
37
37
|
accessToken: string;
|
|
@@ -51,8 +51,7 @@ interface SsoExchangeWireResponse {
|
|
|
51
51
|
*
|
|
52
52
|
* Exposed as a module-level helper (in addition to the instance method below)
|
|
53
53
|
* so consumers that do not yet hold an `OxyServices` instance can still mint a
|
|
54
|
-
* bounce state.
|
|
55
|
-
* mixin uses for its CSRF state, with a `getRandomValues` fallback.
|
|
54
|
+
* bounce state. Uses `crypto.randomUUID` with a `getRandomValues` fallback.
|
|
56
55
|
*/
|
|
57
56
|
export function generateSsoState(): string {
|
|
58
57
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
@@ -75,8 +74,8 @@ export function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
|
75
74
|
/**
|
|
76
75
|
* Generate cryptographically secure state for the SSO bounce (CSRF
|
|
77
76
|
* protection). Delegates to the module-level {@link generateSsoState}
|
|
78
|
-
* helper, which uses
|
|
79
|
-
*
|
|
77
|
+
* helper, which uses `crypto.randomUUID` when available and falls back to
|
|
78
|
+
* `crypto.getRandomValues`.
|
|
80
79
|
*/
|
|
81
80
|
public generateSsoState(): string {
|
|
82
81
|
return generateSsoState();
|
|
@@ -154,7 +153,7 @@ export function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
|
154
153
|
// Plant the access token exactly like exchangeIdTokenForSession does.
|
|
155
154
|
// The SSO exchange does not return a refresh token (the central store
|
|
156
155
|
// holds the refresh credential), so default it to an empty string.
|
|
157
|
-
this.httpService.setTokens(payload.accessToken
|
|
156
|
+
this.httpService.setTokens(payload.accessToken);
|
|
158
157
|
|
|
159
158
|
debug.log('SSO exchange complete:', { hasSession: !!payload.sessionId });
|
|
160
159
|
|
|
@@ -62,13 +62,8 @@ export interface ServiceApp {
|
|
|
62
62
|
appId: string;
|
|
63
63
|
appName: string;
|
|
64
64
|
scopes: string[];
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
* Carried by newer service-token JWTs alongside `appId`; absent on tokens
|
|
68
|
-
* issued before credential-level audit linking. Use for per-credential audit
|
|
69
|
-
* trails and rotation alignment (GitHub #215).
|
|
70
|
-
*/
|
|
71
|
-
credentialId?: string;
|
|
65
|
+
/** The credentialId of the specific service credential that minted this token. */
|
|
66
|
+
credentialId: string;
|
|
72
67
|
}
|
|
73
68
|
|
|
74
69
|
/**
|
|
@@ -409,19 +404,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
409
404
|
};
|
|
410
405
|
|
|
411
406
|
try {
|
|
412
|
-
// Extract token from Authorization header
|
|
407
|
+
// Extract token from Authorization header.
|
|
413
408
|
// Node/Express normalizes `Authorization` to a string; we guard
|
|
414
409
|
// against the (legal but unusual) string[] case anyway.
|
|
415
410
|
const rawAuthHeader = req.headers.authorization;
|
|
416
411
|
const authHeader = Array.isArray(rawAuthHeader) ? rawAuthHeader[0] : rawAuthHeader;
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
// Fallback to query params (useful for WebSocket upgrades)
|
|
420
|
-
if (!token) {
|
|
421
|
-
const q = req.query || {};
|
|
422
|
-
if (typeof q.token === 'string' && q.token) token = q.token;
|
|
423
|
-
else if (typeof q.access_token === 'string' && q.access_token) token = q.access_token;
|
|
424
|
-
}
|
|
412
|
+
const token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
|
|
425
413
|
|
|
426
414
|
if (debug) {
|
|
427
415
|
logger.debug(`[oxy.auth] ${req.method} ${req.path} | token: ${!!token}`, {
|
|
@@ -573,13 +561,14 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
573
561
|
|
|
574
562
|
// Validate required service token fields
|
|
575
563
|
const appId = decoded.appId;
|
|
576
|
-
|
|
564
|
+
const credentialId = decoded.credentialId;
|
|
565
|
+
if (!appId || typeof credentialId !== 'string' || credentialId.length === 0) {
|
|
577
566
|
if (optional) {
|
|
578
567
|
req.userId = null;
|
|
579
568
|
req.user = null;
|
|
580
569
|
return next();
|
|
581
570
|
}
|
|
582
|
-
const error = { error: 'INVALID_SERVICE_TOKEN', message: 'Invalid service token: missing
|
|
571
|
+
const error = { error: 'INVALID_SERVICE_TOKEN', message: 'Invalid service token: missing required claims', code: 'INVALID_SERVICE_TOKEN', status: 401 };
|
|
583
572
|
if (onError) return onError(error);
|
|
584
573
|
return res.status(401).json(error);
|
|
585
574
|
}
|
|
@@ -625,10 +614,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
625
614
|
req.serviceApp = {
|
|
626
615
|
appId,
|
|
627
616
|
appName: decoded.appName || 'unknown',
|
|
617
|
+
credentialId,
|
|
628
618
|
scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
|
|
629
|
-
...(typeof decoded.credentialId === 'string' && decoded.credentialId.length > 0
|
|
630
|
-
? { credentialId: decoded.credentialId }
|
|
631
|
-
: {}),
|
|
632
619
|
};
|
|
633
620
|
|
|
634
621
|
if (debug) {
|
|
@@ -1133,7 +1120,7 @@ interface OxyAuthInstance {
|
|
|
1133
1120
|
[key: string]: unknown;
|
|
1134
1121
|
} | null>;
|
|
1135
1122
|
getAccessToken(): string | null;
|
|
1136
|
-
setTokens(accessToken: string
|
|
1123
|
+
setTokens(accessToken: string): void;
|
|
1137
1124
|
clearTokens(): void;
|
|
1138
1125
|
getCurrentUser(): Promise<User | null>;
|
|
1139
1126
|
handleError(error: unknown): Error;
|
|
@@ -19,7 +19,7 @@ const setAccessTokenForTest = (oxy: OxyServices): void => {
|
|
|
19
19
|
// `withAuthRetry` loop polls. Reaching in via the public httpService and
|
|
20
20
|
// calling setTokens with a dummy avoids us needing to expose new test
|
|
21
21
|
// hooks just for this.
|
|
22
|
-
oxy.httpService.setTokens('test-token'
|
|
22
|
+
oxy.httpService.setTokens('test-token');
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
describe('OxyServices.appData', () => {
|
|
@@ -29,7 +29,7 @@ describe('OxyServices.onTokensChanged', () => {
|
|
|
29
29
|
const listener = jest.fn();
|
|
30
30
|
oxy.onTokensChanged(listener);
|
|
31
31
|
|
|
32
|
-
oxy.setTokens('access_1'
|
|
32
|
+
oxy.setTokens('access_1');
|
|
33
33
|
|
|
34
34
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
35
35
|
expect(listener).toHaveBeenCalledWith('access_1');
|
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
} from '../OxyServices.reputation';
|
|
21
21
|
|
|
22
22
|
const setAccessTokenForTest = (oxy: OxyServices): void => {
|
|
23
|
-
oxy.httpService.setTokens('test-token'
|
|
23
|
+
oxy.httpService.setTokens('test-token');
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
const balanceFixture: ReputationBalance = {
|
|
@@ -48,6 +48,7 @@ const signServiceToken = (claims: ServiceTokenClaims, secret: string): string =>
|
|
|
48
48
|
type: 'service',
|
|
49
49
|
aud: 'oxy-api',
|
|
50
50
|
iss: 'oxy-auth',
|
|
51
|
+
credentialId: 'cred-1',
|
|
51
52
|
...claims,
|
|
52
53
|
};
|
|
53
54
|
const headerB64 = b64url(JSON.stringify(header));
|
|
@@ -180,6 +181,7 @@ describe('C3: service-token acting-as enforcement', () => {
|
|
|
180
181
|
expect(req.serviceApp).toEqual({
|
|
181
182
|
appId: 'app-1',
|
|
182
183
|
appName: 'trusted-service',
|
|
184
|
+
credentialId: 'cred-1',
|
|
183
185
|
scopes: ['user:read'],
|
|
184
186
|
});
|
|
185
187
|
});
|
|
@@ -202,7 +204,7 @@ describe('C3: service-token acting-as enforcement', () => {
|
|
|
202
204
|
expect(verifySpy).not.toHaveBeenCalled();
|
|
203
205
|
expect(next).toHaveBeenCalledTimes(1);
|
|
204
206
|
expect(req.userId).toBeNull();
|
|
205
|
-
expect(req.serviceApp).toMatchObject({ appId: 'app-1' });
|
|
207
|
+
expect(req.serviceApp).toMatchObject({ appId: 'app-1', credentialId: 'cred-1' });
|
|
206
208
|
});
|
|
207
209
|
|
|
208
210
|
it('caches positive grants per (appId, userId) — avoids hammering verify endpoint', async () => {
|
|
@@ -620,7 +622,7 @@ describe('H4: aud / iss / type claim verification', () => {
|
|
|
620
622
|
await mw(req as unknown as never, res as unknown as never, next as unknown as never);
|
|
621
623
|
|
|
622
624
|
expect(next).toHaveBeenCalledTimes(1);
|
|
623
|
-
expect(req.serviceApp).toMatchObject({ appId: 'a' });
|
|
625
|
+
expect(req.serviceApp).toMatchObject({ appId: 'a', credentialId: 'cred-1' });
|
|
624
626
|
});
|
|
625
627
|
|
|
626
628
|
it('honors expectedAudience and expectedIssuer overrides', async () => {
|
|
@@ -660,7 +662,7 @@ describe('requireScope() middleware', () => {
|
|
|
660
662
|
// Simulate a fully-authenticated service request — auth() has already
|
|
661
663
|
// attached `serviceApp`. requireScope() only reads from that field.
|
|
662
664
|
});
|
|
663
|
-
req.serviceApp = { appId: 'a', appName: 'svc', scopes: ['files:write'] };
|
|
665
|
+
req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['files:write'] };
|
|
664
666
|
const res = makeRes();
|
|
665
667
|
const next = jest.fn();
|
|
666
668
|
|
|
@@ -672,7 +674,7 @@ describe('requireScope() middleware', () => {
|
|
|
672
674
|
|
|
673
675
|
it('allows requests where the delegation grant carries the required scope', () => {
|
|
674
676
|
const req = makeReq();
|
|
675
|
-
req.serviceApp = { appId: 'a', appName: 'svc', scopes: [] };
|
|
677
|
+
req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: [] };
|
|
676
678
|
req.serviceActingAs = { userId: 'u-1', scopes: ['user:read'] };
|
|
677
679
|
const res = makeRes();
|
|
678
680
|
const next = jest.fn();
|
|
@@ -684,7 +686,7 @@ describe('requireScope() middleware', () => {
|
|
|
684
686
|
|
|
685
687
|
it('rejects requests missing the required scope with 403', () => {
|
|
686
688
|
const req = makeReq();
|
|
687
|
-
req.serviceApp = { appId: 'a', appName: 'svc', scopes: ['user:read'] };
|
|
689
|
+
req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['user:read'] };
|
|
688
690
|
const res = makeRes();
|
|
689
691
|
const next = jest.fn();
|
|
690
692
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silent iframe auth tests.
|
|
3
|
+
*
|
|
4
|
+
* The cross-domain durable-restore iframe (`/auth/silent` at the per-apex host)
|
|
5
|
+
* posts a message on success. On a failed load, it never posts, so
|
|
6
|
+
* `waitForIframeAuth` must resolve `null` immediately instead of waiting for
|
|
7
|
+
* the full timeout.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { OxyServices } from '../../OxyServices';
|
|
11
|
+
|
|
12
|
+
const ORIGIN = 'https://mention.earth';
|
|
13
|
+
|
|
14
|
+
function installBrowserGlobals(options: {
|
|
15
|
+
postMessageDispatcher?: { current: ((event: { origin: string; data: unknown }) => void) | null };
|
|
16
|
+
} = {}): void {
|
|
17
|
+
const store = new Map<string, string>();
|
|
18
|
+
const sessionStorageStub = {
|
|
19
|
+
getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
|
|
20
|
+
setItem: (k: string, v: string) => { store.set(k, v); },
|
|
21
|
+
removeItem: (k: string) => { store.delete(k); },
|
|
22
|
+
};
|
|
23
|
+
const messageHandlers: Array<(event: { origin: string; data: unknown }) => void> = [];
|
|
24
|
+
const win = {
|
|
25
|
+
location: { origin: ORIGIN, hostname: 'mention.earth' },
|
|
26
|
+
sessionStorage: sessionStorageStub,
|
|
27
|
+
addEventListener: (event: string, handler: (e: { origin: string; data: unknown }) => void) => {
|
|
28
|
+
if (event === 'message') {
|
|
29
|
+
messageHandlers.push(handler);
|
|
30
|
+
if (options.postMessageDispatcher) {
|
|
31
|
+
options.postMessageDispatcher.current = handler;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
removeEventListener: (event: string, handler: (e: { origin: string; data: unknown }) => void) => {
|
|
36
|
+
if (event === 'message') {
|
|
37
|
+
const idx = messageHandlers.indexOf(handler);
|
|
38
|
+
if (idx >= 0) messageHandlers.splice(idx, 1);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
(globalThis as unknown as { window: unknown }).window = win;
|
|
43
|
+
(globalThis as unknown as { sessionStorage: unknown }).sessionStorage = sessionStorageStub;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function clearBrowserGlobals(): void {
|
|
47
|
+
for (const key of ['window', 'sessionStorage'] as const) {
|
|
48
|
+
delete (globalThis as Record<string, unknown>)[key];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface FakeIframe {
|
|
53
|
+
onerror: ((this: unknown, ...args: unknown[]) => unknown) | null;
|
|
54
|
+
onabort: ((this: unknown, ...args: unknown[]) => unknown) | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('OxyServices waitForIframeAuth fail-fast on iframe load error', () => {
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
clearBrowserGlobals();
|
|
60
|
+
jest.restoreAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('resolves null immediately when the iframe fires onerror', async () => {
|
|
64
|
+
installBrowserGlobals();
|
|
65
|
+
|
|
66
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
67
|
+
const iframe: FakeIframe = { onerror: null, onabort: null };
|
|
68
|
+
|
|
69
|
+
const settled = oxy.waitForIframeAuth(
|
|
70
|
+
iframe as unknown as HTMLIFrameElement,
|
|
71
|
+
100000,
|
|
72
|
+
'https://auth.mention.earth',
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
await Promise.resolve();
|
|
76
|
+
expect(typeof iframe.onerror).toBe('function');
|
|
77
|
+
iframe.onerror?.call(iframe);
|
|
78
|
+
|
|
79
|
+
await expect(settled).resolves.toBeNull();
|
|
80
|
+
expect(iframe.onerror).toBeNull();
|
|
81
|
+
expect(iframe.onabort).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('resolves null immediately when the iframe fires onabort', async () => {
|
|
85
|
+
installBrowserGlobals();
|
|
86
|
+
|
|
87
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
88
|
+
const iframe: FakeIframe = { onerror: null, onabort: null };
|
|
89
|
+
|
|
90
|
+
const settled = oxy.waitForIframeAuth(
|
|
91
|
+
iframe as unknown as HTMLIFrameElement,
|
|
92
|
+
100000,
|
|
93
|
+
'https://auth.mention.earth',
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await Promise.resolve();
|
|
97
|
+
expect(typeof iframe.onabort).toBe('function');
|
|
98
|
+
iframe.onabort?.call(iframe);
|
|
99
|
+
|
|
100
|
+
await expect(settled).resolves.toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -2,13 +2,11 @@
|
|
|
2
2
|
* `verifyChallenge` token-planting regression tests.
|
|
3
3
|
*
|
|
4
4
|
* `OxyServices.verifyChallenge()` returns a `SessionLoginResponse` carrying the
|
|
5
|
-
* first `accessToken
|
|
6
|
-
*
|
|
5
|
+
* first `accessToken` minted by `POST /auth/verify`. It must
|
|
6
|
+
* plant that token internally — mirroring its sibling `claimSessionByToken` —
|
|
7
7
|
* so callers (e.g. @oxyhq/services' `useAuthOperations.performSignIn`) end up
|
|
8
|
-
* with an authenticated client
|
|
9
|
-
*
|
|
10
|
-
* that has no bearer yet and previously broke the entire new-identity
|
|
11
|
-
* onboarding flow.
|
|
8
|
+
* with an authenticated client. Session IDs are not public token-minting
|
|
9
|
+
* credentials, so the initial bearer must come from the verify response body.
|
|
12
10
|
*
|
|
13
11
|
* These tests stub `makeRequest` so the planting logic is exercised end-to-end
|
|
14
12
|
* against a real OxyServices instance, with token state observed via the public
|
|
@@ -22,7 +20,6 @@ interface VerifyResponse {
|
|
|
22
20
|
deviceId: string;
|
|
23
21
|
expiresAt: string;
|
|
24
22
|
accessToken?: string;
|
|
25
|
-
refreshToken?: string;
|
|
26
23
|
user: { id: string; username: string };
|
|
27
24
|
}
|
|
28
25
|
|
|
@@ -35,7 +32,7 @@ describe('OxyServices.verifyChallenge token planting', () => {
|
|
|
35
32
|
jest.restoreAllMocks();
|
|
36
33
|
});
|
|
37
34
|
|
|
38
|
-
it('plants the access
|
|
35
|
+
it('plants the access token from the /auth/verify response body', async () => {
|
|
39
36
|
const oxy = makeOxy();
|
|
40
37
|
expect(oxy.hasValidToken()).toBe(false);
|
|
41
38
|
|
|
@@ -46,7 +43,6 @@ describe('OxyServices.verifyChallenge token planting', () => {
|
|
|
46
43
|
deviceId: 'dev_1',
|
|
47
44
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
48
45
|
accessToken: 'access_verify',
|
|
49
|
-
refreshToken: 'refresh_verify',
|
|
50
46
|
user: { id: 'user_1', username: 'tester' },
|
|
51
47
|
} as never;
|
|
52
48
|
}
|
|
@@ -55,15 +51,15 @@ describe('OxyServices.verifyChallenge token planting', () => {
|
|
|
55
51
|
|
|
56
52
|
const session = await oxy.verifyChallenge('pubkey', 'challenge', 'sig', 123, 'Device', 'fp');
|
|
57
53
|
|
|
58
|
-
// Response still carries the
|
|
54
|
+
// Response still carries the access token for callers that want it.
|
|
59
55
|
expect(session.accessToken).toBe('access_verify');
|
|
60
|
-
// ...and
|
|
56
|
+
// ...and it is now planted on the client so subsequent requests are
|
|
61
57
|
// authenticated without a second round-trip.
|
|
62
58
|
expect(oxy.hasValidToken()).toBe(true);
|
|
63
59
|
expect(oxy.getAccessToken()).toBe('access_verify');
|
|
64
60
|
});
|
|
65
61
|
|
|
66
|
-
it('
|
|
62
|
+
it('plants the access token when no refresh token is present', async () => {
|
|
67
63
|
const oxy = makeOxy();
|
|
68
64
|
|
|
69
65
|
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
|
|
@@ -110,14 +106,13 @@ describe('OxyServices.verifyChallenge token planting', () => {
|
|
|
110
106
|
expect(oxy.hasValidToken()).toBe(false);
|
|
111
107
|
});
|
|
112
108
|
|
|
113
|
-
it('matches claimSessionByToken: both plant tokens via the same path', async () => {
|
|
109
|
+
it('matches claimSessionByToken: both plant access tokens via the same path', async () => {
|
|
114
110
|
const oxy = makeOxy();
|
|
115
111
|
|
|
116
112
|
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
|
|
117
113
|
if (url === '/auth/session/claim') {
|
|
118
114
|
return {
|
|
119
115
|
accessToken: 'access_claim',
|
|
120
|
-
refreshToken: 'refresh_claim',
|
|
121
116
|
sessionId: 'sess_claim',
|
|
122
117
|
deviceId: 'dev_claim',
|
|
123
118
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|