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