@oxy.so/core 1.2.1 → 1.3.1

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 (100) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/identityMove.js +123 -0
  3. package/dist/cjs/crypto/keyManager.js +60 -21
  4. package/dist/cjs/crypto/webIdentityCarrier.js +262 -0
  5. package/dist/cjs/i18n/locales/ar-SA.json +16 -13
  6. package/dist/cjs/i18n/locales/ca-ES.json +16 -13
  7. package/dist/cjs/i18n/locales/de-DE.json +16 -13
  8. package/dist/cjs/i18n/locales/en-US.json +16 -17
  9. package/dist/cjs/i18n/locales/es-ES.json +16 -17
  10. package/dist/cjs/i18n/locales/fr-FR.json +16 -13
  11. package/dist/cjs/i18n/locales/it-IT.json +16 -13
  12. package/dist/cjs/i18n/locales/ja-JP.json +16 -13
  13. package/dist/cjs/i18n/locales/ko-KR.json +16 -13
  14. package/dist/cjs/i18n/locales/locales/ar-SA.json +16 -13
  15. package/dist/cjs/i18n/locales/locales/ca-ES.json +16 -13
  16. package/dist/cjs/i18n/locales/locales/de-DE.json +16 -13
  17. package/dist/cjs/i18n/locales/locales/en-US.json +16 -17
  18. package/dist/cjs/i18n/locales/locales/es-ES.json +16 -17
  19. package/dist/cjs/i18n/locales/locales/fr-FR.json +16 -13
  20. package/dist/cjs/i18n/locales/locales/it-IT.json +16 -13
  21. package/dist/cjs/i18n/locales/locales/ja-JP.json +16 -13
  22. package/dist/cjs/i18n/locales/locales/ko-KR.json +16 -13
  23. package/dist/cjs/i18n/locales/locales/pt-PT.json +16 -13
  24. package/dist/cjs/i18n/locales/locales/zh-CN.json +16 -13
  25. package/dist/cjs/i18n/locales/pt-PT.json +16 -13
  26. package/dist/cjs/i18n/locales/zh-CN.json +16 -13
  27. package/dist/cjs/index.js +38 -4
  28. package/dist/cjs/mixins/OxyServices.auth.js +14 -4
  29. package/dist/cjs/mixins/OxyServices.identity.js +14 -0
  30. package/dist/cjs/session/accountDialogController.js +333 -142
  31. package/dist/cjs/utils/authWebUrl.js +8 -1
  32. package/dist/esm/.tsbuildinfo +1 -1
  33. package/dist/esm/crypto/identityMove.js +110 -0
  34. package/dist/esm/crypto/keyManager.js +60 -21
  35. package/dist/esm/crypto/webIdentityCarrier.js +243 -0
  36. package/dist/esm/i18n/locales/ar-SA.json +16 -13
  37. package/dist/esm/i18n/locales/ca-ES.json +16 -13
  38. package/dist/esm/i18n/locales/de-DE.json +16 -13
  39. package/dist/esm/i18n/locales/en-US.json +16 -17
  40. package/dist/esm/i18n/locales/es-ES.json +16 -17
  41. package/dist/esm/i18n/locales/fr-FR.json +16 -13
  42. package/dist/esm/i18n/locales/it-IT.json +16 -13
  43. package/dist/esm/i18n/locales/ja-JP.json +16 -13
  44. package/dist/esm/i18n/locales/ko-KR.json +16 -13
  45. package/dist/esm/i18n/locales/locales/ar-SA.json +16 -13
  46. package/dist/esm/i18n/locales/locales/ca-ES.json +16 -13
  47. package/dist/esm/i18n/locales/locales/de-DE.json +16 -13
  48. package/dist/esm/i18n/locales/locales/en-US.json +16 -17
  49. package/dist/esm/i18n/locales/locales/es-ES.json +16 -17
  50. package/dist/esm/i18n/locales/locales/fr-FR.json +16 -13
  51. package/dist/esm/i18n/locales/locales/it-IT.json +16 -13
  52. package/dist/esm/i18n/locales/locales/ja-JP.json +16 -13
  53. package/dist/esm/i18n/locales/locales/ko-KR.json +16 -13
  54. package/dist/esm/i18n/locales/locales/pt-PT.json +16 -13
  55. package/dist/esm/i18n/locales/locales/zh-CN.json +16 -13
  56. package/dist/esm/i18n/locales/pt-PT.json +16 -13
  57. package/dist/esm/i18n/locales/zh-CN.json +16 -13
  58. package/dist/esm/index.js +5 -1
  59. package/dist/esm/mixins/OxyServices.auth.js +14 -4
  60. package/dist/esm/mixins/OxyServices.identity.js +14 -0
  61. package/dist/esm/session/accountDialogController.js +334 -143
  62. package/dist/esm/utils/authWebUrl.js +7 -0
  63. package/dist/types/.tsbuildinfo +1 -1
  64. package/dist/types/crypto/identityMove.d.ts +59 -0
  65. package/dist/types/crypto/keyManager.d.ts +35 -5
  66. package/dist/types/crypto/webIdentityCarrier.d.ts +123 -0
  67. package/dist/types/index.d.ts +5 -2
  68. package/dist/types/mixins/OxyServices.auth.d.ts +12 -0
  69. package/dist/types/models/interfaces.d.ts +15 -0
  70. package/dist/types/session/accountDialogController.d.ts +169 -17
  71. package/dist/types/utils/authWebUrl.d.ts +7 -0
  72. package/package.json +3 -3
  73. package/src/crypto/__tests__/identityMove.test.ts +104 -0
  74. package/src/crypto/__tests__/keyManager.sharedSlotSync.test.ts +150 -0
  75. package/src/crypto/__tests__/webIdentityCarrier.test.ts +211 -0
  76. package/src/crypto/identityMove.ts +133 -0
  77. package/src/crypto/keyManager.ts +71 -21
  78. package/src/crypto/webIdentityCarrier.ts +326 -0
  79. package/src/i18n/locales/ar-SA.json +16 -13
  80. package/src/i18n/locales/ca-ES.json +16 -13
  81. package/src/i18n/locales/de-DE.json +16 -13
  82. package/src/i18n/locales/en-US.json +16 -17
  83. package/src/i18n/locales/es-ES.json +16 -17
  84. package/src/i18n/locales/fr-FR.json +16 -13
  85. package/src/i18n/locales/it-IT.json +16 -13
  86. package/src/i18n/locales/ja-JP.json +16 -13
  87. package/src/i18n/locales/ko-KR.json +16 -13
  88. package/src/i18n/locales/pt-PT.json +16 -13
  89. package/src/i18n/locales/zh-CN.json +16 -13
  90. package/src/index.ts +38 -1
  91. package/src/mixins/OxyServices.auth.ts +16 -3
  92. package/src/mixins/OxyServices.identity.ts +14 -0
  93. package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +47 -0
  94. package/src/mixins/__tests__/commonsSignIn.test.ts +3 -0
  95. package/src/mixins/__tests__/verifyChallenge.test.ts +31 -0
  96. package/src/models/interfaces.ts +15 -0
  97. package/src/session/__tests__/accountDialogController.test.ts +407 -10
  98. package/src/session/accountDialogController.ts +406 -145
  99. package/src/utils/__tests__/oauthPkce.test.ts +50 -26
  100. package/src/utils/authWebUrl.ts +8 -0
@@ -116,28 +116,36 @@ const IDLE_SIGN_IN_FACTS = {
116
116
  qrPayload: null,
117
117
  expiresAt: null,
118
118
  error: null,
119
+ failure: null,
119
120
  route: null,
120
121
  routeFailed: false,
121
122
  pushSentAt: null,
122
123
  openedAt: null,
123
124
  };
124
- const IDLE_SIGN_IN = {
125
- ...IDLE_SIGN_IN_FACTS,
126
- progress: deriveSignInProgress(IDLE_SIGN_IN_FACTS),
127
- };
128
125
  /**
129
- * Terminal SUCCESS state: the session was claimed and committed. Holds no live
126
+ * Terminal SUCCESS facts: the session was claimed and committed. Holds no live
130
127
  * resources and no request handles — only the terminal progress the surface
131
128
  * shows ("Identity confirmed") before it closes.
132
129
  */
133
- const COMPLETED_SIGN_IN = {
134
- ...IDLE_SIGN_IN_FACTS,
135
- phase: 'completed',
136
- progress: deriveSignInProgress({ ...IDLE_SIGN_IN_FACTS, phase: 'completed' }),
137
- };
130
+ const COMPLETED_SIGN_IN_FACTS = { ...IDLE_SIGN_IN_FACTS, phase: 'completed' };
131
+ /** The full flow state for `facts`, stamped with the attempt it belongs to. */
132
+ function buildSignIn(facts, attempt) {
133
+ return { ...facts, progress: deriveSignInProgress(facts), attempt };
134
+ }
138
135
  function errorMessage(error) {
139
136
  return error instanceof Error ? error.message : String(error);
140
137
  }
138
+ /**
139
+ * Classify a request that never produced a response. `OxyServices.handleError`
140
+ * normalizes transport failures to `NETWORK_ERROR` / `TIMEOUT` with status `0`.
141
+ */
142
+ function requestFailureReason(error) {
143
+ const code = error && typeof error === 'object' ? error.code : undefined;
144
+ if (code === 'NETWORK_ERROR' || code === 'TIMEOUT' || (0, errorUtils_1.extractErrorStatus)(error) === 0) {
145
+ return 'network';
146
+ }
147
+ return 'unknown';
148
+ }
141
149
  class AccountDialogController {
142
150
  constructor(options) {
143
151
  this.listeners = new Set();
@@ -148,9 +156,25 @@ class AccountDialogController {
148
156
  this.activatingContextId = null;
149
157
  this.removingContextId = null;
150
158
  this.removingPrincipalId = null;
151
- this.signIn = IDLE_SIGN_IN;
159
+ /**
160
+ * `true` while an operation run through {@link runDeviceMutation} (a host
161
+ * sign-out) is in flight. The fourth member of the ONE device-mutation gate,
162
+ * alongside the three flags above.
163
+ */
164
+ this.exclusiveMutationInFlight = false;
165
+ this.signIn = buildSignIn(IDLE_SIGN_IN_FACTS, 0);
152
166
  this.commonsAvailability = 'unknown';
153
167
  // --- Sign-in device-flow bookkeeping ---
168
+ /**
169
+ * The CURRENT sign-in attempt. Every asynchronous step of a sign-in captures
170
+ * it before awaiting and re-checks it after (`isCurrentAttempt`); cancelling,
171
+ * starting another attempt, or destroying the controller moves it, so a late
172
+ * response from an abandoned attempt can neither update the surface nor
173
+ * install a session.
174
+ */
175
+ this.signInAttempt = 0;
176
+ /** How the current attempt was started, so a retry repeats the user's choice. */
177
+ this.signInMethod = 'oxy';
154
178
  /** The secret device-flow token of the active QR flow (never surfaced). */
155
179
  this.signInToken = null;
156
180
  this.pollTimer = null;
@@ -163,9 +187,10 @@ class AccountDialogController {
163
187
  /**
164
188
  * Guards {@link pollOnce} against re-entrancy: the fallback timer and a socket
165
189
  * `auth_update` wake can fire together — without this both could claim the
166
- * single-use token concurrently.
190
+ * single-use token concurrently. Keyed by token, so a poll still running for an
191
+ * ABANDONED request never blocks the next request's first poll.
167
192
  */
168
- this.pollInFlight = false;
193
+ this.pollInFlightToken = null;
169
194
  /** The popup opened by {@link startPasskeyHubSignIn}, or `null`. */
170
195
  this.activePopup = null;
171
196
  /** Watches {@link activePopup}'s `closed` state; see {@link watchPopup}. */
@@ -187,7 +212,7 @@ class AccountDialogController {
187
212
  this.canOpenApp = options.canOpenApp;
188
213
  this.socketFactory = options.socketFactory;
189
214
  this.openPopup = options.openPopup;
190
- this.hubBaseUrl = options.hubBaseUrl ?? `https://auth.${authWebUrl_1.CENTRAL_IDP_APEX}`;
215
+ this.identityOrigin = options.identityOrigin ?? options.hubBaseUrl ?? authWebUrl_1.IDENTITY_WEB_ORIGIN;
191
216
  this.platform = options.platform ?? 'unknown';
192
217
  this.snapshot = this.computeSnapshot();
193
218
  }
@@ -253,6 +278,8 @@ class AccountDialogController {
253
278
  */
254
279
  destroy() {
255
280
  this.started = false;
281
+ // Nothing still in flight may act on a destroyed controller.
282
+ this.signInAttempt += 1;
256
283
  if (this.unsubscribeSession) {
257
284
  this.unsubscribeSession();
258
285
  this.unsubscribeSession = null;
@@ -324,7 +351,7 @@ class AccountDialogController {
324
351
  // another view is a NEW intention, so drop it; otherwise a later `add()`
325
352
  // would open on the previous sign-in's terminal state.
326
353
  if (this.signIn.phase === 'completed') {
327
- this.signIn = IDLE_SIGN_IN;
354
+ this.signIn = buildSignIn(IDLE_SIGN_IN_FACTS, this.signInAttempt);
328
355
  }
329
356
  this.emit();
330
357
  }
@@ -349,6 +376,9 @@ class AccountDialogController {
349
376
  * — and the reconstruction was not merely redundant: it enumerated the
350
377
  * CALLER's account graph, which on a device holding two people is one
351
378
  * person's answer presented as the device's.
379
+ *
380
+ * Resolves `false` when THIS read failed (a 401 is the signed-out edge, not a
381
+ * failure). Never rejects.
352
382
  */
353
383
  async refresh() {
354
384
  const seq = ++this.refreshSeq;
@@ -359,13 +389,14 @@ class AccountDialogController {
359
389
  this.loading = false;
360
390
  this.error = null;
361
391
  this.emit();
362
- return;
392
+ return true;
363
393
  }
364
394
  // Nothing to show yet is the only state worth a spinner; a re-read behind an
365
395
  // already-rendered directory refreshes in place.
366
396
  this.loading = this.sessionClient.getDirectory() === null;
367
397
  this.error = null;
368
398
  this.emit();
399
+ let failure = null;
369
400
  try {
370
401
  await this.sessionClient.refreshDirectory();
371
402
  }
@@ -379,14 +410,18 @@ class AccountDialogController {
379
410
  logger_1.logger.debug('[AccountDialogController] directory unauthorized (signed out)', { component: 'AccountDialogController' }, error);
380
411
  }
381
412
  else {
382
- this.error = errorMessage(error);
413
+ failure = errorMessage(error);
383
414
  logger_1.logger.warn('[AccountDialogController] directory refresh failed', { component: 'AccountDialogController' }, error);
384
415
  }
385
416
  }
417
+ // Superseded by a newer refresh: that read owns `error` and `loading`, so an
418
+ // older failure must not overwrite its outcome.
386
419
  if (seq !== this.refreshSeq)
387
- return; // superseded by a newer refresh
420
+ return failure === null;
421
+ this.error = failure;
388
422
  this.loading = false;
389
423
  this.emit();
424
+ return failure === null;
390
425
  }
391
426
  // =========================================================================
392
427
  // Activation and the two removals (ADR 0002)
@@ -404,26 +439,18 @@ class AccountDialogController {
404
439
  * A context id is not stable across a removal, so a stale one is an ordinary
405
440
  * outcome rather than a bug: the server answers 404 or 403, heals the row, and
406
441
  * the refresh below re-reads a directory that no longer offers it.
442
+ *
443
+ * Resolves `true` once the SWITCH happened — even if the directory re-read
444
+ * after it fails (that failure lands on `error`, but the subject did change).
445
+ * Resolves `false` when the switch failed, or was refused because another
446
+ * device mutation is in flight ({@link isDeviceMutationInFlight}).
407
447
  */
408
448
  async activateContext(contextId) {
409
- if (this.activatingContextId)
410
- return false;
411
- this.activatingContextId = contextId;
412
- this.error = null;
413
- this.emit();
414
- try {
415
- await this.sessionClient.activateContext(contextId);
416
- await this.refresh();
417
- return true;
418
- }
419
- catch (error) {
420
- this.error = errorMessage(error);
421
- return false;
422
- }
423
- finally {
449
+ return this.mutateDevice(() => {
450
+ this.activatingContextId = contextId;
451
+ }, () => {
424
452
  this.activatingContextId = null;
425
- this.emit();
426
- }
453
+ }, () => this.sessionClient.activateContext(contextId));
427
454
  }
428
455
  /**
429
456
  * Remove ONE `principal → account` pair, and only that pair.
@@ -438,24 +465,11 @@ class AccountDialogController {
438
465
  * revision — so nothing may hold a context id across this call.
439
466
  */
440
467
  async signOutContext(contextId) {
441
- if (this.removingContextId || this.removingPrincipalId)
442
- return false;
443
- this.removingContextId = contextId;
444
- this.error = null;
445
- this.emit();
446
- try {
447
- await this.sessionClient.signOutContext(contextId);
448
- await this.refresh();
449
- return true;
450
- }
451
- catch (error) {
452
- this.error = errorMessage(error);
453
- return false;
454
- }
455
- finally {
468
+ return this.mutateDevice(() => {
469
+ this.removingContextId = contextId;
470
+ }, () => {
456
471
  this.removingContextId = null;
457
- this.emit();
458
- }
472
+ }, () => this.sessionClient.signOutContext(contextId));
459
473
  }
460
474
  /**
461
475
  * Remove ONE PERSON and every context they reach — and nobody else's,
@@ -466,22 +480,75 @@ class AccountDialogController {
466
480
  * and elects a replacement active context in one transition.
467
481
  */
468
482
  async signOutPrincipal(principalId) {
469
- if (this.removingContextId || this.removingPrincipalId)
483
+ return this.mutateDevice(() => {
484
+ this.removingPrincipalId = principalId;
485
+ }, () => {
486
+ this.removingPrincipalId = null;
487
+ }, () => this.sessionClient.signOutPrincipal(principalId));
488
+ }
489
+ /**
490
+ * Whether ANY operation that changes who is on this device, or which of them
491
+ * is active, is in flight: an activation, either removal, or a host operation
492
+ * run through {@link runDeviceMutation}.
493
+ *
494
+ * They share ONE gate because they mutate the same device set: a removal that
495
+ * elects a replacement active context racing a switch could leave either one
496
+ * active. A refused call is refused, never queued — a press that could not run
497
+ * must not fire later, after the user has moved on.
498
+ */
499
+ isDeviceMutationInFlight() {
500
+ return (this.activatingContextId !== null ||
501
+ this.removingContextId !== null ||
502
+ this.removingPrincipalId !== null ||
503
+ this.exclusiveMutationInFlight);
504
+ }
505
+ /**
506
+ * Run a device mutation the controller does not own (the host's sign-out)
507
+ * under the same gate as {@link activateContext} and the removals.
508
+ *
509
+ * Resolves `{ ran: false }` without calling `operation` when another mutation
510
+ * is in flight; otherwise `{ ran: true, value }`. A rejection from `operation`
511
+ * propagates, after the gate is released.
512
+ */
513
+ async runDeviceMutation(operation) {
514
+ if (this.isDeviceMutationInFlight())
515
+ return { ran: false };
516
+ this.exclusiveMutationInFlight = true;
517
+ try {
518
+ return { ran: true, value: await operation() };
519
+ }
520
+ finally {
521
+ this.exclusiveMutationInFlight = false;
522
+ }
523
+ }
524
+ /**
525
+ * The shared body of the three controller-owned device mutations: take the
526
+ * gate, run the mutation, then re-read the directory.
527
+ *
528
+ * The mutation's outcome and the re-read's are kept apart. Once `operation`
529
+ * has resolved the device HAS changed, so this resolves `true` even when the
530
+ * re-read fails — that failure is the directory's (`error`), and reporting it
531
+ * as a failed switch would invite the user to repeat something that happened.
532
+ */
533
+ async mutateDevice(mark, clear, operation) {
534
+ if (this.isDeviceMutationInFlight())
470
535
  return false;
471
- this.removingPrincipalId = principalId;
536
+ mark();
472
537
  this.error = null;
473
538
  this.emit();
474
539
  try {
475
- await this.sessionClient.signOutPrincipal(principalId);
540
+ try {
541
+ await operation();
542
+ }
543
+ catch (error) {
544
+ this.error = errorMessage(error);
545
+ return false;
546
+ }
476
547
  await this.refresh();
477
548
  return true;
478
549
  }
479
- catch (error) {
480
- this.error = errorMessage(error);
481
- return false;
482
- }
483
550
  finally {
484
- this.removingPrincipalId = null;
551
+ clear();
485
552
  this.emit();
486
553
  }
487
554
  }
@@ -492,24 +559,31 @@ class AccountDialogController {
492
559
  * Start "Sign in with Oxy". Native devices with a shared identity mint a
493
560
  * session silently (`signInWithSharedIdentity`); everything else (web, or a
494
561
  * native device without a shared identity) falls through to the cross-device
495
- * QR handoff.
562
+ * QR handoff — as the SAME attempt, so cancelling during either half stops both.
496
563
  */
497
564
  async signInWithOxy() {
565
+ const attempt = this.beginSignInAttempt('oxy');
498
566
  this.setView('qr');
499
567
  this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
500
568
  try {
501
- const session = await this.oxyServices.signInWithSharedIdentity();
569
+ // Minted WITHOUT planting the bearer: `completeSignIn` installs it only if
570
+ // this attempt is still the current one when the mint returns.
571
+ const session = await this.oxyServices.signInWithSharedIdentity({ plantTokens: false });
572
+ if (!this.isCurrentAttempt(attempt))
573
+ return;
502
574
  if (session) {
503
- await this.completeSignIn(session, session.user);
575
+ await this.completeSignIn(attempt, session, session.user);
504
576
  return;
505
577
  }
506
578
  }
507
579
  catch (error) {
580
+ if (!this.isCurrentAttempt(attempt))
581
+ return;
508
582
  // Shared-key mint failed — log and fall through to the QR handoff rather
509
583
  // than dead-ending the sign-in.
510
584
  logger_1.logger.warn('[AccountDialogController] signInWithSharedIdentity failed', { component: 'AccountDialogController' }, error);
511
585
  }
512
- await this.showQr();
586
+ await this.startDeviceFlowSession(attempt, { deliver: true });
513
587
  }
514
588
  /**
515
589
  * Begin (or restart) the cross-device QR handoff: create a device-flow
@@ -518,16 +592,38 @@ class AccountDialogController {
518
592
  * session committed. Requires `clientId`.
519
593
  */
520
594
  async showQr() {
521
- this.cancelSignIn();
595
+ const attempt = this.beginSignInAttempt('qr');
522
596
  this.setView('qr');
523
- await this.startDeviceFlowSession({ deliver: true });
597
+ await this.startDeviceFlowSession(attempt, { deliver: true });
524
598
  }
525
599
  /**
526
- * Web-only: "Sign in with a passkey" on a non-Oxy origin cannot run the
527
- * WebAuthn ceremony locally — a credential minted with `WEBAUTHN_RP_ID=oxy.so`
528
- * can only be asserted from `oxy.so`/a subdomain/loopback, a browser-enforced
529
- * boundary `isOxyRpOrigin()` (consumer-side) already gates on. Instead, open
530
- * a popup at the auth.oxy.so passkey hub, scoped to the SAME device-flow
600
+ * Start a NEW attempt the same way the last one was started — "Try again"
601
+ * repeats the user's choice rather than silently switching method. A failed
602
+ * passkey-hub attempt reopens the hub popup; a failed "Sign in with Oxy"
603
+ * retries that; an explicit QR request shows the QR.
604
+ *
605
+ * Call it straight from the press handler: the hub route opens its popup
606
+ * synchronously, before this method's first `await`, so the browser still
607
+ * attributes it to the click.
608
+ */
609
+ retrySignIn() {
610
+ switch (this.signInMethod) {
611
+ case 'passkey-hub':
612
+ return this.startPasskeyHubSignIn();
613
+ case 'qr':
614
+ return this.showQr();
615
+ default:
616
+ return this.signInWithOxy();
617
+ }
618
+ }
619
+ /**
620
+ * Web-only: sign in (or create an account) with a passkey at the web identity
621
+ * carrier. The ceremony runs on `id.oxy.so`, never on the calling origin, for
622
+ * two reasons: a credential minted with `WEBAUTHN_RP_ID=oxy.so` can only be
623
+ * asserted from `oxy.so`/a subdomain/loopback (a browser-enforced boundary),
624
+ * and only the identity origin may unseal or create the account's identity
625
+ * (one identity, two carriers). Opens a popup at `<identityOrigin>/continue`,
626
+ * scoped to the SAME device-flow
531
627
  * session {@link showQr} would create (same `authorizeCode`/`sessionToken`
532
628
  * pair), and let the SAME poll/socket/claim engine complete it once the hub
533
629
  * authorizes the session (`POST /auth/session/authorize-code/:authorizeCode`,
@@ -542,83 +638,119 @@ class AccountDialogController {
542
638
  * `showQr`'s plain QR rendering — no dead end.
543
639
  */
544
640
  async startPasskeyHubSignIn() {
545
- this.cancelSignIn();
641
+ const attempt = this.beginSignInAttempt('passkey-hub');
546
642
  this.setView('qr');
547
643
  const popup = this.openPopup?.() ?? null;
548
644
  if (!popup) {
549
- await this.startDeviceFlowSession({ deliver: true });
645
+ await this.startDeviceFlowSession(attempt, { deliver: true });
550
646
  return;
551
647
  }
648
+ // Owned by the attempt from the moment it exists, so cancelling while the
649
+ // session is still being created closes it instead of leaving a blank
650
+ // window behind.
651
+ this.activePopup = popup;
552
652
  // The hub popup IS the primary surface here, chosen explicitly by the user —
553
653
  // so this flow does NOT run automatic Commons delivery (ringing the user's
554
654
  // phone because they asked for a passkey would be exactly the "menu of
555
655
  // methods" the one-primary-action rule forbids). The underlying request is
556
656
  // the same `AuthSession`, and its Commons route stays the QR the view
557
657
  // renders beneath the popup.
558
- const handle = await this.startDeviceFlowSession({ deliver: false });
559
- if (!handle) {
560
- popup.close();
658
+ const handle = await this.startDeviceFlowSession(attempt, { deliver: false });
659
+ // `null` means the attempt failed (its teardown closed the popup) or was
660
+ // abandoned (the cancel closed it).
661
+ if (!handle)
561
662
  return;
562
- }
563
663
  // The user may have closed the popup during the async session creation
564
664
  // above, before any watcher was attached to catch it — guard rather than
565
665
  // navigate a dead window (browsers vary on whether that throws).
566
666
  if (popup.closed) {
567
- this.failSignIn('Sign-in was cancelled.');
667
+ const pendingCode = this.signIn.authorizeCode;
668
+ this.failSignIn('cancelled', 'Sign-in was cancelled.');
669
+ if (pendingCode)
670
+ void this.withdrawRequest(pendingCode);
568
671
  return;
569
672
  }
570
- this.activePopup = popup;
571
- popup.location.href = `${this.hubBaseUrl}/hub-passkey?code=${encodeURIComponent(handle.authorizeCode)}`;
673
+ popup.location.href = `${this.identityOrigin}/continue?code=${encodeURIComponent(handle.authorizeCode)}`;
572
674
  this.watchPopup(popup);
573
675
  }
676
+ /**
677
+ * End whatever attempt is running (withdrawing its request) and begin a new
678
+ * one. Returns the new attempt's identity for the caller to re-check after
679
+ * every `await`.
680
+ */
681
+ beginSignInAttempt(method) {
682
+ this.cancelSignIn();
683
+ this.signInMethod = method;
684
+ return this.signInAttempt;
685
+ }
686
+ /** Whether `attempt` is still the one the surface is waiting on. */
687
+ isCurrentAttempt(attempt) {
688
+ return attempt === this.signInAttempt;
689
+ }
574
690
  /**
575
691
  * Shared device-flow session creation for both {@link showQr} (renders the
576
692
  * QR) and {@link startPasskeyHubSignIn} (also opens the hub popup) — the
577
693
  * same `startCommonsSignIn` → poll/socket wiring either way. Returns the
578
- * handle on success (already reflected in `signIn`), or `null` on failure
579
- * (already set as `signIn.error`).
694
+ * handle on success (already reflected in `signIn`), or `null` when the
695
+ * attempt failed (already set as `signIn.failure`) or was abandoned while the
696
+ * request was being created.
580
697
  *
698
+ * @param attempt - The attempt this request belongs to. A request that comes
699
+ * back for an abandoned attempt is withdrawn server-side and never wired up:
700
+ * closing the dialog on a slow connection must not leave an approvable
701
+ * request, a socket, and a poll behind it.
581
702
  * @param opts.deliver - Whether to run automatic Commons delivery selection
582
703
  * ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
583
704
  * entry; `false` when the caller already owns the primary surface (the
584
705
  * passkey hub popup), where the request's Commons route is simply the QR.
585
706
  */
586
- async startDeviceFlowSession(opts) {
707
+ async startDeviceFlowSession(attempt, opts) {
708
+ if (!this.isCurrentAttempt(attempt))
709
+ return null;
587
710
  if (!this.clientId) {
588
- this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: 'This app is not configured for sign-in (missing clientId).' });
711
+ this.failSignIn('not-configured', 'This app is not configured for sign-in (missing clientId).');
589
712
  return null;
590
713
  }
591
714
  this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
715
+ let handle;
592
716
  try {
593
- const handle = await this.oxyServices.startCommonsSignIn({ clientId: this.clientId });
594
- this.signInToken = handle.sessionToken;
595
- this.setSignIn({
596
- ...IDLE_SIGN_IN_FACTS,
597
- phase: 'waiting',
598
- authorizeCode: handle.authorizeCode,
599
- qrPayload: handle.qrPayload,
600
- expiresAt: handle.expiresAt,
601
- // No route yet: the surface shows "Preparing request" until the primary
602
- // route is resolved below. It is never guessed in the meantime.
603
- route: opts.deliver ? null : 'qr',
604
- });
605
- // Primary path: an instant `auth_update` wake over the `/auth-session`
606
- // socket. The poll below is only the fallback for when the socket can't
607
- // connect, so it now runs at the slow fallback cadence.
608
- this.openAuthSessionSocket(handle.sessionToken);
609
- this.scheduleNextPoll(handle.sessionToken);
610
- if (opts.deliver) {
611
- // Non-blocking on purpose: the QR/authorizeCode are already renderable
612
- // and the popup caller can navigate immediately, while the route (a
613
- // local probe plus at most one delivery round-trip) resolves behind it.
614
- void this.resolveDeliveryRoute(handle);
615
- }
616
- return handle;
717
+ handle = await this.oxyServices.startCommonsSignIn({ clientId: this.clientId });
617
718
  }
618
719
  catch (error) {
619
- this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: errorMessage(error) });
720
+ if (!this.isCurrentAttempt(attempt))
721
+ return null;
722
+ this.failSignIn(requestFailureReason(error), errorMessage(error));
723
+ return null;
724
+ }
725
+ if (!this.isCurrentAttempt(attempt)) {
726
+ // Created after the user walked away. The cancel could not withdraw it (it
727
+ // had no code yet), so withdraw it now — best effort, like every withdrawal.
728
+ void this.withdrawRequest(handle.authorizeCode);
620
729
  return null;
621
730
  }
731
+ this.signInToken = handle.sessionToken;
732
+ this.setSignIn({
733
+ ...IDLE_SIGN_IN_FACTS,
734
+ phase: 'waiting',
735
+ authorizeCode: handle.authorizeCode,
736
+ qrPayload: handle.qrPayload,
737
+ expiresAt: handle.expiresAt,
738
+ // No route yet: the surface shows "Preparing request" until the primary
739
+ // route is resolved below. It is never guessed in the meantime.
740
+ route: opts.deliver ? null : 'qr',
741
+ });
742
+ // Primary path: an instant `auth_update` wake over the `/auth-session`
743
+ // socket. The poll below is only the fallback for when the socket can't
744
+ // connect, so it now runs at the slow fallback cadence.
745
+ void this.openAuthSessionSocket(handle.sessionToken);
746
+ this.scheduleNextPoll(handle.sessionToken);
747
+ if (opts.deliver) {
748
+ // Non-blocking on purpose: the QR/authorizeCode are already renderable
749
+ // and the popup caller can navigate immediately, while the route (a
750
+ // local probe plus at most one delivery round-trip) resolves behind it.
751
+ void this.resolveDeliveryRoute(handle);
752
+ }
753
+ return handle;
622
754
  }
623
755
  /**
624
756
  * Choose and carry out the ONE primary delivery route for the active request
@@ -735,7 +867,7 @@ class AccountDialogController {
735
867
  if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
736
868
  // Closing the surface cancels the REQUEST too, not just this listener.
737
869
  const pendingCode = this.signIn.authorizeCode;
738
- this.failSignIn('Sign-in was cancelled.');
870
+ this.failSignIn('cancelled', 'Sign-in was cancelled.');
739
871
  if (pendingCode) {
740
872
  void this.withdrawRequest(pendingCode);
741
873
  }
@@ -798,8 +930,15 @@ class AccountDialogController {
798
930
  * cancel the request, not just stop listening to it. Without the withdrawal a
799
931
  * dismissed QR would stay approvable until it expired, so a later scan of a
800
932
  * stale code could authorize a session nobody is waiting for.
933
+ *
934
+ * It also ENDS the attempt: anything of it still awaiting a response (the
935
+ * request being created, a shared-identity mint, a claim) finds itself
936
+ * superseded when that response arrives, and neither touches the surface nor
937
+ * installs a session. The withdrawal itself is best effort — cancelling
938
+ * locally is not proof the server received it (see {@link withdrawRequest}).
801
939
  */
802
940
  cancelSignIn() {
941
+ this.signInAttempt += 1;
803
942
  // Capture before the teardown clears it, and only for a request that can
804
943
  // still be approved — a completed/failed flow has nothing to withdraw.
805
944
  const pendingCode = this.signIn.phase === 'starting' || this.signIn.phase === 'waiting'
@@ -809,7 +948,7 @@ class AccountDialogController {
809
948
  this.closeAuthSessionSocket();
810
949
  this.closeActivePopup();
811
950
  this.signInToken = null;
812
- if (this.signIn !== IDLE_SIGN_IN) {
951
+ if (this.signIn.phase !== 'idle') {
813
952
  this.setSignIn(IDLE_SIGN_IN_FACTS);
814
953
  }
815
954
  if (pendingCode) {
@@ -848,13 +987,14 @@ class AccountDialogController {
848
987
  */
849
988
  async pollOnce(sessionToken) {
850
989
  // A superseded / cancelled flow must not act; a poll already running owns the claim.
851
- if (this.signInToken !== sessionToken || this.pollInFlight)
990
+ if (this.signInToken !== sessionToken || this.pollInFlightToken === sessionToken)
852
991
  return;
853
- this.pollInFlight = true;
992
+ const attempt = this.signInAttempt;
993
+ this.pollInFlightToken = sessionToken;
854
994
  try {
855
995
  const expiresAt = this.signIn.expiresAt;
856
996
  if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
857
- this.failSignIn('Session expired. Please try again.');
997
+ this.failSignIn('expired', 'Session expired. Please try again.');
858
998
  return;
859
999
  }
860
1000
  try {
@@ -870,20 +1010,20 @@ class AccountDialogController {
870
1010
  // OAuth-bound sessions mint no sessionId on approval — they finalize
871
1011
  // into an authorization code. The account dialog only starts device
872
1012
  // sign-in today; stop rather than poll until expiry.
873
- this.failSignIn('This sign-in flow cannot be completed here. Use the app\'s OAuth sign-in instead.');
1013
+ this.failSignIn('unsupported-flow', 'This sign-in flow cannot be completed here. Use the app\'s OAuth sign-in instead.');
874
1014
  return;
875
1015
  }
876
1016
  if (status.authorized && status.sessionId) {
877
1017
  this.clearPollTimer();
878
- await this.claimAndComplete(status.sessionId, sessionToken);
1018
+ await this.claimAndComplete(attempt, status.sessionId, sessionToken);
879
1019
  return;
880
1020
  }
881
1021
  if (status.status === 'cancelled') {
882
- this.failSignIn('Authorization was denied.');
1022
+ this.failSignIn('denied', 'Authorization was denied.');
883
1023
  return;
884
1024
  }
885
1025
  if (status.status === 'expired') {
886
- this.failSignIn('Session expired. Please try again.');
1026
+ this.failSignIn('expired', 'Session expired. Please try again.');
887
1027
  return;
888
1028
  }
889
1029
  }
@@ -896,7 +1036,8 @@ class AccountDialogController {
896
1036
  }
897
1037
  }
898
1038
  finally {
899
- this.pollInFlight = false;
1039
+ if (this.pollInFlightToken === sessionToken)
1040
+ this.pollInFlightToken = null;
900
1041
  }
901
1042
  }
902
1043
  /**
@@ -914,18 +1055,24 @@ class AccountDialogController {
914
1055
  return;
915
1056
  this.patchSignIn({ pushSentAt: nextPushSentAt, openedAt: nextOpenedAt });
916
1057
  }
917
- async claimAndComplete(sessionId, sessionToken) {
1058
+ async claimAndComplete(attempt, sessionId, sessionToken) {
918
1059
  this.patchSignIn({ phase: 'authorized' });
919
1060
  let claimed;
920
1061
  try {
921
- claimed = await this.oxyServices.claimSessionByToken(sessionToken);
1062
+ // Claimed WITHOUT planting the bearer — `completeSignIn` installs it only
1063
+ // if the user is still waiting for this attempt when the claim returns.
1064
+ claimed = await this.oxyServices.claimSessionByToken(sessionToken, { plantTokens: false });
922
1065
  }
923
1066
  catch (error) {
924
- this.failSignIn(errorMessage(error));
1067
+ if (!this.isCurrentAttempt(attempt))
1068
+ return;
1069
+ this.failSignIn('claim-failed', errorMessage(error));
925
1070
  return;
926
1071
  }
1072
+ if (!this.isCurrentAttempt(attempt))
1073
+ return;
927
1074
  if (!claimed?.accessToken || !claimed.user) {
928
- this.failSignIn('Authorization succeeded but the session could not be claimed. Please try again.');
1075
+ this.failSignIn('claim-failed', 'Authorization succeeded but the session could not be claimed. Please try again.');
929
1076
  return;
930
1077
  }
931
1078
  // `SessionLoginResponse.user` is the minimal session-carried shape; the claim
@@ -938,7 +1085,7 @@ class AccountDialogController {
938
1085
  avatar: claimed.user.avatar ?? undefined,
939
1086
  };
940
1087
  try {
941
- await this.completeSignIn({
1088
+ await this.completeSignIn(attempt, {
942
1089
  sessionId: claimed.sessionId || sessionId,
943
1090
  deviceId: claimed.deviceId ?? '',
944
1091
  expiresAt: claimed.expiresAt ?? '',
@@ -948,24 +1095,63 @@ class AccountDialogController {
948
1095
  }, minimalUser);
949
1096
  }
950
1097
  catch (error) {
951
- this.failSignIn(errorMessage(error));
1098
+ if (this.isCurrentAttempt(attempt))
1099
+ this.failSignIn('unknown', errorMessage(error));
952
1100
  }
953
1101
  }
954
1102
  /**
955
- * Commit an authorized session, notify, and return to the account list. Shared
956
- * by the shared-key, QR, and mint-switch paths so they cannot drift.
1103
+ * Install an authorized session, notify, and return to the account list.
1104
+ * Shared by the shared-key and QR paths so they cannot drift.
1105
+ *
1106
+ * THE install point, and so the last place an abandoned attempt is stopped:
1107
+ * the bearer is planted and the session committed only while `attempt` is
1108
+ * still current. A commit that fails restores the bearer the client had
1109
+ * before, so a half-installed session never leaves requests going out as an
1110
+ * account the device does not hold.
1111
+ *
1112
+ * A failed commit REJECTS (after that restore), so each caller keeps its own
1113
+ * policy: the QR path fails the attempt, the shared-identity path falls
1114
+ * through to the QR handoff.
1115
+ *
1116
+ * Once the commit has resolved the session IS this device's, whether or not
1117
+ * the user moved on while it ran. It is reported (`onSignedIn`, re-read) either
1118
+ * way; only the surface bookkeeping is skipped for a superseded attempt, so a
1119
+ * newer attempt's state is never overwritten.
1120
+ *
1121
+ * Residual: an attempt abandoned after the server minted its session (a claim
1122
+ * or shared-identity mint that was already in flight) leaves that server
1123
+ * session un-installed on this device — it is never planted or committed here,
1124
+ * but it is not revoked either.
957
1125
  */
958
- async completeSignIn(session, user) {
959
- await this.commitAuthorizedSession(session, user);
960
- this.signInToken = null;
961
- this.clearPollTimer();
962
- this.closeAuthSessionSocket();
963
- this.closeActivePopup();
964
- // Terminal SUCCESS, not idle: the surface gets one honest frame to show
965
- // "Identity confirmed" before it closes. Cleared on the next view change.
966
- this.signIn = COMPLETED_SIGN_IN;
967
- this.view = 'accounts';
968
- this.emit();
1126
+ async completeSignIn(attempt, session, user) {
1127
+ if (!this.isCurrentAttempt(attempt))
1128
+ return;
1129
+ const previousToken = this.oxyServices.getAccessToken();
1130
+ try {
1131
+ if (session.accessToken)
1132
+ this.oxyServices.setTokens(session.accessToken);
1133
+ await this.commitAuthorizedSession(session, user);
1134
+ }
1135
+ catch (error) {
1136
+ if (session.accessToken && this.oxyServices.getAccessToken() === session.accessToken) {
1137
+ if (previousToken)
1138
+ this.oxyServices.setTokens(previousToken);
1139
+ else
1140
+ this.oxyServices.clearTokens();
1141
+ }
1142
+ throw error;
1143
+ }
1144
+ if (this.isCurrentAttempt(attempt)) {
1145
+ this.signInToken = null;
1146
+ this.clearPollTimer();
1147
+ this.closeAuthSessionSocket();
1148
+ this.closeActivePopup();
1149
+ // Terminal SUCCESS, not idle: the surface gets one honest frame to show
1150
+ // "Identity confirmed" before it closes. Cleared on the next view change.
1151
+ this.signIn = buildSignIn(COMPLETED_SIGN_IN_FACTS, this.signInAttempt);
1152
+ this.view = 'accounts';
1153
+ this.emit();
1154
+ }
969
1155
  this.onSignedIn?.(user);
970
1156
  await this.refresh();
971
1157
  }
@@ -982,12 +1168,16 @@ class AccountDialogController {
982
1168
  await this.sessionClient.registerAndActivate(user.id);
983
1169
  }
984
1170
  }
985
- failSignIn(message) {
1171
+ /**
1172
+ * End the CURRENT attempt as failed. The attempt keeps its identity (the
1173
+ * failure belongs to it); only a new attempt or a cancel moves it on.
1174
+ */
1175
+ failSignIn(failure, message) {
986
1176
  this.clearPollTimer();
987
1177
  this.closeAuthSessionSocket();
988
1178
  this.closeActivePopup();
989
1179
  this.signInToken = null;
990
- this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message });
1180
+ this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message, failure });
991
1181
  }
992
1182
  clearPollTimer() {
993
1183
  if (this.pollTimer !== null) {
@@ -1077,18 +1267,19 @@ class AccountDialogController {
1077
1267
  * to advance without a fact behind it.
1078
1268
  */
1079
1269
  setSignIn(facts) {
1080
- this.signIn = { ...facts, progress: deriveSignInProgress(facts) };
1270
+ this.signIn = buildSignIn(facts, this.signInAttempt);
1081
1271
  this.emit();
1082
1272
  }
1083
1273
  /** Update a subset of the device-flow facts, re-deriving `progress`. */
1084
1274
  patchSignIn(patch) {
1085
- const { phase, authorizeCode, qrPayload, expiresAt, error, route, routeFailed, pushSentAt, openedAt, } = this.signIn;
1275
+ const { phase, authorizeCode, qrPayload, expiresAt, error, failure, route, routeFailed, pushSentAt, openedAt, } = this.signIn;
1086
1276
  this.setSignIn({
1087
1277
  phase,
1088
1278
  authorizeCode,
1089
1279
  qrPayload,
1090
1280
  expiresAt,
1091
1281
  error,
1282
+ failure,
1092
1283
  route,
1093
1284
  routeFailed,
1094
1285
  pushSentAt,