@oxyhq/core 12.10.6 → 12.11.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/boot/sessionColdBoot.js +142 -37
- package/dist/cjs/i18n/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/cjs/i18n/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/zh-CN.json +13 -1
- package/dist/cjs/index.js +31 -5
- package/dist/cjs/mixins/OxyServices.auth.js +257 -17
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/index.js +5 -0
- package/dist/cjs/session/SessionClient.js +40 -4
- package/dist/cjs/session/accountDialogController.js +288 -41
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +54 -15
- package/dist/cjs/session/refresh.js +73 -8
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/oauthPkce.js +33 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +142 -37
- package/dist/esm/i18n/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/esm/i18n/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/zh-CN.json +13 -1
- package/dist/esm/index.js +14 -1
- package/dist/esm/mixins/OxyServices.auth.js +257 -17
- package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/index.js +5 -0
- package/dist/esm/session/SessionClient.js +40 -4
- package/dist/esm/session/accountDialogController.js +288 -41
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +54 -15
- package/dist/esm/session/refresh.js +73 -8
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/oauthPkce.js +31 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +38 -1
- package/dist/types/index.d.ts +12 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/session/SessionClient.d.ts +28 -0
- package/dist/types/session/accountDialogController.d.ts +187 -9
- package/dist/types/session/identityPin.d.ts +87 -0
- package/dist/types/session/identitySession.d.ts +105 -0
- package/dist/types/session/projectSessionState.d.ts +21 -19
- package/dist/types/session/refresh.d.ts +32 -1
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/oauthPkce.d.ts +21 -1
- package/package.json +2 -2
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/sessionColdBoot.ts +183 -37
- package/src/i18n/locales/ar-SA.json +13 -1
- package/src/i18n/locales/ca-ES.json +13 -1
- package/src/i18n/locales/de-DE.json +13 -1
- package/src/i18n/locales/en-US.json +13 -4
- package/src/i18n/locales/es-ES.json +13 -4
- package/src/i18n/locales/fr-FR.json +13 -1
- package/src/i18n/locales/it-IT.json +13 -1
- package/src/i18n/locales/ja-JP.json +13 -1
- package/src/i18n/locales/ko-KR.json +13 -1
- package/src/i18n/locales/pt-PT.json +13 -1
- package/src/i18n/locales/zh-CN.json +13 -1
- package/src/index.ts +49 -0
- package/src/mixins/OxyServices.auth.ts +464 -24
- package/src/mixins/OxyServices.deviceBoot.ts +54 -2
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
- package/src/mixins/index.ts +6 -0
- package/src/session/SessionClient.ts +57 -4
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/accountDialogController.test.ts +812 -1
- package/src/session/__tests__/identityPin.test.ts +131 -0
- package/src/session/__tests__/identitySession.test.ts +194 -0
- package/src/session/__tests__/projectSessionState.test.ts +88 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
- package/src/session/accountDialogController.ts +435 -44
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +61 -13
- package/src/session/refresh.ts +103 -9
- package/src/utils/__tests__/oauthPkce.test.ts +51 -0
- package/src/utils/commonsDelivery.ts +132 -0
- package/src/utils/oauthPkce.ts +56 -3
package/src/session/refresh.ts
CHANGED
|
@@ -24,6 +24,8 @@ import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
|
24
24
|
import type { OxyServices } from '../OxyServices';
|
|
25
25
|
import type { AuthRefreshHandler, AuthRefreshReason } from '../HttpService';
|
|
26
26
|
import type { AuthStateStore, PersistedAuthState } from './authStateStore';
|
|
27
|
+
import type { IdentityPin } from './identityPin';
|
|
28
|
+
import { establishIdentitySession, resolveIdentityPin, type IdentityBinding } from './identitySession';
|
|
27
29
|
import { isNative } from '../utils/platform';
|
|
28
30
|
import { extractErrorStatus } from '../utils/errorUtils';
|
|
29
31
|
import { logger } from '../logger';
|
|
@@ -67,9 +69,19 @@ export interface RefreshDeps {
|
|
|
67
69
|
/**
|
|
68
70
|
* Whether to fall back to the native shared-keychain re-mint (arm 2) when the
|
|
69
71
|
* persisted secret is absent / rejected. Defaults to `isNative()` — web has no
|
|
70
|
-
* shared keychain. Exposed for tests.
|
|
72
|
+
* shared keychain. Exposed for tests. IGNORED when {@link identity} is set: an
|
|
73
|
+
* identity-bound client must never adopt the CROSS-APP shared slot, which may
|
|
74
|
+
* hold a different identity than this device's primary key.
|
|
71
75
|
*/
|
|
72
76
|
allowSharedKeyFallback?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Identity-bound (pinned) mode. When present, every re-mint targets the
|
|
79
|
+
* PINNED account — resolved fresh from the pin store on each call, since a
|
|
80
|
+
* re-established session can move it — instead of the device's active
|
|
81
|
+
* account, and arm 2 becomes the PRIMARY-key identity sign-in rather than the
|
|
82
|
+
* shared-keychain one.
|
|
83
|
+
*/
|
|
84
|
+
identity?: IdentityBinding;
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
/**
|
|
@@ -82,6 +94,10 @@ export interface RefreshDeps {
|
|
|
82
94
|
* diverged (another tab/device rotated it past the grace window).
|
|
83
95
|
* - `no-session` — 401 `no_active_session`: the device is known but has no live
|
|
84
96
|
* session (authoritative signed-out).
|
|
97
|
+
* - `account-not-on-device` — 401 `account_not_on_device` for a PINNED mint: the
|
|
98
|
+
* pinned account is not (or no longer) a live account of this device session.
|
|
99
|
+
* The device secret is FINE — it is the identity binding that went stale, so
|
|
100
|
+
* the caller must re-establish from the local key, never drop the credential.
|
|
85
101
|
* - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
|
|
86
102
|
* - `persist-failed` — the mint succeeded (the SERVER rotated the secret) but
|
|
87
103
|
* the rotated `nextDeviceSecret` could NOT be durably persisted. The token is
|
|
@@ -93,6 +109,7 @@ export type DeviceSecretMintOutcome =
|
|
|
93
109
|
| { status: 'no-secret' }
|
|
94
110
|
| { status: 'invalid-secret' }
|
|
95
111
|
| { status: 'no-session' }
|
|
112
|
+
| { status: 'account-not-on-device' }
|
|
96
113
|
| { status: 'transient' }
|
|
97
114
|
| { status: 'persist-failed' };
|
|
98
115
|
|
|
@@ -116,12 +133,21 @@ export type DeviceSecretMintOutcome =
|
|
|
116
133
|
* planting. This function performs NO store mutation on failure — the caller
|
|
117
134
|
* applies the drop/clear policy (which differs web vs native) from the returned
|
|
118
135
|
* status.
|
|
136
|
+
*
|
|
137
|
+
* `pin` makes the mint IDENTITY-BOUND: the request carries the pinned
|
|
138
|
+
* `accountId` (so the server mints that account's token without touching
|
|
139
|
+
* `activeAccountId`), and the persisted `sessionId`/`userId` are resolved from
|
|
140
|
+
* the PINNED account entry — never from `state.activeAccountId`, whose drift is
|
|
141
|
+
* exactly what the pin exists to stop.
|
|
119
142
|
*/
|
|
120
143
|
export async function refreshDeviceSecretArm(deps: {
|
|
121
144
|
oxy: OxyServices;
|
|
122
145
|
store: AuthStateStore;
|
|
146
|
+
/** The identity pin, when this client is identity-bound. */
|
|
147
|
+
pin?: IdentityPin | null;
|
|
123
148
|
}): Promise<DeviceSecretMintOutcome> {
|
|
124
149
|
const { oxy, store } = deps;
|
|
150
|
+
const pin = deps.pin ?? null;
|
|
125
151
|
return oxy.httpService.runSingleFlightDeviceSecretMint(async () => {
|
|
126
152
|
const persisted = await store.load();
|
|
127
153
|
if (!persisted?.deviceId || !persisted?.deviceSecret) {
|
|
@@ -130,7 +156,13 @@ export async function refreshDeviceSecretArm(deps: {
|
|
|
130
156
|
|
|
131
157
|
let mint: DeviceTokenMintResponse;
|
|
132
158
|
try {
|
|
133
|
-
|
|
159
|
+
// Unpinned callers pass NO third argument at all, so the account-mode call
|
|
160
|
+
// shape (and therefore the request body) is untouched by this feature.
|
|
161
|
+
mint = pin
|
|
162
|
+
? await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret, {
|
|
163
|
+
accountId: pin.accountId,
|
|
164
|
+
})
|
|
165
|
+
: await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
|
|
134
166
|
} catch (error) {
|
|
135
167
|
if (extractErrorStatus(error) === 401) {
|
|
136
168
|
// Structural read (not `instanceof Error`): the thrown value can be a
|
|
@@ -147,19 +179,26 @@ export async function refreshDeviceSecretArm(deps: {
|
|
|
147
179
|
// ecosystem-wide.
|
|
148
180
|
if (body.includes('invalid_device_secret')) return { status: 'invalid-secret' };
|
|
149
181
|
if (body.includes('no_active_session')) return { status: 'no-session' };
|
|
182
|
+
// A pinned mint whose account left the device set. The secret is intact —
|
|
183
|
+
// never classify this as a bad secret, or the caller would drop a healthy
|
|
184
|
+
// credential over a stale identity binding.
|
|
185
|
+
if (body.includes('account_not_on_device')) return { status: 'account-not-on-device' };
|
|
150
186
|
return { status: 'transient' };
|
|
151
187
|
}
|
|
152
188
|
return { status: 'transient' };
|
|
153
189
|
}
|
|
154
190
|
|
|
155
|
-
|
|
191
|
+
// The account this session is BOUND to: the pinned one when identity-bound
|
|
192
|
+
// (the server already minted for it), else the device's active account.
|
|
193
|
+
const boundAccountId = pin ? pin.accountId : mint.state.activeAccountId;
|
|
194
|
+
const bound = mint.state.accounts.find((a) => a.accountId === boundAccountId);
|
|
156
195
|
const next: PersistedAuthState = {
|
|
157
196
|
...persisted,
|
|
158
197
|
deviceId: mint.state.deviceId,
|
|
159
198
|
deviceSecret: mint.nextDeviceSecret,
|
|
160
199
|
accessToken: mint.accessToken,
|
|
161
200
|
expiresAt: mint.expiresAt,
|
|
162
|
-
...(
|
|
201
|
+
...(bound ? { sessionId: bound.sessionId, userId: bound.accountId } : {}),
|
|
163
202
|
};
|
|
164
203
|
// Rotation-in-use anti-loss: persist the NEXT secret and read-back-VERIFY it
|
|
165
204
|
// landed BEFORE planting the token. A failed durable persist must NOT plant.
|
|
@@ -188,12 +227,26 @@ export async function refreshDeviceSecretArm(deps: {
|
|
|
188
227
|
* recovered `{deviceId, deviceSecret, …}` is PERSISTED so the fast device-secret
|
|
189
228
|
* lane is repopulated (mirrors the cold boot's `shared-key-signin` step) — an
|
|
190
229
|
* in-session shared-key recovery must not leave the fast-lane credential empty.
|
|
230
|
+
*
|
|
231
|
+
* IDENTITY-BOUND clients (`deps.identity`) run a different arm 2: the
|
|
232
|
+
* shared-keychain lane is DISABLED (its cross-app slot may hold a different
|
|
233
|
+
* identity) and replaced by {@link establishIdentitySession}, which re-signs a
|
|
234
|
+
* challenge with the PRIMARY local key and rewrites the pin. Arm 1 is pinned.
|
|
191
235
|
*/
|
|
192
236
|
export async function refreshPersistedSession(deps: RefreshDeps): Promise<string | null> {
|
|
193
237
|
const { oxy, store } = deps;
|
|
194
|
-
const
|
|
238
|
+
const identity = deps.identity ?? null;
|
|
239
|
+
// The shared keychain is never an identity-bound client's recovery path.
|
|
240
|
+
const allowSharedKeyFallback = identity ? false : (deps.allowSharedKeyFallback ?? isNative());
|
|
241
|
+
// Resolved per call: a re-established identity session can move the pin, and a
|
|
242
|
+
// replaced/removed local key clears it (in which case arm 1 must NOT mint —
|
|
243
|
+
// an unpinned mint would adopt whatever account the device switched to).
|
|
244
|
+
const pin = identity ? await resolveIdentityPin(identity) : null;
|
|
245
|
+
if (identity && !pin) {
|
|
246
|
+
return recoverIdentitySession(oxy, store, identity);
|
|
247
|
+
}
|
|
195
248
|
|
|
196
|
-
const arm1 = await refreshDeviceSecretArm({ oxy, store });
|
|
249
|
+
const arm1 = await refreshDeviceSecretArm({ oxy, store, pin });
|
|
197
250
|
switch (arm1.status) {
|
|
198
251
|
case 'ok':
|
|
199
252
|
return arm1.token;
|
|
@@ -216,11 +269,12 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
216
269
|
return null;
|
|
217
270
|
case 'invalid-secret':
|
|
218
271
|
case 'no-session': {
|
|
219
|
-
// 401: secret diverged or no live session.
|
|
220
|
-
//
|
|
272
|
+
// 401: secret diverged or no live session. When a key-based arm 2 can still
|
|
273
|
+
// recover (native shared key, or an identity-bound client's own primary
|
|
274
|
+
// key) drop ONLY the secret and keep the deviceId; otherwise (web) the
|
|
221
275
|
// session is over — clear the store.
|
|
222
276
|
const persisted = await store.load();
|
|
223
|
-
if (allowSharedKeyFallback) {
|
|
277
|
+
if (allowSharedKeyFallback || identity) {
|
|
224
278
|
if (persisted) {
|
|
225
279
|
await store.save({ ...persisted, deviceSecret: undefined });
|
|
226
280
|
}
|
|
@@ -229,10 +283,22 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
229
283
|
}
|
|
230
284
|
break;
|
|
231
285
|
}
|
|
286
|
+
case 'account-not-on-device':
|
|
287
|
+
// The pinned account left this device's session set. The secret is healthy —
|
|
288
|
+
// leave the store untouched and let the identity arm re-establish.
|
|
289
|
+
logger.debug(
|
|
290
|
+
'Pinned device-secret mint rejected: the pinned account is no longer on this device — re-establishing from the identity key',
|
|
291
|
+
{ component: 'refresh', method: 'refreshPersistedSession' },
|
|
292
|
+
);
|
|
293
|
+
break;
|
|
232
294
|
case 'no-secret':
|
|
233
295
|
break;
|
|
234
296
|
}
|
|
235
297
|
|
|
298
|
+
if (identity) {
|
|
299
|
+
return recoverIdentitySession(oxy, store, identity);
|
|
300
|
+
}
|
|
301
|
+
|
|
236
302
|
if (allowSharedKeyFallback) {
|
|
237
303
|
try {
|
|
238
304
|
const session = await oxy.signInWithSharedIdentity();
|
|
@@ -262,6 +328,34 @@ export async function refreshPersistedSession(deps: RefreshDeps): Promise<string
|
|
|
262
328
|
return null;
|
|
263
329
|
}
|
|
264
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Arm 2 for an IDENTITY-BOUND client: re-establish the session from the PRIMARY
|
|
333
|
+
* local key and rewrite the pin (`establishIdentitySession` plants the token and
|
|
334
|
+
* persists the device credential itself).
|
|
335
|
+
*
|
|
336
|
+
* Returns `null` — never throws — when there is no local identity, the verify
|
|
337
|
+
* yielded no token, or the exchange failed: the caller treats that as "could not
|
|
338
|
+
* refresh", exactly like the shared-key arm. A locked keychain therefore ends
|
|
339
|
+
* signed out rather than falling back to the device's active account.
|
|
340
|
+
*/
|
|
341
|
+
async function recoverIdentitySession(
|
|
342
|
+
oxy: OxyServices,
|
|
343
|
+
store: AuthStateStore,
|
|
344
|
+
binding: IdentityBinding,
|
|
345
|
+
): Promise<string | null> {
|
|
346
|
+
try {
|
|
347
|
+
const established = await establishIdentitySession({ oxy, store, binding });
|
|
348
|
+
return established?.session.accessToken ?? null;
|
|
349
|
+
} catch (error) {
|
|
350
|
+
logger.debug(
|
|
351
|
+
'Identity-key re-sign-in failed',
|
|
352
|
+
{ component: 'refresh', method: 'recoverIdentitySession' },
|
|
353
|
+
error,
|
|
354
|
+
);
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
265
359
|
/**
|
|
266
360
|
* Build the reactive `AuthRefreshHandler` (arm 1 + arm 2). Install it via
|
|
267
361
|
* {@link installAuthRefreshHandler} or directly on
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import {
|
|
3
3
|
buildOAuthAuthorizeUrl,
|
|
4
|
+
canonicalizeOAuthRedirectUri,
|
|
4
5
|
computeCodeChallenge,
|
|
5
6
|
DEFAULT_OAUTH_SCOPE,
|
|
6
7
|
generateOAuthState,
|
|
7
8
|
generatePkcePair,
|
|
8
9
|
OXY_AUTHORIZE_URL,
|
|
10
|
+
OXY_OAUTH_REDIRECT_URI_STORAGE_KEY,
|
|
9
11
|
OXY_OAUTH_RETURN_PATH_STORAGE_KEY,
|
|
10
12
|
clearOAuthHandshake,
|
|
11
13
|
consumeOAuthReturnPath,
|
|
14
|
+
persistOAuthHandshake,
|
|
12
15
|
persistOAuthReturnPath,
|
|
16
|
+
readOAuthHandshake,
|
|
13
17
|
} from '../oauthPkce';
|
|
14
18
|
|
|
15
19
|
/** RFC 7636 unreserved subset produced by base64url (no `+`, `/`, `=`). */
|
|
@@ -157,6 +161,53 @@ describe('buildOAuthAuthorizeUrl', () => {
|
|
|
157
161
|
});
|
|
158
162
|
});
|
|
159
163
|
|
|
164
|
+
describe('canonicalizeOAuthRedirectUri', () => {
|
|
165
|
+
it('collapses apex URLs to origin only', () => {
|
|
166
|
+
expect(canonicalizeOAuthRedirectUri('https://app.example/')).toBe('https://app.example');
|
|
167
|
+
expect(canonicalizeOAuthRedirectUri('https://app.example')).toBe('https://app.example');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('preserves path-qualified redirect URIs', () => {
|
|
171
|
+
const uri = 'https://app.example/oauth/callback';
|
|
172
|
+
expect(canonicalizeOAuthRedirectUri(uri)).toBe(uri);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('OAuth handshake persistence', () => {
|
|
177
|
+
function installSessionStorage(): Map<string, string> {
|
|
178
|
+
const map = new Map<string, string>();
|
|
179
|
+
(globalThis as { sessionStorage?: Storage }).sessionStorage = {
|
|
180
|
+
getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null),
|
|
181
|
+
setItem: (k: string, v: string) => void map.set(k, v),
|
|
182
|
+
removeItem: (k: string) => void map.delete(k),
|
|
183
|
+
clear: () => map.clear(),
|
|
184
|
+
key: (i: number) => [...map.keys()][i] ?? null,
|
|
185
|
+
get length() {
|
|
186
|
+
return map.size;
|
|
187
|
+
},
|
|
188
|
+
} as unknown as Storage;
|
|
189
|
+
return map;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
afterEach(() => {
|
|
193
|
+
delete (globalThis as { sessionStorage?: Storage }).sessionStorage;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('round-trips redirect_uri through persist/read/clear', () => {
|
|
197
|
+
const store = installSessionStorage();
|
|
198
|
+
const redirectUri = 'https://app.example/oauth/callback';
|
|
199
|
+
expect(persistOAuthHandshake('state-1', 'verifier-1', redirectUri)).toBe(true);
|
|
200
|
+
expect(readOAuthHandshake()).toEqual({
|
|
201
|
+
state: 'state-1',
|
|
202
|
+
codeVerifier: 'verifier-1',
|
|
203
|
+
redirectUri,
|
|
204
|
+
});
|
|
205
|
+
clearOAuthHandshake();
|
|
206
|
+
expect(store.get(OXY_OAUTH_REDIRECT_URI_STORAGE_KEY)).toBeUndefined();
|
|
207
|
+
expect(readOAuthHandshake()).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
160
211
|
describe('OAuth return path', () => {
|
|
161
212
|
/** Minimal sessionStorage stand-in — jsdom is not assumed by this suite. */
|
|
162
213
|
function installSessionStorage(): Map<string, string> {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { getPlatformOS } from './platform';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Automatic "Sign in with Oxy" delivery selection (issue #691, Phase 4).
|
|
5
|
+
*
|
|
6
|
+
* The user performs ONE action ("Continue with Oxy"); Oxy — not the user —
|
|
7
|
+
* decides how the approval request reaches their Commons identity. This module
|
|
8
|
+
* is that decision, and nothing else: a single pure function mapping the facts
|
|
9
|
+
* the caller has gathered onto exactly ONE primary route.
|
|
10
|
+
*
|
|
11
|
+
* Deliberate design constraints:
|
|
12
|
+
*
|
|
13
|
+
* - **Pure.** No I/O, no platform sniffing, no clock, no globals. Every input
|
|
14
|
+
* is passed in by the caller, which is what makes the decision exhaustively
|
|
15
|
+
* unit-testable and identical on web, native, and server.
|
|
16
|
+
* - **One route, never a chain.** The result is the PRIMARY route only.
|
|
17
|
+
* Alternatives stay hidden behind a "Having trouble?" affordance and are
|
|
18
|
+
* revealed by the UI only once the primary route fails or is unavailable —
|
|
19
|
+
* the SDK never silently cascades from one delivery surface to the next.
|
|
20
|
+
* - **Fail-safe to QR.** QR is the route that works with no prior knowledge of
|
|
21
|
+
* the device, so every ambiguous or malformed input degrades to it rather
|
|
22
|
+
* than to a route that could leave the user staring at a dead end.
|
|
23
|
+
*
|
|
24
|
+
* Every route drives the SAME `AuthSession`, resolved from the same public
|
|
25
|
+
* `authorizeCode`; the route only decides how that code travels.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The kind of surface the sign-in was initiated from.
|
|
30
|
+
*
|
|
31
|
+
* `'unknown'` is a first-class value, not an error: a caller that cannot
|
|
32
|
+
* confidently classify the surface must say so rather than guess, and an
|
|
33
|
+
* unknown surface never opts into the deep-link route (a custom-scheme
|
|
34
|
+
* navigation that does not resolve is a dead end with no automatic way back).
|
|
35
|
+
*/
|
|
36
|
+
export type CommonsDeliveryPlatform = 'mobile' | 'desktop' | 'unknown';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The single primary route chosen for this request.
|
|
40
|
+
*
|
|
41
|
+
* - `'open-commons'` — navigate to the verified Commons app/universal link on
|
|
42
|
+
* THIS device and let the user confirm there.
|
|
43
|
+
* - `'await-push'` — the request was pushed to a known Commons installation;
|
|
44
|
+
* show "Check Commons on your phone" and wait for the authorization.
|
|
45
|
+
* - `'qr'` — render the QR carrying the public `authorizeCode` for the user to
|
|
46
|
+
* scan with Commons on another device.
|
|
47
|
+
*/
|
|
48
|
+
export type CommonsDeliveryRoute = 'open-commons' | 'await-push' | 'qr';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The facts the caller must gather before asking for a route. All of them are
|
|
52
|
+
* observations, never decisions — the caller owns the platform detection, the
|
|
53
|
+
* app-link verification, and the delivery round-trip; this module owns only the
|
|
54
|
+
* choice between them.
|
|
55
|
+
*/
|
|
56
|
+
export interface CommonsDeliveryFacts {
|
|
57
|
+
/** Which surface the sign-in was initiated from (see {@link CommonsDeliveryPlatform}). */
|
|
58
|
+
platform: CommonsDeliveryPlatform;
|
|
59
|
+
/**
|
|
60
|
+
* `true` only when a VERIFIED Commons app/universal link can be opened on
|
|
61
|
+
* this very device (an installed, link-verified Commons). "The user probably
|
|
62
|
+
* has Commons somewhere" is not this fact — that is what `pushTargets`
|
|
63
|
+
* expresses.
|
|
64
|
+
*/
|
|
65
|
+
commonsAvailable: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* How many eligible Commons installations the server said it delivered the
|
|
68
|
+
* request to — the `targets` field of `deliverCommonsSignIn`. Zero is a
|
|
69
|
+
* NORMAL outcome (no capable Commons install is registered for this
|
|
70
|
+
* identity), never an error; it simply means push is not a usable route.
|
|
71
|
+
*/
|
|
72
|
+
pushTargets: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Choose the ONE primary delivery route for a "Sign in with Oxy" request.
|
|
77
|
+
*
|
|
78
|
+
* Decision order (issue #691, "Automatic delivery selection"):
|
|
79
|
+
*
|
|
80
|
+
* 1. Mobile with a verified Commons link available → open Commons directly.
|
|
81
|
+
* No push is needed when the identity is already reachable on this device.
|
|
82
|
+
* 2. At least one eligible Commons installation was pushed to → await the push.
|
|
83
|
+
* 3. Otherwise → QR.
|
|
84
|
+
*
|
|
85
|
+
* A caller that already knows it is on step 1 does not need to call
|
|
86
|
+
* `deliverCommonsSignIn` at all; every other caller runs the delivery
|
|
87
|
+
* round-trip first and passes its `targets` in here.
|
|
88
|
+
*
|
|
89
|
+
* `pushTargets` is server-derived, so it is validated rather than trusted: a
|
|
90
|
+
* negative, fractional, non-finite, or otherwise malformed count is treated as
|
|
91
|
+
* "no targets" and degrades to QR instead of parking the user on a "check your
|
|
92
|
+
* phone" screen that nothing will ever wake.
|
|
93
|
+
*/
|
|
94
|
+
export function selectCommonsDelivery(facts: CommonsDeliveryFacts): CommonsDeliveryRoute {
|
|
95
|
+
if (facts.platform === 'mobile' && facts.commonsAvailable) {
|
|
96
|
+
return 'open-commons';
|
|
97
|
+
}
|
|
98
|
+
if (Number.isInteger(facts.pushTargets) && facts.pushTargets >= 1) {
|
|
99
|
+
return 'await-push';
|
|
100
|
+
}
|
|
101
|
+
return 'qr';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Map a `deliverCommonsSignIn` result onto the `pushTargets` fact for
|
|
106
|
+
* {@link selectCommonsDelivery}. The server reports eligible install *count*
|
|
107
|
+
* separately from whether any push was accepted — a transport failure can leave
|
|
108
|
+
* `delivered: false` with `targets > 0`, which must NOT park the user on
|
|
109
|
+
* "check your phone".
|
|
110
|
+
*/
|
|
111
|
+
export function pushTargetsFromDelivery(result: {
|
|
112
|
+
delivered: boolean;
|
|
113
|
+
targets: number;
|
|
114
|
+
}): number {
|
|
115
|
+
return result.delivered ? result.targets : 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Classify the current runtime surface for delivery selection. Callers that
|
|
120
|
+
* cannot confidently detect the platform should not use this — pass
|
|
121
|
+
* `'unknown'` explicitly instead.
|
|
122
|
+
*/
|
|
123
|
+
export function commonsDeliveryPlatform(): CommonsDeliveryPlatform {
|
|
124
|
+
const os = getPlatformOS();
|
|
125
|
+
if (os === 'ios' || os === 'android') {
|
|
126
|
+
return 'mobile';
|
|
127
|
+
}
|
|
128
|
+
if (os === 'web') {
|
|
129
|
+
return 'desktop';
|
|
130
|
+
}
|
|
131
|
+
return 'unknown';
|
|
132
|
+
}
|
package/src/utils/oauthPkce.ts
CHANGED
|
@@ -66,6 +66,18 @@ export interface BuildOAuthAuthorizeUrlParams {
|
|
|
66
66
|
* (no UI when the IdP hub already has a session + grant).
|
|
67
67
|
*/
|
|
68
68
|
prompt?: 'none' | 'login' | 'consent';
|
|
69
|
+
/**
|
|
70
|
+
* How the IdP should deliver the authorization response. Omitted (the
|
|
71
|
+
* default) means the ordinary top-level redirect back to `redirectUri`.
|
|
72
|
+
*
|
|
73
|
+
* `web_message` asks the IdP to `postMessage` the result to its opener
|
|
74
|
+
* instead, so a popup sign-in never navigates the relying party's tab. It is
|
|
75
|
+
* a REQUEST, not a guarantee: an IdP with no opener still redirects, which is
|
|
76
|
+
* why the popup transport must handle both outcomes. Only the authorization
|
|
77
|
+
* code, the `state`, and a typed OAuth error ever cross that channel — never
|
|
78
|
+
* a token, device secret, or the PKCE verifier, which the opener keeps.
|
|
79
|
+
*/
|
|
80
|
+
responseMode?: 'web_message';
|
|
69
81
|
}
|
|
70
82
|
|
|
71
83
|
/**
|
|
@@ -192,6 +204,9 @@ export function buildOAuthAuthorizeUrl(params: BuildOAuthAuthorizeUrlParams): st
|
|
|
192
204
|
if (params.prompt) {
|
|
193
205
|
url.searchParams.set('prompt', params.prompt);
|
|
194
206
|
}
|
|
207
|
+
if (params.responseMode) {
|
|
208
|
+
url.searchParams.set('response_mode', params.responseMode);
|
|
209
|
+
}
|
|
195
210
|
|
|
196
211
|
return url.toString();
|
|
197
212
|
}
|
|
@@ -202,6 +217,9 @@ export const OXY_OAUTH_STATE_STORAGE_KEY = 'oxy_oauth_state';
|
|
|
202
217
|
/** `sessionStorage` key for the PKCE `code_verifier` across an authorize redirect. */
|
|
203
218
|
export const OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = 'oxy_oauth_code_verifier';
|
|
204
219
|
|
|
220
|
+
/** `sessionStorage` key — the exact `redirect_uri` sent on the authorize request. */
|
|
221
|
+
export const OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = 'oxy.oauth_redirect_uri';
|
|
222
|
+
|
|
205
223
|
/** `sessionStorage` key — at most one silent OAuth attempt per navigation. */
|
|
206
224
|
export const OXY_SILENT_OAUTH_ATTEMPTED_KEY = 'oxy.silent_oauth_attempted';
|
|
207
225
|
|
|
@@ -231,6 +249,25 @@ export function normalizeOAuthRedirectUri(input: string): string {
|
|
|
231
249
|
}
|
|
232
250
|
}
|
|
233
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Collapse `https://app.example/` → `https://app.example` for OAuth binding.
|
|
254
|
+
* Path-qualified redirect URIs are preserved — matches the API token exchange.
|
|
255
|
+
*/
|
|
256
|
+
export function canonicalizeOAuthRedirectUri(redirectUri: string): string {
|
|
257
|
+
try {
|
|
258
|
+
const parsed = new URL(redirectUri);
|
|
259
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
260
|
+
return redirectUri;
|
|
261
|
+
}
|
|
262
|
+
if (parsed.pathname === '/' && !parsed.search && !parsed.hash) {
|
|
263
|
+
return parsed.origin;
|
|
264
|
+
}
|
|
265
|
+
return redirectUri;
|
|
266
|
+
} catch {
|
|
267
|
+
return redirectUri;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
234
271
|
/**
|
|
235
272
|
* Is this a safe same-origin path to restore into the address bar?
|
|
236
273
|
*
|
|
@@ -295,12 +332,22 @@ export function consumeOAuthReturnPath(): string | null {
|
|
|
295
332
|
}
|
|
296
333
|
|
|
297
334
|
/** Persist the OAuth handshake for a full-page redirect return (web only). */
|
|
298
|
-
export function persistOAuthHandshake(
|
|
335
|
+
export function persistOAuthHandshake(
|
|
336
|
+
state: string,
|
|
337
|
+
codeVerifier: string,
|
|
338
|
+
redirectUri?: string,
|
|
339
|
+
): boolean {
|
|
299
340
|
const store = (globalThis as { sessionStorage?: Storage }).sessionStorage;
|
|
300
341
|
try {
|
|
301
342
|
if (!store) throw new Error('sessionStorage is unavailable');
|
|
302
343
|
store.setItem(OXY_OAUTH_STATE_STORAGE_KEY, state);
|
|
303
344
|
store.setItem(OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, codeVerifier);
|
|
345
|
+
if (redirectUri) {
|
|
346
|
+
store.setItem(
|
|
347
|
+
OXY_OAUTH_REDIRECT_URI_STORAGE_KEY,
|
|
348
|
+
canonicalizeOAuthRedirectUri(redirectUri),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
304
351
|
return true;
|
|
305
352
|
} catch (error) {
|
|
306
353
|
logger.warn(
|
|
@@ -313,13 +360,18 @@ export function persistOAuthHandshake(state: string, codeVerifier: string): bool
|
|
|
313
360
|
}
|
|
314
361
|
|
|
315
362
|
/** Read the persisted OAuth handshake, or `null` when absent. */
|
|
316
|
-
export function readOAuthHandshake(): {
|
|
363
|
+
export function readOAuthHandshake(): {
|
|
364
|
+
state: string;
|
|
365
|
+
codeVerifier: string;
|
|
366
|
+
redirectUri?: string;
|
|
367
|
+
} | null {
|
|
317
368
|
const store = (globalThis as { sessionStorage?: Storage }).sessionStorage;
|
|
318
369
|
if (!store) return null;
|
|
319
370
|
const state = store.getItem(OXY_OAUTH_STATE_STORAGE_KEY);
|
|
320
371
|
const codeVerifier = store.getItem(OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY);
|
|
321
372
|
if (!state || !codeVerifier) return null;
|
|
322
|
-
|
|
373
|
+
const redirectUri = store.getItem(OXY_OAUTH_REDIRECT_URI_STORAGE_KEY) ?? undefined;
|
|
374
|
+
return redirectUri ? { state, codeVerifier, redirectUri } : { state, codeVerifier };
|
|
323
375
|
}
|
|
324
376
|
|
|
325
377
|
/** Drop persisted OAuth handshake keys after a successful or aborted return. */
|
|
@@ -328,6 +380,7 @@ export function clearOAuthHandshake(): void {
|
|
|
328
380
|
try {
|
|
329
381
|
store?.removeItem(OXY_OAUTH_STATE_STORAGE_KEY);
|
|
330
382
|
store?.removeItem(OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY);
|
|
383
|
+
store?.removeItem(OXY_OAUTH_REDIRECT_URI_STORAGE_KEY);
|
|
331
384
|
// The return path belongs to this handshake. Leaving it behind would let a
|
|
332
385
|
// later, unrelated navigation in the same tab be redirected by it.
|
|
333
386
|
store?.removeItem(OXY_OAUTH_RETURN_PATH_STORAGE_KEY);
|