@oxyhq/core 12.11.0 → 13.0.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +66 -6
- package/dist/cjs/index.js +2 -12
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +0 -31
- package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
- package/dist/cjs/mixins/OxyServices.user.js +1 -0
- package/dist/cjs/server/index.js +1 -6
- package/dist/cjs/server/rateLimit.js +3 -0
- package/dist/cjs/session/accountDialogController.js +6 -8
- package/dist/cjs/utils/oauthPkce.js +1 -5
- package/dist/cjs/utils/officialOrigins.js +3 -73
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +66 -6
- package/dist/esm/index.js +2 -3
- package/dist/esm/mixins/OxyServices.deviceBoot.js +1 -32
- package/dist/esm/mixins/OxyServices.privacy.js +6 -0
- package/dist/esm/mixins/OxyServices.user.js +1 -0
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/server/rateLimit.js +3 -0
- package/dist/esm/session/accountDialogController.js +6 -8
- package/dist/esm/utils/oauthPkce.js +0 -4
- package/dist/esm/utils/officialOrigins.js +3 -68
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +18 -1
- package/dist/types/index.d.ts +3 -5
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -12
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +1 -5
- package/dist/types/mixins/OxyServices.user.d.ts +2 -0
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/session/accountDialogController.d.ts +9 -15
- package/dist/types/utils/oauthPkce.d.ts +11 -7
- package/dist/types/utils/officialOrigins.d.ts +3 -13
- package/package.json +4 -4
- package/src/HttpService.ts +67 -6
- package/src/__tests__/inSessionRefresh.test.ts +67 -0
- package/src/index.ts +6 -14
- package/src/mixins/OxyServices.auth.ts +6 -13
- package/src/mixins/OxyServices.deviceBoot.ts +0 -47
- package/src/mixins/OxyServices.privacy.ts +6 -0
- package/src/mixins/OxyServices.user.ts +3 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +9 -2
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +2 -0
- package/src/server/index.ts +1 -8
- package/src/server/rateLimit.ts +3 -0
- package/src/session/__tests__/accountDialogController.test.ts +3 -5
- package/src/session/accountDialogController.ts +12 -18
- package/src/utils/__tests__/officialOrigins.test.ts +0 -57
- package/src/utils/oauthPkce.ts +11 -9
- package/src/utils/officialOrigins.ts +3 -70
- package/dist/cjs/session/hubSync.js +0 -55
- package/dist/esm/session/hubSync.js +0 -51
- package/dist/types/session/hubSync.d.ts +0 -20
- package/src/session/__tests__/hubSync.test.ts +0 -51
- package/src/session/hubSync.ts +0 -79
|
@@ -89,7 +89,15 @@ export declare class HttpService {
|
|
|
89
89
|
private logger;
|
|
90
90
|
private config;
|
|
91
91
|
private tokenRefreshPromise;
|
|
92
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Epoch ms of the last FAILED refresh (0 = none since the last success). The
|
|
94
|
+
* post-failure cooldown is measured from here; its length depends on whether
|
|
95
|
+
* the current token is still valid ({@link TOKEN_REFRESH_COOLDOWN_MS}) or
|
|
96
|
+
* already expired ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}), so an expired
|
|
97
|
+
* token recovers promptly the instant it crosses `exp` — without storing a
|
|
98
|
+
* fixed deadline that could not shrink once the token expired mid-cooldown.
|
|
99
|
+
*/
|
|
100
|
+
private lastRefreshFailureAt;
|
|
93
101
|
private authRefreshHandler;
|
|
94
102
|
private accessTokenProvider;
|
|
95
103
|
private deviceSecretMintInFlight;
|
|
@@ -224,6 +232,15 @@ export declare class HttpService {
|
|
|
224
232
|
*/
|
|
225
233
|
private getAuthHeader;
|
|
226
234
|
refreshAccessToken(reason: AuthRefreshReason): Promise<string | null>;
|
|
235
|
+
/**
|
|
236
|
+
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
237
|
+
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
238
|
+
* a still-valid (near-expiry) token can wait out the full cooldown, but an
|
|
239
|
+
* expired one must re-mint promptly. Returns `false` for an absent or
|
|
240
|
+
* opaque/no-`exp` token — no proof it is expired, so keep the conservative
|
|
241
|
+
* (longer) cooldown and avoid an unnecessary retry loop.
|
|
242
|
+
*/
|
|
243
|
+
private isAccessTokenExpired;
|
|
227
244
|
/**
|
|
228
245
|
* PROCESS-WIDE single-flight for the rotating device-secret mint
|
|
229
246
|
* (`POST /session/device/token`).
|
package/dist/types/index.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from
|
|
|
26
26
|
export { selectCommonsDelivery, pushTargetsFromDelivery, commonsDeliveryPlatform } from './utils/commonsDelivery';
|
|
27
27
|
export type { CommonsDeliveryFacts, CommonsDeliveryPlatform, CommonsDeliveryRoute, } from './utils/commonsDelivery';
|
|
28
28
|
export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
29
|
-
export type { CommonsSignInHandle, CommonsSignInStatus, CommonsSignInPurpose, CommonsOAuthContext, CommonsApprovalInfo, CommonsApprovalSubjectAccount, CommonsSignInActionResult,
|
|
29
|
+
export type { CommonsSignInHandle, CommonsSignInStatus, CommonsSignInPurpose, CommonsOAuthContext, CommonsApprovalInfo, CommonsApprovalSubjectAccount, CommonsSignInActionResult, CommonsOAuthFinalizeResult, CommonsDeliveryResult, } from './mixins/OxyServices.auth';
|
|
30
30
|
export type { PushTokenPlatform, RegisterPushTokenInput, } from './mixins/OxyServices.notifications';
|
|
31
31
|
export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
|
|
32
32
|
export type { ContactDiscoveryMatch, ContactDiscoveryResponse, } from './mixins/OxyServices.contacts';
|
|
@@ -96,11 +96,9 @@ export { CENTRAL_IDP_APEX } from './utils/authWebUrl';
|
|
|
96
96
|
export { isOxyRpOrigin } from './utils/webauthnOrigin';
|
|
97
97
|
export { runColdBoot } from './utils/coldBoot';
|
|
98
98
|
export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
|
|
99
|
-
export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, OXY_OAUTH_STATE_STORAGE_KEY, OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, OXY_OAUTH_REDIRECT_URI_STORAGE_KEY,
|
|
99
|
+
export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, OXY_OAUTH_STATE_STORAGE_KEY, OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, OXY_OAUTH_REDIRECT_URI_STORAGE_KEY, OXY_OAUTH_RETURN_PATH_STORAGE_KEY, normalizeOAuthRedirectUri, canonicalizeOAuthRedirectUri, persistOAuthHandshake, readOAuthHandshake, clearOAuthHandshake, persistOAuthReturnPath, consumeOAuthReturnPath, } from './utils/oauthPkce';
|
|
100
100
|
export type { PkcePair, BuildOAuthAuthorizeUrlParams } from './utils/oauthPkce';
|
|
101
|
-
export {
|
|
102
|
-
export { syncHubAfterSignIn, redeemHubTicketOnHub, } from './session/hubSync';
|
|
103
|
-
export type { SyncHubAfterSignInOptions } from './session/hubSync';
|
|
101
|
+
export { isLoopbackOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, } from './utils/officialOrigins';
|
|
104
102
|
export { SessionClient } from './session/SessionClient';
|
|
105
103
|
export type { TokenTransport, SessionClientHost, SessionClientOptions, DeviceCredential, SessionStateOrigin } from './session/SessionClient';
|
|
106
104
|
export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Supports password-based login (email/username) and public key challenge-response.
|
|
5
5
|
*/
|
|
6
6
|
import type { User } from '../models/interfaces';
|
|
7
|
-
import type { LoginResult, LoginSessionResult } from '@oxyhq/contracts';
|
|
7
|
+
import type { LoginResult, LoginSessionResult, CommonsDenyReason } from '@oxyhq/contracts';
|
|
8
8
|
import type { SessionLoginResponse } from '../models/session';
|
|
9
9
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
10
10
|
import type { PublicApplication } from './OxyServices.connectedApps';
|
|
@@ -226,17 +226,6 @@ export interface CommonsApprovalInfo {
|
|
|
226
226
|
export interface CommonsSignInActionResult {
|
|
227
227
|
success: boolean;
|
|
228
228
|
}
|
|
229
|
-
/**
|
|
230
|
-
* Why the approver denied a "Sign in with Oxy" request. A CLOSED set — the deny
|
|
231
|
-
* endpoint is unauthenticated, so it accepts no free-form text:
|
|
232
|
-
*
|
|
233
|
-
* - `'declined'` the approver rejected a request they recognised ("Not now").
|
|
234
|
-
* - `'not_me'` the approver did not start the request ("This wasn't me").
|
|
235
|
-
* The only value that records the denial as suspicious rather
|
|
236
|
-
* than an ordinary cancel, so a UI may only offer it where the
|
|
237
|
-
* user genuinely said so.
|
|
238
|
-
*/
|
|
239
|
-
export type CommonsDenyReason = 'declined' | 'not_me';
|
|
240
229
|
/**
|
|
241
230
|
* Result of finalizing an approved, OAuth-bound "Sign in with Oxy" request.
|
|
242
231
|
*
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* the cold boot / re-mint handler own persistence and `setTokens`, so the same
|
|
14
14
|
* primitive can be reused from either without double-planting.
|
|
15
15
|
*/
|
|
16
|
-
import { type DeviceTokenMintResponse
|
|
16
|
+
import { type DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
17
17
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
18
18
|
/**
|
|
19
19
|
* The server's `401 account_not_on_device` for a PINNED mint: the requested
|
|
@@ -71,10 +71,6 @@ export declare function OxyServicesDeviceBootMixin<T extends typeof OxyServicesB
|
|
|
71
71
|
mintFromDeviceSecret(deviceId: string, deviceSecret: string, options?: {
|
|
72
72
|
accountId?: string;
|
|
73
73
|
}): Promise<DeviceTokenMintResponse>;
|
|
74
|
-
/** Mint a one-time hub sync ticket (bearer required). */
|
|
75
|
-
issueHubTicket(returnOrigin: string): Promise<DeviceHubTicketIssueResponse>;
|
|
76
|
-
/** Redeem a hub sync ticket for a fresh device secret (public). */
|
|
77
|
-
redeemHubTicket(ticket: string, returnOrigin: string): Promise<DeviceHubTicketRedeemResponse>;
|
|
78
74
|
httpService: import("../HttpService").HttpService;
|
|
79
75
|
cloudURL: string;
|
|
80
76
|
config: import("../OxyServices.base").OxyConfig;
|
|
@@ -55,6 +55,8 @@ export interface ViewerGraph {
|
|
|
55
55
|
mutualIds: string[];
|
|
56
56
|
/** Accounts the viewer has blocked (bounded). */
|
|
57
57
|
blockedIds: string[];
|
|
58
|
+
/** Accounts the viewer has restricted (bounded). */
|
|
59
|
+
restrictedIds: string[];
|
|
58
60
|
}
|
|
59
61
|
/** Per-user outcome returned by `POST /users/unfollow/bulk`. */
|
|
60
62
|
export interface BulkUnfollowEntry {
|
|
@@ -24,4 +24,4 @@ export { createOxyCors } from './cors';
|
|
|
24
24
|
export type { OxyCorsOptions } from './cors';
|
|
25
25
|
export { verifySecret } from './verifySecret';
|
|
26
26
|
export { registrableApex } from '../utils/registrableApex';
|
|
27
|
-
export {
|
|
27
|
+
export { isOfficialWebOrigin } from '../utils/officialOrigins';
|
|
@@ -231,21 +231,17 @@ export interface AccountDialogControllerOptions {
|
|
|
231
231
|
* `SessionClient.registerAndActivate` (registration + activation only — no
|
|
232
232
|
* provider-side durable persist/hydration).
|
|
233
233
|
*
|
|
234
|
-
* This is the SIGN-IN commit:
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* origin legitimately needs that. An account SWITCH does NOT — see
|
|
238
|
-
* {@link commitSwitchedSession}.
|
|
234
|
+
* This is the SIGN-IN commit: registers the session into the host's device
|
|
235
|
+
* set with durable persist + profile hydration. An account SWITCH uses
|
|
236
|
+
* {@link commitSwitchedSession} instead — see below.
|
|
239
237
|
*/
|
|
240
238
|
commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
241
239
|
/**
|
|
242
240
|
* Commit a minted graph SWITCH session into the host's session set — same
|
|
243
241
|
* device-first registration + durable persist + profile hydration as
|
|
244
|
-
* {@link commitSession}, but IN-PLACE: it must NOT
|
|
245
|
-
*
|
|
246
|
-
* device
|
|
247
|
-
* re-syncing is redundant and a full-page redirect on switch is the exact
|
|
248
|
-
* regression this separation prevents. Cross-tab/app propagation of the switch
|
|
242
|
+
* {@link commitSession}, but IN-PLACE: it must NOT re-run sign-in side effects
|
|
243
|
+
* that belong only to a fresh authorization (for example, a redundant full
|
|
244
|
+
* device-set reconcile on switch). Cross-tab/app propagation of the switch
|
|
249
245
|
* still happens instantly via the server's device-scoped `session_state` /
|
|
250
246
|
* `session_accounts_changed` socket broadcast — no navigation required.
|
|
251
247
|
*
|
|
@@ -600,11 +596,9 @@ export declare class AccountDialogController {
|
|
|
600
596
|
* consumer's commit funnel (durable persist + hydration); falls back to
|
|
601
597
|
* `SessionClient.registerAndActivate` (registration + activation only).
|
|
602
598
|
*
|
|
603
|
-
* A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
* switch funnel is not wired it falls back to the sign-in funnel, then to
|
|
607
|
-
* `registerAndActivate`.
|
|
599
|
+
* A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel;
|
|
600
|
+
* a SIGN-IN uses `commitSession`. When the switch funnel is not wired it falls
|
|
601
|
+
* back to the sign-in funnel, then to `registerAndActivate`.
|
|
608
602
|
*/
|
|
609
603
|
private commitAuthorizedSession;
|
|
610
604
|
private failSignIn;
|
|
@@ -43,10 +43,18 @@ export interface BuildOAuthAuthorizeUrlParams {
|
|
|
43
43
|
/** The PKCE `codeChallenge` from {@link generatePkcePair}. */
|
|
44
44
|
codeChallenge: string;
|
|
45
45
|
/**
|
|
46
|
-
* OAuth `prompt` parameter
|
|
47
|
-
*
|
|
46
|
+
* OAuth `prompt` parameter — `login` forces a fresh authentication even when
|
|
47
|
+
* the IdP already has a session, `consent` forces the consent screen even for
|
|
48
|
+
* an already-granted scope. Both are ordinary OAuth/OIDC and are here for
|
|
49
|
+
* third-party relying parties building their own authorize link.
|
|
50
|
+
*
|
|
51
|
+
* `'none'` is deliberately NOT in this union. It is the silent-SSO value, and
|
|
52
|
+
* it was the only value this SDK ever sent — from the cold-boot cross-origin
|
|
53
|
+
* restore deleted in #691 phase 7b. Accepting it again would hand consumers a
|
|
54
|
+
* one-line rebuild of the automatic, gesture-less full-page bounce to the IdP
|
|
55
|
+
* that the popup transport exists to eliminate.
|
|
48
56
|
*/
|
|
49
|
-
prompt?: '
|
|
57
|
+
prompt?: 'login' | 'consent';
|
|
50
58
|
/**
|
|
51
59
|
* How the IdP should deliver the authorization response. Omitted (the
|
|
52
60
|
* default) means the ordinary top-level redirect back to `redirectUri`.
|
|
@@ -95,10 +103,6 @@ export declare const OXY_OAUTH_STATE_STORAGE_KEY = "oxy_oauth_state";
|
|
|
95
103
|
export declare const OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = "oxy_oauth_code_verifier";
|
|
96
104
|
/** `sessionStorage` key — the exact `redirect_uri` sent on the authorize request. */
|
|
97
105
|
export declare const OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = "oxy.oauth_redirect_uri";
|
|
98
|
-
/** `sessionStorage` key — at most one silent OAuth attempt per navigation. */
|
|
99
|
-
export declare const OXY_SILENT_OAUTH_ATTEMPTED_KEY = "oxy.silent_oauth_attempted";
|
|
100
|
-
/** `sessionStorage` key — blocks further cross-origin auto-restore in this tab. */
|
|
101
|
-
export declare const OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = "oxy.cross_origin_restore_attempted";
|
|
102
106
|
/**
|
|
103
107
|
* `sessionStorage` key for the in-app path to return to after an authorize
|
|
104
108
|
* round trip. See {@link persistOAuthReturnPath}.
|
|
@@ -1,23 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Official first-party web origin allowlist — shared by
|
|
3
|
-
*
|
|
2
|
+
* Official first-party web origin allowlist — shared by OAuth redirect
|
|
3
|
+
* validation and the server-side trusted-origin checks.
|
|
4
4
|
*/
|
|
5
|
-
export declare function buildIdpHubOrigin(): string;
|
|
6
|
-
/** Whether the current web origin is the central IdP hub (`auth.oxy.so`). */
|
|
7
|
-
export declare function isIdpHubOrigin(): boolean;
|
|
8
5
|
/**
|
|
9
6
|
* Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
|
|
10
|
-
* or `[::1]` on any port, http or https).
|
|
11
|
-
* hosted IdP for cross-origin session restore.
|
|
7
|
+
* or `[::1]` on any port, http or https).
|
|
12
8
|
*/
|
|
13
9
|
export declare function isLoopbackOrigin(origin: string): boolean;
|
|
14
10
|
/** Whether an origin belongs to the official Oxy web ecosystem. */
|
|
15
11
|
export declare function isOfficialWebOrigin(origin: string): boolean;
|
|
16
|
-
/** Normalize and validate a return URL against official origins. Returns origin only. */
|
|
17
|
-
export declare function normalizeOfficialReturnOrigin(raw: string): string | null;
|
|
18
|
-
/** Validate a hub-sync return URL; returns the full normalized URL string. */
|
|
19
|
-
export declare function parseHubSyncReturnUrl(raw: string | null): string | null;
|
|
20
|
-
/** Build auth.oxy.so/sync redirect URL with a one-time hub ticket. */
|
|
21
|
-
export declare function buildHubSyncUrl(ticket: string, returnUrl?: string): string;
|
|
22
12
|
/** @deprecated Use {@link isOfficialWebOrigin}. */
|
|
23
13
|
export declare const isAllowedDeviceJoinOrigin: typeof isOfficialWebOrigin;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "13.0.0",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -115,8 +115,8 @@
|
|
|
115
115
|
"dependencies": {
|
|
116
116
|
"@noble/ciphers": "^1.3.0",
|
|
117
117
|
"@noble/hashes": "^1.8.0",
|
|
118
|
-
"@oxyhq/contracts": "^0.
|
|
119
|
-
"@oxyhq/protocol": "^0.1.
|
|
118
|
+
"@oxyhq/contracts": "^0.19.0",
|
|
119
|
+
"@oxyhq/protocol": "^0.1.6",
|
|
120
120
|
"bip39": "^3.1.0",
|
|
121
121
|
"buffer": "^6.0.3",
|
|
122
122
|
"elliptic": "^6.6.1",
|
|
@@ -157,7 +157,7 @@
|
|
|
157
157
|
"expo-crypto": "~56.0.3",
|
|
158
158
|
"expo-secure-store": "~56.0.4",
|
|
159
159
|
"express": "^4.22.2",
|
|
160
|
-
"express-rate-limit": "^
|
|
160
|
+
"express-rate-limit": "^8.6.0",
|
|
161
161
|
"regexpu-core": "^6.4.0",
|
|
162
162
|
"release-it": "^19.0.6",
|
|
163
163
|
"typescript": "^5.9.2"
|
package/src/HttpService.ts
CHANGED
|
@@ -143,12 +143,32 @@ const CSRF_FETCH_RETRY_DELAY_MS = 500;
|
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
145
|
* Cooldown (ms) applied after a failed access-token refresh before another
|
|
146
|
-
* refresh is attempted
|
|
146
|
+
* refresh is attempted while the CURRENT token is still valid (a proactive,
|
|
147
|
+
* near-expiry refresh). Prevents a refresh storm (and server hammering) when
|
|
147
148
|
* the auth refresh handler is failing — every in-flight request that
|
|
148
|
-
* hits a 401 would otherwise trigger its own refresh.
|
|
149
|
+
* hits a 401 would otherwise trigger its own refresh. A still-valid token can
|
|
150
|
+
* afford to wait this out; the request keeps carrying it in the meantime.
|
|
149
151
|
*/
|
|
150
152
|
const TOKEN_REFRESH_COOLDOWN_MS = 15000;
|
|
151
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Cooldown (ms) applied after a failed refresh when the CURRENT access token is
|
|
156
|
+
* already past its `exp`. Much shorter than {@link TOKEN_REFRESH_COOLDOWN_MS}:
|
|
157
|
+
* an expired token is UNUSABLE, so the client must re-mint as soon as the mint
|
|
158
|
+
* endpoint is reachable again (e.g. a few seconds after an ECS rolling-deploy
|
|
159
|
+
* blip drains/restarts a task) instead of waiting out the full proactive
|
|
160
|
+
* cooldown while every request forwards or omits a stale bearer → server 401.
|
|
161
|
+
*
|
|
162
|
+
* Still NON-ZERO on purpose: it bounds the request-driven retry rate to at most
|
|
163
|
+
* one attempt per this interval so a PROLONGED outage cannot become a tight
|
|
164
|
+
* network storm. Combined with the process-wide single-flight below (concurrent
|
|
165
|
+
* requests coalesce to one in-flight mint) and the refresh handler's own
|
|
166
|
+
* terminal-state handling (a genuinely revoked session clears its device
|
|
167
|
+
* credential and stops issuing network mints), this recovers a transient blip
|
|
168
|
+
* ~15× faster without weakening the storm guard.
|
|
169
|
+
*/
|
|
170
|
+
const EXPIRED_TOKEN_REFRESH_COOLDOWN_MS = 1000;
|
|
171
|
+
|
|
152
172
|
/**
|
|
153
173
|
* Lead time (seconds) before access-token expiry at which a preflight refresh
|
|
154
174
|
* is triggered. A token within this window of `exp` is treated as effectively
|
|
@@ -247,7 +267,15 @@ export class HttpService {
|
|
|
247
267
|
private logger: SimpleLogger;
|
|
248
268
|
private config: OxyConfig;
|
|
249
269
|
private tokenRefreshPromise: Promise<string | null> | null = null;
|
|
250
|
-
|
|
270
|
+
/**
|
|
271
|
+
* Epoch ms of the last FAILED refresh (0 = none since the last success). The
|
|
272
|
+
* post-failure cooldown is measured from here; its length depends on whether
|
|
273
|
+
* the current token is still valid ({@link TOKEN_REFRESH_COOLDOWN_MS}) or
|
|
274
|
+
* already expired ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}), so an expired
|
|
275
|
+
* token recovers promptly the instant it crosses `exp` — without storing a
|
|
276
|
+
* fixed deadline that could not shrink once the token expired mid-cooldown.
|
|
277
|
+
*/
|
|
278
|
+
private lastRefreshFailureAt = 0;
|
|
251
279
|
private authRefreshHandler: AuthRefreshHandler | null = null;
|
|
252
280
|
private accessTokenProvider: AccessTokenProvider | null = null;
|
|
253
281
|
private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
@@ -1058,7 +1086,16 @@ export class HttpService {
|
|
|
1058
1086
|
return null;
|
|
1059
1087
|
}
|
|
1060
1088
|
|
|
1061
|
-
|
|
1089
|
+
// Post-failure cooldown. A genuinely EXPIRED current token uses a much
|
|
1090
|
+
// shorter cooldown than a still-valid (proactive, near-expiry) one: an
|
|
1091
|
+
// expired token is unusable, so re-mint as soon as the endpoint is reachable
|
|
1092
|
+
// again rather than waiting out the full window while requests carry a stale
|
|
1093
|
+
// bearer. Both cooldowns are measured from the last failure, so the moment a
|
|
1094
|
+
// still-valid token crosses `exp` mid-cooldown the shorter window applies.
|
|
1095
|
+
const cooldownMs = this.isAccessTokenExpired()
|
|
1096
|
+
? EXPIRED_TOKEN_REFRESH_COOLDOWN_MS
|
|
1097
|
+
: TOKEN_REFRESH_COOLDOWN_MS;
|
|
1098
|
+
if (Date.now() - this.lastRefreshFailureAt < cooldownMs) {
|
|
1062
1099
|
return null;
|
|
1063
1100
|
}
|
|
1064
1101
|
|
|
@@ -1066,19 +1103,22 @@ export class HttpService {
|
|
|
1066
1103
|
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
1067
1104
|
.then((newToken) => {
|
|
1068
1105
|
if (!newToken) {
|
|
1069
|
-
this.
|
|
1106
|
+
this.lastRefreshFailureAt = Date.now();
|
|
1070
1107
|
return null;
|
|
1071
1108
|
}
|
|
1072
1109
|
if (this.tokenStore.getAccessToken() !== newToken) {
|
|
1073
1110
|
this.tokenStore.setTokens(newToken);
|
|
1074
1111
|
this.notifyTokenChange();
|
|
1075
1112
|
}
|
|
1113
|
+
// A success clears the failure timestamp so the next refresh is never
|
|
1114
|
+
// throttled by a stale cooldown.
|
|
1115
|
+
this.lastRefreshFailureAt = 0;
|
|
1076
1116
|
this.logger.debug('Token refreshed via the auth refresh handler');
|
|
1077
1117
|
return newToken;
|
|
1078
1118
|
})
|
|
1079
1119
|
.catch((error) => {
|
|
1080
1120
|
this.logger.warn('Token refresh failed:', error);
|
|
1081
|
-
this.
|
|
1121
|
+
this.lastRefreshFailureAt = Date.now();
|
|
1082
1122
|
return null;
|
|
1083
1123
|
})
|
|
1084
1124
|
.finally(() => {
|
|
@@ -1089,6 +1129,27 @@ export class HttpService {
|
|
|
1089
1129
|
return this.tokenRefreshPromise;
|
|
1090
1130
|
}
|
|
1091
1131
|
|
|
1132
|
+
/**
|
|
1133
|
+
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
1134
|
+
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
1135
|
+
* a still-valid (near-expiry) token can wait out the full cooldown, but an
|
|
1136
|
+
* expired one must re-mint promptly. Returns `false` for an absent or
|
|
1137
|
+
* opaque/no-`exp` token — no proof it is expired, so keep the conservative
|
|
1138
|
+
* (longer) cooldown and avoid an unnecessary retry loop.
|
|
1139
|
+
*/
|
|
1140
|
+
private isAccessTokenExpired(): boolean {
|
|
1141
|
+
const token = this.tokenStore.getAccessToken();
|
|
1142
|
+
if (!token) {
|
|
1143
|
+
return false;
|
|
1144
|
+
}
|
|
1145
|
+
try {
|
|
1146
|
+
const decoded = jwtDecode<JwtPayload>(token);
|
|
1147
|
+
return typeof decoded.exp === 'number' && decoded.exp <= Math.floor(Date.now() / 1000);
|
|
1148
|
+
} catch {
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1092
1153
|
/**
|
|
1093
1154
|
* PROCESS-WIDE single-flight for the rotating device-secret mint
|
|
1094
1155
|
* (`POST /session/device/token`).
|
|
@@ -164,6 +164,73 @@ describe('HttpService in-session refresh handler', () => {
|
|
|
164
164
|
// The second call is inside the post-failure cooldown → handler not re-run.
|
|
165
165
|
expect(handlerCalls).toBe(1);
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
it('lets an EXPIRED token re-mint promptly instead of waiting out the long proactive cooldown', async () => {
|
|
169
|
+
// Regression: an ECS rolling-deploy blip briefly fails a mint; the 15s
|
|
170
|
+
// proactive cooldown then left the client forwarding/omitting a now-expired
|
|
171
|
+
// bearer for up to 15s after the endpoint recovered (Mention /privacy 401s).
|
|
172
|
+
// An already-expired token must recover on the short cooldown instead.
|
|
173
|
+
globalThis.fetch = async () => jsonResponse({ ok: true });
|
|
174
|
+
const nowSpy = jest.spyOn(Date, 'now');
|
|
175
|
+
const T0 = 1_000_000_000_000;
|
|
176
|
+
nowSpy.mockReturnValue(T0);
|
|
177
|
+
|
|
178
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
179
|
+
// Current token is already 10s past exp (exp is in SECONDS).
|
|
180
|
+
http.setTokens(createJwt({ userId: 'u', exp: Math.floor(T0 / 1000) - 10 }));
|
|
181
|
+
|
|
182
|
+
let handlerCalls = 0;
|
|
183
|
+
http.setAuthRefreshHandler(async () => {
|
|
184
|
+
handlerCalls += 1;
|
|
185
|
+
return null;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// First attempt fails → records the failure timestamp.
|
|
189
|
+
await http.refreshAccessToken('preflight');
|
|
190
|
+
expect(handlerCalls).toBe(1);
|
|
191
|
+
|
|
192
|
+
// 500ms later: still inside the SHORT expired cooldown → NOT re-run. This is
|
|
193
|
+
// the storm guard — an expired token does not fully bypass the cooldown.
|
|
194
|
+
nowSpy.mockReturnValue(T0 + 500);
|
|
195
|
+
await http.refreshAccessToken('preflight');
|
|
196
|
+
expect(handlerCalls).toBe(1);
|
|
197
|
+
|
|
198
|
+
// 1.5s later: past the short expired cooldown but WELL inside the 15s
|
|
199
|
+
// proactive cooldown → the expired token re-mints promptly.
|
|
200
|
+
nowSpy.mockReturnValue(T0 + 1500);
|
|
201
|
+
await http.refreshAccessToken('preflight');
|
|
202
|
+
expect(handlerCalls).toBe(2);
|
|
203
|
+
|
|
204
|
+
nowSpy.mockRestore();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('keeps the full 15s cooldown for a still-valid (proactive) near-expiry refresh', async () => {
|
|
208
|
+
globalThis.fetch = async () => jsonResponse({ ok: true });
|
|
209
|
+
const nowSpy = jest.spyOn(Date, 'now');
|
|
210
|
+
const T0 = 1_000_000_000_000;
|
|
211
|
+
nowSpy.mockReturnValue(T0);
|
|
212
|
+
|
|
213
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
214
|
+
// Token is still valid for another hour — a proactive refresh, not expired.
|
|
215
|
+
http.setTokens(createJwt({ userId: 'u', exp: Math.floor(T0 / 1000) + 3600 }));
|
|
216
|
+
|
|
217
|
+
let handlerCalls = 0;
|
|
218
|
+
http.setAuthRefreshHandler(async () => {
|
|
219
|
+
handlerCalls += 1;
|
|
220
|
+
return null;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await http.refreshAccessToken('preflight');
|
|
224
|
+
expect(handlerCalls).toBe(1);
|
|
225
|
+
|
|
226
|
+
// 1.5s later: past the short expired cooldown, but the token is NOT expired,
|
|
227
|
+
// so the full proactive cooldown still applies → no re-run (no storm).
|
|
228
|
+
nowSpy.mockReturnValue(T0 + 1500);
|
|
229
|
+
await http.refreshAccessToken('preflight');
|
|
230
|
+
expect(handlerCalls).toBe(1);
|
|
231
|
+
|
|
232
|
+
nowSpy.mockRestore();
|
|
233
|
+
});
|
|
167
234
|
});
|
|
168
235
|
|
|
169
236
|
describe('OxyServices.getAccessTokenExpiry', () => {
|
package/src/index.ts
CHANGED
|
@@ -59,10 +59,15 @@ export type {
|
|
|
59
59
|
CommonsApprovalInfo,
|
|
60
60
|
CommonsApprovalSubjectAccount,
|
|
61
61
|
CommonsSignInActionResult,
|
|
62
|
-
CommonsDenyReason,
|
|
63
62
|
CommonsOAuthFinalizeResult,
|
|
64
63
|
CommonsDeliveryResult,
|
|
65
64
|
} from './mixins/OxyServices.auth';
|
|
65
|
+
// `denyCommonsSignIn`'s reason parameter is typed by `CommonsDenyReason`, which
|
|
66
|
+
// is a WIRE contract shared with the API (the request schema of
|
|
67
|
+
// `POST /auth/session/deny/:authorizeCode` and the persisted
|
|
68
|
+
// `AuthSession.deniedReason` enum read the same declaration). It lives in
|
|
69
|
+
// `@oxyhq/contracts` and is NOT re-exported here: consumers import API contract
|
|
70
|
+
// types directly from `@oxyhq/contracts`, per the package boundary rule.
|
|
66
71
|
// Push-token registration (Expo push tokens — never raw APNs/FCM device tokens).
|
|
67
72
|
export type {
|
|
68
73
|
PushTokenPlatform,
|
|
@@ -601,8 +606,6 @@ export {
|
|
|
601
606
|
OXY_OAUTH_STATE_STORAGE_KEY,
|
|
602
607
|
OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY,
|
|
603
608
|
OXY_OAUTH_REDIRECT_URI_STORAGE_KEY,
|
|
604
|
-
OXY_SILENT_OAUTH_ATTEMPTED_KEY,
|
|
605
|
-
OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY,
|
|
606
609
|
OXY_OAUTH_RETURN_PATH_STORAGE_KEY,
|
|
607
610
|
normalizeOAuthRedirectUri,
|
|
608
611
|
canonicalizeOAuthRedirectUri,
|
|
@@ -615,22 +618,11 @@ export {
|
|
|
615
618
|
export type { PkcePair, BuildOAuthAuthorizeUrlParams } from './utils/oauthPkce';
|
|
616
619
|
|
|
617
620
|
export {
|
|
618
|
-
buildIdpHubOrigin,
|
|
619
|
-
buildHubSyncUrl,
|
|
620
|
-
isIdpHubOrigin,
|
|
621
621
|
isLoopbackOrigin,
|
|
622
622
|
isOfficialWebOrigin,
|
|
623
623
|
isAllowedDeviceJoinOrigin,
|
|
624
|
-
normalizeOfficialReturnOrigin,
|
|
625
|
-
parseHubSyncReturnUrl,
|
|
626
624
|
} from './utils/officialOrigins';
|
|
627
625
|
|
|
628
|
-
export {
|
|
629
|
-
syncHubAfterSignIn,
|
|
630
|
-
redeemHubTicketOnHub,
|
|
631
|
-
} from './session/hubSync';
|
|
632
|
-
export type { SyncHubAfterSignInOptions } from './session/hubSync';
|
|
633
|
-
|
|
634
626
|
// ---------------------------------------------------------------------------
|
|
635
627
|
// Session sync (device-scoped multi-account session client)
|
|
636
628
|
// ---------------------------------------------------------------------------
|
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
* Supports password-based login (email/username) and public key challenge-response.
|
|
5
5
|
*/
|
|
6
6
|
import type { User } from '../models/interfaces';
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
UserNameResponse,
|
|
9
|
+
LoginResult,
|
|
10
|
+
LoginSessionResult,
|
|
11
|
+
CommonsDenyReason,
|
|
12
|
+
} from '@oxyhq/contracts';
|
|
8
13
|
import { loginResultSchema, safeParseContract } from '@oxyhq/contracts';
|
|
9
14
|
import type { SessionLoginResponse } from '../models/session';
|
|
10
15
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
@@ -336,18 +341,6 @@ export interface CommonsSignInActionResult {
|
|
|
336
341
|
success: boolean;
|
|
337
342
|
}
|
|
338
343
|
|
|
339
|
-
/**
|
|
340
|
-
* Why the approver denied a "Sign in with Oxy" request. A CLOSED set — the deny
|
|
341
|
-
* endpoint is unauthenticated, so it accepts no free-form text:
|
|
342
|
-
*
|
|
343
|
-
* - `'declined'` the approver rejected a request they recognised ("Not now").
|
|
344
|
-
* - `'not_me'` the approver did not start the request ("This wasn't me").
|
|
345
|
-
* The only value that records the denial as suspicious rather
|
|
346
|
-
* than an ordinary cancel, so a UI may only offer it where the
|
|
347
|
-
* user genuinely said so.
|
|
348
|
-
*/
|
|
349
|
-
export type CommonsDenyReason = 'declined' | 'not_me';
|
|
350
|
-
|
|
351
344
|
/**
|
|
352
345
|
* Result of finalizing an approved, OAuth-bound "Sign in with Oxy" request.
|
|
353
346
|
*
|
|
@@ -15,12 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import {
|
|
17
17
|
deviceTokenMintResponseSchema,
|
|
18
|
-
deviceHubTicketIssueResponseSchema,
|
|
19
|
-
deviceHubTicketRedeemResponseSchema,
|
|
20
18
|
safeParseContract,
|
|
21
19
|
type DeviceTokenMintResponse,
|
|
22
|
-
type DeviceHubTicketIssueResponse,
|
|
23
|
-
type DeviceHubTicketRedeemResponse,
|
|
24
20
|
} from '@oxyhq/contracts';
|
|
25
21
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
26
22
|
|
|
@@ -124,48 +120,5 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
124
120
|
throw normalized;
|
|
125
121
|
}
|
|
126
122
|
}
|
|
127
|
-
|
|
128
|
-
/** Mint a one-time hub sync ticket (bearer required). */
|
|
129
|
-
async issueHubTicket(returnOrigin: string): Promise<DeviceHubTicketIssueResponse> {
|
|
130
|
-
try {
|
|
131
|
-
const res = await this.makeRequest<unknown>(
|
|
132
|
-
'POST',
|
|
133
|
-
'/session/device/hub-ticket',
|
|
134
|
-
{ returnOrigin },
|
|
135
|
-
{ cache: false },
|
|
136
|
-
);
|
|
137
|
-
const parsed = safeParseContract(deviceHubTicketIssueResponseSchema, res);
|
|
138
|
-
if (!parsed) {
|
|
139
|
-
throw new Error('session/device/hub-ticket returned an unexpected response shape');
|
|
140
|
-
}
|
|
141
|
-
return parsed;
|
|
142
|
-
} catch (error) {
|
|
143
|
-
throw this.handleError(error);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Redeem a hub sync ticket for a fresh device secret (public). */
|
|
148
|
-
async redeemHubTicket(
|
|
149
|
-
ticket: string,
|
|
150
|
-
returnOrigin: string,
|
|
151
|
-
): Promise<DeviceHubTicketRedeemResponse> {
|
|
152
|
-
try {
|
|
153
|
-
const res = await this.makeRequest<unknown>(
|
|
154
|
-
'POST',
|
|
155
|
-
'/session/device/redeem-ticket',
|
|
156
|
-
{ ticket, returnOrigin },
|
|
157
|
-
// Public device-hub sync mint (bearer-less). Same control-plane class as
|
|
158
|
-
// the device-secret mint — bypassQueue so it never waits for a slot.
|
|
159
|
-
{ cache: false, skipAuth: true, bypassQueue: true },
|
|
160
|
-
);
|
|
161
|
-
const parsed = safeParseContract(deviceHubTicketRedeemResponseSchema, res);
|
|
162
|
-
if (!parsed) {
|
|
163
|
-
throw new Error('session/device/redeem-ticket returned an unexpected response shape');
|
|
164
|
-
}
|
|
165
|
-
return parsed;
|
|
166
|
-
} catch (error) {
|
|
167
|
-
throw this.handleError(error);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
123
|
};
|
|
171
124
|
}
|
|
@@ -167,6 +167,9 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
167
167
|
cache: false,
|
|
168
168
|
});
|
|
169
169
|
this.clearCacheEntry('GET:/privacy/restricted');
|
|
170
|
+
// The restriction changed the viewer's graph (`restrictedIds`) — bust the
|
|
171
|
+
// cached consolidated `GET /users/me/graph` so the next read reflects it.
|
|
172
|
+
this.clearCacheEntry('GET:/users/me/graph');
|
|
170
173
|
return result;
|
|
171
174
|
} catch (error) {
|
|
172
175
|
throw this.handleError(error);
|
|
@@ -190,6 +193,9 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
190
193
|
cache: false,
|
|
191
194
|
});
|
|
192
195
|
this.clearCacheEntry('GET:/privacy/restricted');
|
|
196
|
+
// Symmetric to restrictUser: the unrestrict changed the viewer's
|
|
197
|
+
// `restrictedIds`, so bust the consolidated `GET /users/me/graph` cache.
|
|
198
|
+
this.clearCacheEntry('GET:/users/me/graph');
|
|
193
199
|
return result;
|
|
194
200
|
} catch (error) {
|
|
195
201
|
throw this.handleError(error);
|
|
@@ -92,6 +92,8 @@ export interface ViewerGraph {
|
|
|
92
92
|
mutualIds: string[];
|
|
93
93
|
/** Accounts the viewer has blocked (bounded). */
|
|
94
94
|
blockedIds: string[];
|
|
95
|
+
/** Accounts the viewer has restricted (bounded). */
|
|
96
|
+
restrictedIds: string[];
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
/** Per-user outcome returned by `POST /users/unfollow/bulk`. */
|
|
@@ -1044,6 +1046,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1044
1046
|
followingIds: graph?.followingIds || [],
|
|
1045
1047
|
mutualIds: graph?.mutualIds || [],
|
|
1046
1048
|
blockedIds: graph?.blockedIds || [],
|
|
1049
|
+
restrictedIds: graph?.restrictedIds || [],
|
|
1047
1050
|
};
|
|
1048
1051
|
} catch (error) {
|
|
1049
1052
|
throw this.handleError(error);
|