@oxyhq/core 12.10.5 → 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.
Files changed (130) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +142 -37
  3. package/dist/cjs/i18n/locales/ar-SA.json +13 -1
  4. package/dist/cjs/i18n/locales/ca-ES.json +13 -1
  5. package/dist/cjs/i18n/locales/de-DE.json +13 -1
  6. package/dist/cjs/i18n/locales/en-US.json +13 -4
  7. package/dist/cjs/i18n/locales/es-ES.json +13 -4
  8. package/dist/cjs/i18n/locales/fr-FR.json +13 -1
  9. package/dist/cjs/i18n/locales/it-IT.json +13 -1
  10. package/dist/cjs/i18n/locales/ja-JP.json +13 -1
  11. package/dist/cjs/i18n/locales/ko-KR.json +13 -1
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
  15. package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
  23. package/dist/cjs/i18n/locales/pt-PT.json +13 -1
  24. package/dist/cjs/i18n/locales/zh-CN.json +13 -1
  25. package/dist/cjs/index.js +31 -5
  26. package/dist/cjs/mixins/OxyServices.auth.js +257 -17
  27. package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
  28. package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
  29. package/dist/cjs/mixins/index.js +5 -0
  30. package/dist/cjs/session/SessionClient.js +40 -4
  31. package/dist/cjs/session/accountDialogController.js +288 -41
  32. package/dist/cjs/session/hubSync.js +1 -1
  33. package/dist/cjs/session/identityPin.js +256 -0
  34. package/dist/cjs/session/identitySession.js +135 -0
  35. package/dist/cjs/session/projectSessionState.js +54 -15
  36. package/dist/cjs/session/refresh.js +73 -8
  37. package/dist/cjs/utils/commonsDelivery.js +59 -0
  38. package/dist/cjs/utils/oauthPkce.js +33 -3
  39. package/dist/esm/.tsbuildinfo +1 -1
  40. package/dist/esm/boot/sessionColdBoot.js +142 -37
  41. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  42. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  43. package/dist/esm/i18n/locales/de-DE.json +13 -1
  44. package/dist/esm/i18n/locales/en-US.json +13 -4
  45. package/dist/esm/i18n/locales/es-ES.json +13 -4
  46. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  47. package/dist/esm/i18n/locales/it-IT.json +13 -1
  48. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  49. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  50. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  51. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  52. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  53. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  54. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  55. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  56. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  57. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  58. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  59. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  60. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  61. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  62. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  63. package/dist/esm/index.js +14 -1
  64. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  65. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  66. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  67. package/dist/esm/mixins/index.js +5 -0
  68. package/dist/esm/session/SessionClient.js +40 -4
  69. package/dist/esm/session/accountDialogController.js +288 -41
  70. package/dist/esm/session/hubSync.js +1 -1
  71. package/dist/esm/session/identityPin.js +249 -0
  72. package/dist/esm/session/identitySession.js +131 -0
  73. package/dist/esm/session/projectSessionState.js +54 -15
  74. package/dist/esm/session/refresh.js +73 -8
  75. package/dist/esm/utils/commonsDelivery.js +54 -0
  76. package/dist/esm/utils/oauthPkce.js +31 -2
  77. package/dist/types/.tsbuildinfo +1 -1
  78. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  79. package/dist/types/index.d.ts +12 -4
  80. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  81. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  82. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  83. package/dist/types/mixins/index.d.ts +2 -1
  84. package/dist/types/session/SessionClient.d.ts +28 -0
  85. package/dist/types/session/accountDialogController.d.ts +187 -9
  86. package/dist/types/session/identityPin.d.ts +87 -0
  87. package/dist/types/session/identitySession.d.ts +105 -0
  88. package/dist/types/session/projectSessionState.d.ts +21 -19
  89. package/dist/types/session/refresh.d.ts +32 -1
  90. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  91. package/dist/types/utils/oauthPkce.d.ts +21 -1
  92. package/package.json +2 -2
  93. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  94. package/src/boot/sessionColdBoot.ts +183 -37
  95. package/src/i18n/locales/ar-SA.json +13 -1
  96. package/src/i18n/locales/ca-ES.json +13 -1
  97. package/src/i18n/locales/de-DE.json +13 -1
  98. package/src/i18n/locales/en-US.json +13 -4
  99. package/src/i18n/locales/es-ES.json +13 -4
  100. package/src/i18n/locales/fr-FR.json +13 -1
  101. package/src/i18n/locales/it-IT.json +13 -1
  102. package/src/i18n/locales/ja-JP.json +13 -1
  103. package/src/i18n/locales/ko-KR.json +13 -1
  104. package/src/i18n/locales/pt-PT.json +13 -1
  105. package/src/i18n/locales/zh-CN.json +13 -1
  106. package/src/index.ts +49 -0
  107. package/src/mixins/OxyServices.auth.ts +464 -24
  108. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  109. package/src/mixins/OxyServices.notifications.ts +142 -0
  110. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  111. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  112. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  113. package/src/mixins/index.ts +6 -0
  114. package/src/session/SessionClient.ts +57 -4
  115. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  116. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  117. package/src/session/__tests__/hubSync.test.ts +51 -0
  118. package/src/session/__tests__/identityPin.test.ts +131 -0
  119. package/src/session/__tests__/identitySession.test.ts +194 -0
  120. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  121. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  122. package/src/session/accountDialogController.ts +435 -44
  123. package/src/session/hubSync.ts +1 -1
  124. package/src/session/identityPin.ts +310 -0
  125. package/src/session/identitySession.ts +206 -0
  126. package/src/session/projectSessionState.ts +61 -13
  127. package/src/session/refresh.ts +103 -9
  128. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  129. package/src/utils/commonsDelivery.ts +132 -0
  130. package/src/utils/oauthPkce.ts +56 -3
@@ -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
- mint = await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
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
- const active = mint.state.accounts.find((a) => a.accountId === mint.state.activeAccountId);
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
- ...(active ? { sessionId: active.sessionId, userId: active.accountId } : {}),
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 allowSharedKeyFallback = deps.allowSharedKeyFallback ?? isNative();
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. On a shared-key device drop only
220
- // the secret (keep the identity so arm 2 can recover); otherwise (web) the
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
+ }
@@ -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(state: string, codeVerifier: string): boolean {
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(): { state: string; codeVerifier: string } | null {
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
- return { state, codeVerifier };
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);