@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/identityMove.js +123 -0
- package/dist/cjs/crypto/keyManager.js +60 -21
- package/dist/cjs/crypto/webIdentityCarrier.js +262 -0
- package/dist/cjs/i18n/locales/ar-SA.json +16 -13
- package/dist/cjs/i18n/locales/ca-ES.json +16 -13
- package/dist/cjs/i18n/locales/de-DE.json +16 -13
- package/dist/cjs/i18n/locales/en-US.json +16 -17
- package/dist/cjs/i18n/locales/es-ES.json +16 -17
- package/dist/cjs/i18n/locales/fr-FR.json +16 -13
- package/dist/cjs/i18n/locales/it-IT.json +16 -13
- package/dist/cjs/i18n/locales/ja-JP.json +16 -13
- package/dist/cjs/i18n/locales/ko-KR.json +16 -13
- package/dist/cjs/i18n/locales/locales/ar-SA.json +16 -13
- package/dist/cjs/i18n/locales/locales/ca-ES.json +16 -13
- package/dist/cjs/i18n/locales/locales/de-DE.json +16 -13
- package/dist/cjs/i18n/locales/locales/en-US.json +16 -17
- package/dist/cjs/i18n/locales/locales/es-ES.json +16 -17
- package/dist/cjs/i18n/locales/locales/fr-FR.json +16 -13
- package/dist/cjs/i18n/locales/locales/it-IT.json +16 -13
- package/dist/cjs/i18n/locales/locales/ja-JP.json +16 -13
- package/dist/cjs/i18n/locales/locales/ko-KR.json +16 -13
- package/dist/cjs/i18n/locales/locales/pt-PT.json +16 -13
- package/dist/cjs/i18n/locales/locales/zh-CN.json +16 -13
- package/dist/cjs/i18n/locales/pt-PT.json +16 -13
- package/dist/cjs/i18n/locales/zh-CN.json +16 -13
- package/dist/cjs/index.js +38 -4
- package/dist/cjs/mixins/OxyServices.auth.js +14 -4
- package/dist/cjs/mixins/OxyServices.identity.js +14 -0
- package/dist/cjs/session/accountDialogController.js +333 -142
- package/dist/cjs/utils/authWebUrl.js +8 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/identityMove.js +110 -0
- package/dist/esm/crypto/keyManager.js +60 -21
- package/dist/esm/crypto/webIdentityCarrier.js +243 -0
- package/dist/esm/i18n/locales/ar-SA.json +16 -13
- package/dist/esm/i18n/locales/ca-ES.json +16 -13
- package/dist/esm/i18n/locales/de-DE.json +16 -13
- package/dist/esm/i18n/locales/en-US.json +16 -17
- package/dist/esm/i18n/locales/es-ES.json +16 -17
- package/dist/esm/i18n/locales/fr-FR.json +16 -13
- package/dist/esm/i18n/locales/it-IT.json +16 -13
- package/dist/esm/i18n/locales/ja-JP.json +16 -13
- package/dist/esm/i18n/locales/ko-KR.json +16 -13
- package/dist/esm/i18n/locales/locales/ar-SA.json +16 -13
- package/dist/esm/i18n/locales/locales/ca-ES.json +16 -13
- package/dist/esm/i18n/locales/locales/de-DE.json +16 -13
- package/dist/esm/i18n/locales/locales/en-US.json +16 -17
- package/dist/esm/i18n/locales/locales/es-ES.json +16 -17
- package/dist/esm/i18n/locales/locales/fr-FR.json +16 -13
- package/dist/esm/i18n/locales/locales/it-IT.json +16 -13
- package/dist/esm/i18n/locales/locales/ja-JP.json +16 -13
- package/dist/esm/i18n/locales/locales/ko-KR.json +16 -13
- package/dist/esm/i18n/locales/locales/pt-PT.json +16 -13
- package/dist/esm/i18n/locales/locales/zh-CN.json +16 -13
- package/dist/esm/i18n/locales/pt-PT.json +16 -13
- package/dist/esm/i18n/locales/zh-CN.json +16 -13
- package/dist/esm/index.js +5 -1
- package/dist/esm/mixins/OxyServices.auth.js +14 -4
- package/dist/esm/mixins/OxyServices.identity.js +14 -0
- package/dist/esm/session/accountDialogController.js +334 -143
- package/dist/esm/utils/authWebUrl.js +7 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/identityMove.d.ts +59 -0
- package/dist/types/crypto/keyManager.d.ts +35 -5
- package/dist/types/crypto/webIdentityCarrier.d.ts +123 -0
- package/dist/types/index.d.ts +5 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +12 -0
- package/dist/types/models/interfaces.d.ts +15 -0
- package/dist/types/session/accountDialogController.d.ts +169 -17
- package/dist/types/utils/authWebUrl.d.ts +7 -0
- package/package.json +2 -2
- package/src/crypto/__tests__/identityMove.test.ts +104 -0
- package/src/crypto/__tests__/keyManager.sharedSlotSync.test.ts +150 -0
- package/src/crypto/__tests__/webIdentityCarrier.test.ts +211 -0
- package/src/crypto/identityMove.ts +133 -0
- package/src/crypto/keyManager.ts +71 -21
- package/src/crypto/webIdentityCarrier.ts +326 -0
- package/src/i18n/locales/ar-SA.json +16 -13
- package/src/i18n/locales/ca-ES.json +16 -13
- package/src/i18n/locales/de-DE.json +16 -13
- package/src/i18n/locales/en-US.json +16 -17
- package/src/i18n/locales/es-ES.json +16 -17
- package/src/i18n/locales/fr-FR.json +16 -13
- package/src/i18n/locales/it-IT.json +16 -13
- package/src/i18n/locales/ja-JP.json +16 -13
- package/src/i18n/locales/ko-KR.json +16 -13
- package/src/i18n/locales/pt-PT.json +16 -13
- package/src/i18n/locales/zh-CN.json +16 -13
- package/src/index.ts +38 -1
- package/src/mixins/OxyServices.auth.ts +16 -3
- package/src/mixins/OxyServices.identity.ts +14 -0
- package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +47 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +3 -0
- package/src/mixins/__tests__/verifyChallenge.test.ts +31 -0
- package/src/models/interfaces.ts +15 -0
- package/src/session/__tests__/accountDialogController.test.ts +407 -10
- package/src/session/accountDialogController.ts +406 -145
- package/src/utils/__tests__/oauthPkce.test.ts +50 -26
- package/src/utils/authWebUrl.ts +8 -0
|
@@ -62,7 +62,7 @@ import type { SessionLoginResponse, MinimalUserData } from '../models/session';
|
|
|
62
62
|
import type { User } from '../models/interfaces';
|
|
63
63
|
import { logger } from '../logger';
|
|
64
64
|
import { extractErrorStatus } from '../utils/errorUtils';
|
|
65
|
-
import {
|
|
65
|
+
import { IDENTITY_WEB_ORIGIN } from '../utils/authWebUrl';
|
|
66
66
|
import type { SessionClient } from './SessionClient';
|
|
67
67
|
import { getSocketIO, type MinimalSocket, type SocketIOFactory } from './socketLoader';
|
|
68
68
|
import { resolveActiveContext, type DeviceContext } from './deviceDirectory';
|
|
@@ -139,6 +139,30 @@ export type SignInProgress =
|
|
|
139
139
|
| 'confirming-identity'
|
|
140
140
|
| 'identity-confirmed';
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* WHY a sign-in attempt ended in `'error'` — a machine-readable reason the UI
|
|
144
|
+
* turns into localized copy (the controller ships no user-facing prose).
|
|
145
|
+
*
|
|
146
|
+
* - `'cancelled'` — the USER walked away (closed the passkey hub popup). A
|
|
147
|
+
* voluntary outcome, not a failure to report.
|
|
148
|
+
* - `'denied'` — the approver declined the request in Commons.
|
|
149
|
+
* - `'expired'` — the request outlived its server-authoritative expiry.
|
|
150
|
+
* - `'network'` — Oxy could not be reached (no response, timeout).
|
|
151
|
+
* - `'not-configured'` — the app has no registered `clientId`.
|
|
152
|
+
* - `'unsupported-flow'` — an OAuth-bound request, which this surface cannot finalize.
|
|
153
|
+
* - `'claim-failed'` — the request WAS approved, but the session could not be claimed.
|
|
154
|
+
* - `'unknown'` — anything else; the raw message stays on `error` for logs.
|
|
155
|
+
*/
|
|
156
|
+
export type SignInFailureReason =
|
|
157
|
+
| 'cancelled'
|
|
158
|
+
| 'denied'
|
|
159
|
+
| 'expired'
|
|
160
|
+
| 'network'
|
|
161
|
+
| 'not-configured'
|
|
162
|
+
| 'unsupported-flow'
|
|
163
|
+
| 'claim-failed'
|
|
164
|
+
| 'unknown';
|
|
165
|
+
|
|
142
166
|
/**
|
|
143
167
|
* Minimal structural handle over a popup `Window` — just enough for the
|
|
144
168
|
* cross-origin passkey hub flow ({@link AccountDialogController.startPasskeyHubSignIn}):
|
|
@@ -175,8 +199,13 @@ export interface SignInFlowState {
|
|
|
175
199
|
qrPayload: string | null;
|
|
176
200
|
/** Server-authoritative expiry (epoch ms), or `null`. */
|
|
177
201
|
expiresAt: number | null;
|
|
178
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* The raw, UNLOCALIZED failure message (diagnostics only), or `null`. A surface
|
|
204
|
+
* renders {@link failure} instead — this string is English or a server message.
|
|
205
|
+
*/
|
|
179
206
|
error: string | null;
|
|
207
|
+
/** Why the attempt failed, set together with `phase: 'error'`; `null` otherwise. */
|
|
208
|
+
failure: SignInFailureReason | null;
|
|
180
209
|
/**
|
|
181
210
|
* The ONE primary delivery route the controller chose for this request
|
|
182
211
|
* ({@link selectCommonsDelivery}), or `null` while it is still being resolved.
|
|
@@ -217,14 +246,25 @@ export interface SignInFlowState {
|
|
|
217
246
|
* cannot drift from them or run ahead of a real signal.
|
|
218
247
|
*/
|
|
219
248
|
progress: SignInProgress;
|
|
249
|
+
/**
|
|
250
|
+
* Identity of the sign-in ATTEMPT this state belongs to — a counter that moves
|
|
251
|
+
* whenever an attempt is started, cancelled, or the controller is destroyed.
|
|
252
|
+
* Not a secret. It lets a surface treat "this attempt failed" as one event (a
|
|
253
|
+
* re-notification of the same failure is not a second failure, while a new
|
|
254
|
+
* attempt failing with the same message is).
|
|
255
|
+
*/
|
|
256
|
+
attempt: number;
|
|
220
257
|
}
|
|
221
258
|
|
|
222
259
|
/**
|
|
223
260
|
* The observable FACTS of a device flow — {@link SignInFlowState} minus the
|
|
224
|
-
*
|
|
225
|
-
* which is what makes `progress`
|
|
261
|
+
* values the controller derives. Every mutation of the flow goes through this
|
|
262
|
+
* shape, which is what makes `progress` (and `attempt`) impossible to set by hand.
|
|
226
263
|
*/
|
|
227
|
-
type SignInFlowFacts = Omit<SignInFlowState, 'progress'>;
|
|
264
|
+
type SignInFlowFacts = Omit<SignInFlowState, 'progress' | 'attempt'>;
|
|
265
|
+
|
|
266
|
+
/** How the current sign-in attempt was started — what "Try again" repeats. */
|
|
267
|
+
type SignInMethod = 'oxy' | 'qr' | 'passkey-hub';
|
|
228
268
|
|
|
229
269
|
/**
|
|
230
270
|
* Derive the surface-facing progress from the flow's real facts. Pure, total,
|
|
@@ -356,8 +396,16 @@ export interface AccountDialogControllerOptions {
|
|
|
356
396
|
*/
|
|
357
397
|
openPopup?: () => PopupWindowHandle | null;
|
|
358
398
|
/**
|
|
359
|
-
*
|
|
360
|
-
* `https://
|
|
399
|
+
* Origin of the web identity carrier (default `IDENTITY_WEB_ORIGIN`,
|
|
400
|
+
* `https://id.oxy.so`) — where a passkey sign-in or sign-up runs
|
|
401
|
+
* and the account's identity is kept sealed under the passkey. The popup opens
|
|
402
|
+
* `<identityOrigin>/continue?code=…`. Overridable for local/staging testing.
|
|
403
|
+
*/
|
|
404
|
+
identityOrigin?: string;
|
|
405
|
+
/**
|
|
406
|
+
* @deprecated Alias of {@link identityOrigin}, kept for existing
|
|
407
|
+
* configuration. The popup used to open `auth.oxy.so/hub-passkey`; it now opens
|
|
408
|
+
* the identity origin's `/continue`, so a value here must be that origin.
|
|
361
409
|
*/
|
|
362
410
|
hubBaseUrl?: string;
|
|
363
411
|
/**
|
|
@@ -398,32 +446,41 @@ const IDLE_SIGN_IN_FACTS: SignInFlowFacts = {
|
|
|
398
446
|
qrPayload: null,
|
|
399
447
|
expiresAt: null,
|
|
400
448
|
error: null,
|
|
449
|
+
failure: null,
|
|
401
450
|
route: null,
|
|
402
451
|
routeFailed: false,
|
|
403
452
|
pushSentAt: null,
|
|
404
453
|
openedAt: null,
|
|
405
454
|
};
|
|
406
455
|
|
|
407
|
-
const IDLE_SIGN_IN: SignInFlowState = {
|
|
408
|
-
...IDLE_SIGN_IN_FACTS,
|
|
409
|
-
progress: deriveSignInProgress(IDLE_SIGN_IN_FACTS),
|
|
410
|
-
};
|
|
411
|
-
|
|
412
456
|
/**
|
|
413
|
-
* Terminal SUCCESS
|
|
457
|
+
* Terminal SUCCESS facts: the session was claimed and committed. Holds no live
|
|
414
458
|
* resources and no request handles — only the terminal progress the surface
|
|
415
459
|
* shows ("Identity confirmed") before it closes.
|
|
416
460
|
*/
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
};
|
|
461
|
+
const COMPLETED_SIGN_IN_FACTS: SignInFlowFacts = { ...IDLE_SIGN_IN_FACTS, phase: 'completed' };
|
|
462
|
+
|
|
463
|
+
/** The full flow state for `facts`, stamped with the attempt it belongs to. */
|
|
464
|
+
function buildSignIn(facts: SignInFlowFacts, attempt: number): SignInFlowState {
|
|
465
|
+
return { ...facts, progress: deriveSignInProgress(facts), attempt };
|
|
466
|
+
}
|
|
422
467
|
|
|
423
468
|
function errorMessage(error: unknown): string {
|
|
424
469
|
return error instanceof Error ? error.message : String(error);
|
|
425
470
|
}
|
|
426
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Classify a request that never produced a response. `OxyServices.handleError`
|
|
474
|
+
* normalizes transport failures to `NETWORK_ERROR` / `TIMEOUT` with status `0`.
|
|
475
|
+
*/
|
|
476
|
+
function requestFailureReason(error: unknown): SignInFailureReason {
|
|
477
|
+
const code = error && typeof error === 'object' ? (error as { code?: unknown }).code : undefined;
|
|
478
|
+
if (code === 'NETWORK_ERROR' || code === 'TIMEOUT' || extractErrorStatus(error) === 0) {
|
|
479
|
+
return 'network';
|
|
480
|
+
}
|
|
481
|
+
return 'unknown';
|
|
482
|
+
}
|
|
483
|
+
|
|
427
484
|
type SnapshotListener = (snapshot: AccountDialogSnapshot) => void;
|
|
428
485
|
|
|
429
486
|
export class AccountDialogController {
|
|
@@ -437,7 +494,7 @@ export class AccountDialogController {
|
|
|
437
494
|
private readonly canOpenApp?: (url: string) => Promise<boolean>;
|
|
438
495
|
private readonly socketFactory?: SocketIOFactory;
|
|
439
496
|
private readonly openPopup?: () => PopupWindowHandle | null;
|
|
440
|
-
private readonly
|
|
497
|
+
private readonly identityOrigin: string;
|
|
441
498
|
private readonly platform: CommonsDeliveryPlatform;
|
|
442
499
|
|
|
443
500
|
private readonly listeners = new Set<SnapshotListener>();
|
|
@@ -449,10 +506,26 @@ export class AccountDialogController {
|
|
|
449
506
|
private activatingContextId: string | null = null;
|
|
450
507
|
private removingContextId: string | null = null;
|
|
451
508
|
private removingPrincipalId: string | null = null;
|
|
452
|
-
|
|
509
|
+
/**
|
|
510
|
+
* `true` while an operation run through {@link runDeviceMutation} (a host
|
|
511
|
+
* sign-out) is in flight. The fourth member of the ONE device-mutation gate,
|
|
512
|
+
* alongside the three flags above.
|
|
513
|
+
*/
|
|
514
|
+
private exclusiveMutationInFlight = false;
|
|
515
|
+
private signIn: SignInFlowState = buildSignIn(IDLE_SIGN_IN_FACTS, 0);
|
|
453
516
|
private commonsAvailability: CommonsAvailability = 'unknown';
|
|
454
517
|
|
|
455
518
|
// --- Sign-in device-flow bookkeeping ---
|
|
519
|
+
/**
|
|
520
|
+
* The CURRENT sign-in attempt. Every asynchronous step of a sign-in captures
|
|
521
|
+
* it before awaiting and re-checks it after (`isCurrentAttempt`); cancelling,
|
|
522
|
+
* starting another attempt, or destroying the controller moves it, so a late
|
|
523
|
+
* response from an abandoned attempt can neither update the surface nor
|
|
524
|
+
* install a session.
|
|
525
|
+
*/
|
|
526
|
+
private signInAttempt = 0;
|
|
527
|
+
/** How the current attempt was started, so a retry repeats the user's choice. */
|
|
528
|
+
private signInMethod: SignInMethod = 'oxy';
|
|
456
529
|
/** The secret device-flow token of the active QR flow (never surfaced). */
|
|
457
530
|
private signInToken: string | null = null;
|
|
458
531
|
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -465,9 +538,10 @@ export class AccountDialogController {
|
|
|
465
538
|
/**
|
|
466
539
|
* Guards {@link pollOnce} against re-entrancy: the fallback timer and a socket
|
|
467
540
|
* `auth_update` wake can fire together — without this both could claim the
|
|
468
|
-
* single-use token concurrently.
|
|
541
|
+
* single-use token concurrently. Keyed by token, so a poll still running for an
|
|
542
|
+
* ABANDONED request never blocks the next request's first poll.
|
|
469
543
|
*/
|
|
470
|
-
private
|
|
544
|
+
private pollInFlightToken: string | null = null;
|
|
471
545
|
/** The popup opened by {@link startPasskeyHubSignIn}, or `null`. */
|
|
472
546
|
private activePopup: PopupWindowHandle | null = null;
|
|
473
547
|
/** Watches {@link activePopup}'s `closed` state; see {@link watchPopup}. */
|
|
@@ -493,7 +567,7 @@ export class AccountDialogController {
|
|
|
493
567
|
this.canOpenApp = options.canOpenApp;
|
|
494
568
|
this.socketFactory = options.socketFactory;
|
|
495
569
|
this.openPopup = options.openPopup;
|
|
496
|
-
this.
|
|
570
|
+
this.identityOrigin = options.identityOrigin ?? options.hubBaseUrl ?? IDENTITY_WEB_ORIGIN;
|
|
497
571
|
this.platform = options.platform ?? 'unknown';
|
|
498
572
|
this.snapshot = this.computeSnapshot();
|
|
499
573
|
}
|
|
@@ -564,6 +638,8 @@ export class AccountDialogController {
|
|
|
564
638
|
*/
|
|
565
639
|
destroy(): void {
|
|
566
640
|
this.started = false;
|
|
641
|
+
// Nothing still in flight may act on a destroyed controller.
|
|
642
|
+
this.signInAttempt += 1;
|
|
567
643
|
if (this.unsubscribeSession) {
|
|
568
644
|
this.unsubscribeSession();
|
|
569
645
|
this.unsubscribeSession = null;
|
|
@@ -638,7 +714,7 @@ export class AccountDialogController {
|
|
|
638
714
|
// another view is a NEW intention, so drop it; otherwise a later `add()`
|
|
639
715
|
// would open on the previous sign-in's terminal state.
|
|
640
716
|
if (this.signIn.phase === 'completed') {
|
|
641
|
-
this.signIn =
|
|
717
|
+
this.signIn = buildSignIn(IDLE_SIGN_IN_FACTS, this.signInAttempt);
|
|
642
718
|
}
|
|
643
719
|
this.emit();
|
|
644
720
|
}
|
|
@@ -667,8 +743,11 @@ export class AccountDialogController {
|
|
|
667
743
|
* — and the reconstruction was not merely redundant: it enumerated the
|
|
668
744
|
* CALLER's account graph, which on a device holding two people is one
|
|
669
745
|
* person's answer presented as the device's.
|
|
746
|
+
*
|
|
747
|
+
* Resolves `false` when THIS read failed (a 401 is the signed-out edge, not a
|
|
748
|
+
* failure). Never rejects.
|
|
670
749
|
*/
|
|
671
|
-
async refresh(): Promise<
|
|
750
|
+
async refresh(): Promise<boolean> {
|
|
672
751
|
const seq = ++this.refreshSeq;
|
|
673
752
|
|
|
674
753
|
// Never read the directory while signed out: at cold boot the bearer is not
|
|
@@ -678,7 +757,7 @@ export class AccountDialogController {
|
|
|
678
757
|
this.loading = false;
|
|
679
758
|
this.error = null;
|
|
680
759
|
this.emit();
|
|
681
|
-
return;
|
|
760
|
+
return true;
|
|
682
761
|
}
|
|
683
762
|
|
|
684
763
|
// Nothing to show yet is the only state worth a spinner; a re-read behind an
|
|
@@ -687,6 +766,7 @@ export class AccountDialogController {
|
|
|
687
766
|
this.error = null;
|
|
688
767
|
this.emit();
|
|
689
768
|
|
|
769
|
+
let failure: string | null = null;
|
|
690
770
|
try {
|
|
691
771
|
await this.sessionClient.refreshDirectory();
|
|
692
772
|
} catch (error) {
|
|
@@ -698,14 +778,18 @@ export class AccountDialogController {
|
|
|
698
778
|
if (extractErrorStatus(error) === 401) {
|
|
699
779
|
logger.debug('[AccountDialogController] directory unauthorized (signed out)', { component: 'AccountDialogController' }, error);
|
|
700
780
|
} else {
|
|
701
|
-
|
|
781
|
+
failure = errorMessage(error);
|
|
702
782
|
logger.warn('[AccountDialogController] directory refresh failed', { component: 'AccountDialogController' }, error);
|
|
703
783
|
}
|
|
704
784
|
}
|
|
705
|
-
|
|
785
|
+
// Superseded by a newer refresh: that read owns `error` and `loading`, so an
|
|
786
|
+
// older failure must not overwrite its outcome.
|
|
787
|
+
if (seq !== this.refreshSeq) return failure === null;
|
|
706
788
|
|
|
789
|
+
this.error = failure;
|
|
707
790
|
this.loading = false;
|
|
708
791
|
this.emit();
|
|
792
|
+
return failure === null;
|
|
709
793
|
}
|
|
710
794
|
|
|
711
795
|
// =========================================================================
|
|
@@ -725,23 +809,22 @@ export class AccountDialogController {
|
|
|
725
809
|
* A context id is not stable across a removal, so a stale one is an ordinary
|
|
726
810
|
* outcome rather than a bug: the server answers 404 or 403, heals the row, and
|
|
727
811
|
* the refresh below re-reads a directory that no longer offers it.
|
|
812
|
+
*
|
|
813
|
+
* Resolves `true` once the SWITCH happened — even if the directory re-read
|
|
814
|
+
* after it fails (that failure lands on `error`, but the subject did change).
|
|
815
|
+
* Resolves `false` when the switch failed, or was refused because another
|
|
816
|
+
* device mutation is in flight ({@link isDeviceMutationInFlight}).
|
|
728
817
|
*/
|
|
729
818
|
async activateContext(contextId: string): Promise<boolean> {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
this.error = errorMessage(error);
|
|
740
|
-
return false;
|
|
741
|
-
} finally {
|
|
742
|
-
this.activatingContextId = null;
|
|
743
|
-
this.emit();
|
|
744
|
-
}
|
|
819
|
+
return this.mutateDevice(
|
|
820
|
+
() => {
|
|
821
|
+
this.activatingContextId = contextId;
|
|
822
|
+
},
|
|
823
|
+
() => {
|
|
824
|
+
this.activatingContextId = null;
|
|
825
|
+
},
|
|
826
|
+
() => this.sessionClient.activateContext(contextId),
|
|
827
|
+
);
|
|
745
828
|
}
|
|
746
829
|
|
|
747
830
|
/**
|
|
@@ -757,21 +840,15 @@ export class AccountDialogController {
|
|
|
757
840
|
* revision — so nothing may hold a context id across this call.
|
|
758
841
|
*/
|
|
759
842
|
async signOutContext(contextId: string): Promise<boolean> {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
this.error = errorMessage(error);
|
|
770
|
-
return false;
|
|
771
|
-
} finally {
|
|
772
|
-
this.removingContextId = null;
|
|
773
|
-
this.emit();
|
|
774
|
-
}
|
|
843
|
+
return this.mutateDevice(
|
|
844
|
+
() => {
|
|
845
|
+
this.removingContextId = contextId;
|
|
846
|
+
},
|
|
847
|
+
() => {
|
|
848
|
+
this.removingContextId = null;
|
|
849
|
+
},
|
|
850
|
+
() => this.sessionClient.signOutContext(contextId),
|
|
851
|
+
);
|
|
775
852
|
}
|
|
776
853
|
|
|
777
854
|
/**
|
|
@@ -783,19 +860,85 @@ export class AccountDialogController {
|
|
|
783
860
|
* and elects a replacement active context in one transition.
|
|
784
861
|
*/
|
|
785
862
|
async signOutPrincipal(principalId: string): Promise<boolean> {
|
|
786
|
-
|
|
787
|
-
|
|
863
|
+
return this.mutateDevice(
|
|
864
|
+
() => {
|
|
865
|
+
this.removingPrincipalId = principalId;
|
|
866
|
+
},
|
|
867
|
+
() => {
|
|
868
|
+
this.removingPrincipalId = null;
|
|
869
|
+
},
|
|
870
|
+
() => this.sessionClient.signOutPrincipal(principalId),
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Whether ANY operation that changes who is on this device, or which of them
|
|
876
|
+
* is active, is in flight: an activation, either removal, or a host operation
|
|
877
|
+
* run through {@link runDeviceMutation}.
|
|
878
|
+
*
|
|
879
|
+
* They share ONE gate because they mutate the same device set: a removal that
|
|
880
|
+
* elects a replacement active context racing a switch could leave either one
|
|
881
|
+
* active. A refused call is refused, never queued — a press that could not run
|
|
882
|
+
* must not fire later, after the user has moved on.
|
|
883
|
+
*/
|
|
884
|
+
isDeviceMutationInFlight(): boolean {
|
|
885
|
+
return (
|
|
886
|
+
this.activatingContextId !== null ||
|
|
887
|
+
this.removingContextId !== null ||
|
|
888
|
+
this.removingPrincipalId !== null ||
|
|
889
|
+
this.exclusiveMutationInFlight
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Run a device mutation the controller does not own (the host's sign-out)
|
|
895
|
+
* under the same gate as {@link activateContext} and the removals.
|
|
896
|
+
*
|
|
897
|
+
* Resolves `{ ran: false }` without calling `operation` when another mutation
|
|
898
|
+
* is in flight; otherwise `{ ran: true, value }`. A rejection from `operation`
|
|
899
|
+
* propagates, after the gate is released.
|
|
900
|
+
*/
|
|
901
|
+
async runDeviceMutation<T>(
|
|
902
|
+
operation: () => Promise<T>,
|
|
903
|
+
): Promise<{ ran: true; value: T } | { ran: false }> {
|
|
904
|
+
if (this.isDeviceMutationInFlight()) return { ran: false };
|
|
905
|
+
this.exclusiveMutationInFlight = true;
|
|
906
|
+
try {
|
|
907
|
+
return { ran: true, value: await operation() };
|
|
908
|
+
} finally {
|
|
909
|
+
this.exclusiveMutationInFlight = false;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* The shared body of the three controller-owned device mutations: take the
|
|
915
|
+
* gate, run the mutation, then re-read the directory.
|
|
916
|
+
*
|
|
917
|
+
* The mutation's outcome and the re-read's are kept apart. Once `operation`
|
|
918
|
+
* has resolved the device HAS changed, so this resolves `true` even when the
|
|
919
|
+
* re-read fails — that failure is the directory's (`error`), and reporting it
|
|
920
|
+
* as a failed switch would invite the user to repeat something that happened.
|
|
921
|
+
*/
|
|
922
|
+
private async mutateDevice(
|
|
923
|
+
mark: () => void,
|
|
924
|
+
clear: () => void,
|
|
925
|
+
operation: () => Promise<void>,
|
|
926
|
+
): Promise<boolean> {
|
|
927
|
+
if (this.isDeviceMutationInFlight()) return false;
|
|
928
|
+
mark();
|
|
788
929
|
this.error = null;
|
|
789
930
|
this.emit();
|
|
790
931
|
try {
|
|
791
|
-
|
|
932
|
+
try {
|
|
933
|
+
await operation();
|
|
934
|
+
} catch (error) {
|
|
935
|
+
this.error = errorMessage(error);
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
792
938
|
await this.refresh();
|
|
793
939
|
return true;
|
|
794
|
-
} catch (error) {
|
|
795
|
-
this.error = errorMessage(error);
|
|
796
|
-
return false;
|
|
797
940
|
} finally {
|
|
798
|
-
|
|
941
|
+
clear();
|
|
799
942
|
this.emit();
|
|
800
943
|
}
|
|
801
944
|
}
|
|
@@ -808,23 +951,28 @@ export class AccountDialogController {
|
|
|
808
951
|
* Start "Sign in with Oxy". Native devices with a shared identity mint a
|
|
809
952
|
* session silently (`signInWithSharedIdentity`); everything else (web, or a
|
|
810
953
|
* native device without a shared identity) falls through to the cross-device
|
|
811
|
-
* QR handoff.
|
|
954
|
+
* QR handoff — as the SAME attempt, so cancelling during either half stops both.
|
|
812
955
|
*/
|
|
813
956
|
async signInWithOxy(): Promise<void> {
|
|
957
|
+
const attempt = this.beginSignInAttempt('oxy');
|
|
814
958
|
this.setView('qr');
|
|
815
959
|
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
|
|
816
960
|
try {
|
|
817
|
-
|
|
961
|
+
// Minted WITHOUT planting the bearer: `completeSignIn` installs it only if
|
|
962
|
+
// this attempt is still the current one when the mint returns.
|
|
963
|
+
const session = await this.oxyServices.signInWithSharedIdentity({ plantTokens: false });
|
|
964
|
+
if (!this.isCurrentAttempt(attempt)) return;
|
|
818
965
|
if (session) {
|
|
819
|
-
await this.completeSignIn(session, session.user);
|
|
966
|
+
await this.completeSignIn(attempt, session, session.user);
|
|
820
967
|
return;
|
|
821
968
|
}
|
|
822
969
|
} catch (error) {
|
|
970
|
+
if (!this.isCurrentAttempt(attempt)) return;
|
|
823
971
|
// Shared-key mint failed — log and fall through to the QR handoff rather
|
|
824
972
|
// than dead-ending the sign-in.
|
|
825
973
|
logger.warn('[AccountDialogController] signInWithSharedIdentity failed', { component: 'AccountDialogController' }, error);
|
|
826
974
|
}
|
|
827
|
-
await this.
|
|
975
|
+
await this.startDeviceFlowSession(attempt, { deliver: true });
|
|
828
976
|
}
|
|
829
977
|
|
|
830
978
|
/**
|
|
@@ -834,17 +982,40 @@ export class AccountDialogController {
|
|
|
834
982
|
* session committed. Requires `clientId`.
|
|
835
983
|
*/
|
|
836
984
|
async showQr(): Promise<void> {
|
|
837
|
-
this.
|
|
985
|
+
const attempt = this.beginSignInAttempt('qr');
|
|
838
986
|
this.setView('qr');
|
|
839
|
-
await this.startDeviceFlowSession({ deliver: true });
|
|
987
|
+
await this.startDeviceFlowSession(attempt, { deliver: true });
|
|
840
988
|
}
|
|
841
989
|
|
|
842
990
|
/**
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
*
|
|
991
|
+
* Start a NEW attempt the same way the last one was started — "Try again"
|
|
992
|
+
* repeats the user's choice rather than silently switching method. A failed
|
|
993
|
+
* passkey-hub attempt reopens the hub popup; a failed "Sign in with Oxy"
|
|
994
|
+
* retries that; an explicit QR request shows the QR.
|
|
995
|
+
*
|
|
996
|
+
* Call it straight from the press handler: the hub route opens its popup
|
|
997
|
+
* synchronously, before this method's first `await`, so the browser still
|
|
998
|
+
* attributes it to the click.
|
|
999
|
+
*/
|
|
1000
|
+
retrySignIn(): Promise<void> {
|
|
1001
|
+
switch (this.signInMethod) {
|
|
1002
|
+
case 'passkey-hub':
|
|
1003
|
+
return this.startPasskeyHubSignIn();
|
|
1004
|
+
case 'qr':
|
|
1005
|
+
return this.showQr();
|
|
1006
|
+
default:
|
|
1007
|
+
return this.signInWithOxy();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Web-only: sign in (or create an account) with a passkey at the web identity
|
|
1013
|
+
* carrier. The ceremony runs on `id.oxy.so`, never on the calling origin, for
|
|
1014
|
+
* two reasons: a credential minted with `WEBAUTHN_RP_ID=oxy.so` can only be
|
|
1015
|
+
* asserted from `oxy.so`/a subdomain/loopback (a browser-enforced boundary),
|
|
1016
|
+
* and only the identity origin may unseal or create the account's identity
|
|
1017
|
+
* (one identity, two carriers). Opens a popup at `<identityOrigin>/continue`,
|
|
1018
|
+
* scoped to the SAME device-flow
|
|
848
1019
|
* session {@link showQr} would create (same `authorizeCode`/`sessionToken`
|
|
849
1020
|
* pair), and let the SAME poll/socket/claim engine complete it once the hub
|
|
850
1021
|
* authorizes the session (`POST /auth/session/authorize-code/:authorizeCode`,
|
|
@@ -859,83 +1030,120 @@ export class AccountDialogController {
|
|
|
859
1030
|
* `showQr`'s plain QR rendering — no dead end.
|
|
860
1031
|
*/
|
|
861
1032
|
async startPasskeyHubSignIn(): Promise<void> {
|
|
862
|
-
this.
|
|
1033
|
+
const attempt = this.beginSignInAttempt('passkey-hub');
|
|
863
1034
|
this.setView('qr');
|
|
864
1035
|
const popup = this.openPopup?.() ?? null;
|
|
865
1036
|
if (!popup) {
|
|
866
|
-
await this.startDeviceFlowSession({ deliver: true });
|
|
1037
|
+
await this.startDeviceFlowSession(attempt, { deliver: true });
|
|
867
1038
|
return;
|
|
868
1039
|
}
|
|
1040
|
+
// Owned by the attempt from the moment it exists, so cancelling while the
|
|
1041
|
+
// session is still being created closes it instead of leaving a blank
|
|
1042
|
+
// window behind.
|
|
1043
|
+
this.activePopup = popup;
|
|
869
1044
|
// The hub popup IS the primary surface here, chosen explicitly by the user —
|
|
870
1045
|
// so this flow does NOT run automatic Commons delivery (ringing the user's
|
|
871
1046
|
// phone because they asked for a passkey would be exactly the "menu of
|
|
872
1047
|
// methods" the one-primary-action rule forbids). The underlying request is
|
|
873
1048
|
// the same `AuthSession`, and its Commons route stays the QR the view
|
|
874
1049
|
// renders beneath the popup.
|
|
875
|
-
const handle = await this.startDeviceFlowSession({ deliver: false });
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
}
|
|
1050
|
+
const handle = await this.startDeviceFlowSession(attempt, { deliver: false });
|
|
1051
|
+
// `null` means the attempt failed (its teardown closed the popup) or was
|
|
1052
|
+
// abandoned (the cancel closed it).
|
|
1053
|
+
if (!handle) return;
|
|
880
1054
|
// The user may have closed the popup during the async session creation
|
|
881
1055
|
// above, before any watcher was attached to catch it — guard rather than
|
|
882
1056
|
// navigate a dead window (browsers vary on whether that throws).
|
|
883
1057
|
if (popup.closed) {
|
|
884
|
-
|
|
1058
|
+
const pendingCode = this.signIn.authorizeCode;
|
|
1059
|
+
this.failSignIn('cancelled', 'Sign-in was cancelled.');
|
|
1060
|
+
if (pendingCode) void this.withdrawRequest(pendingCode);
|
|
885
1061
|
return;
|
|
886
1062
|
}
|
|
887
|
-
|
|
888
|
-
popup.location.href = `${this.hubBaseUrl}/hub-passkey?code=${encodeURIComponent(handle.authorizeCode)}`;
|
|
1063
|
+
popup.location.href = `${this.identityOrigin}/continue?code=${encodeURIComponent(handle.authorizeCode)}`;
|
|
889
1064
|
this.watchPopup(popup);
|
|
890
1065
|
}
|
|
891
1066
|
|
|
1067
|
+
/**
|
|
1068
|
+
* End whatever attempt is running (withdrawing its request) and begin a new
|
|
1069
|
+
* one. Returns the new attempt's identity for the caller to re-check after
|
|
1070
|
+
* every `await`.
|
|
1071
|
+
*/
|
|
1072
|
+
private beginSignInAttempt(method: SignInMethod): number {
|
|
1073
|
+
this.cancelSignIn();
|
|
1074
|
+
this.signInMethod = method;
|
|
1075
|
+
return this.signInAttempt;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** Whether `attempt` is still the one the surface is waiting on. */
|
|
1079
|
+
private isCurrentAttempt(attempt: number): boolean {
|
|
1080
|
+
return attempt === this.signInAttempt;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
892
1083
|
/**
|
|
893
1084
|
* Shared device-flow session creation for both {@link showQr} (renders the
|
|
894
1085
|
* QR) and {@link startPasskeyHubSignIn} (also opens the hub popup) — the
|
|
895
1086
|
* same `startCommonsSignIn` → poll/socket wiring either way. Returns the
|
|
896
|
-
* handle on success (already reflected in `signIn`), or `null`
|
|
897
|
-
* (already set as `signIn.
|
|
1087
|
+
* handle on success (already reflected in `signIn`), or `null` when the
|
|
1088
|
+
* attempt failed (already set as `signIn.failure`) or was abandoned while the
|
|
1089
|
+
* request was being created.
|
|
898
1090
|
*
|
|
1091
|
+
* @param attempt - The attempt this request belongs to. A request that comes
|
|
1092
|
+
* back for an abandoned attempt is withdrawn server-side and never wired up:
|
|
1093
|
+
* closing the dialog on a slow connection must not leave an approvable
|
|
1094
|
+
* request, a socket, and a poll behind it.
|
|
899
1095
|
* @param opts.deliver - Whether to run automatic Commons delivery selection
|
|
900
1096
|
* ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
|
|
901
1097
|
* entry; `false` when the caller already owns the primary surface (the
|
|
902
1098
|
* passkey hub popup), where the request's Commons route is simply the QR.
|
|
903
1099
|
*/
|
|
904
|
-
private async startDeviceFlowSession(
|
|
1100
|
+
private async startDeviceFlowSession(
|
|
1101
|
+
attempt: number,
|
|
1102
|
+
opts: { deliver: boolean },
|
|
1103
|
+
): Promise<CommonsSignInHandle | null> {
|
|
1104
|
+
if (!this.isCurrentAttempt(attempt)) return null;
|
|
905
1105
|
if (!this.clientId) {
|
|
906
|
-
this.
|
|
1106
|
+
this.failSignIn('not-configured', 'This app is not configured for sign-in (missing clientId).');
|
|
907
1107
|
return null;
|
|
908
1108
|
}
|
|
909
1109
|
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
|
|
1110
|
+
let handle: CommonsSignInHandle;
|
|
910
1111
|
try {
|
|
911
|
-
|
|
912
|
-
this.signInToken = handle.sessionToken;
|
|
913
|
-
this.setSignIn({
|
|
914
|
-
...IDLE_SIGN_IN_FACTS,
|
|
915
|
-
phase: 'waiting',
|
|
916
|
-
authorizeCode: handle.authorizeCode,
|
|
917
|
-
qrPayload: handle.qrPayload,
|
|
918
|
-
expiresAt: handle.expiresAt,
|
|
919
|
-
// No route yet: the surface shows "Preparing request" until the primary
|
|
920
|
-
// route is resolved below. It is never guessed in the meantime.
|
|
921
|
-
route: opts.deliver ? null : 'qr',
|
|
922
|
-
});
|
|
923
|
-
// Primary path: an instant `auth_update` wake over the `/auth-session`
|
|
924
|
-
// socket. The poll below is only the fallback for when the socket can't
|
|
925
|
-
// connect, so it now runs at the slow fallback cadence.
|
|
926
|
-
this.openAuthSessionSocket(handle.sessionToken);
|
|
927
|
-
this.scheduleNextPoll(handle.sessionToken);
|
|
928
|
-
if (opts.deliver) {
|
|
929
|
-
// Non-blocking on purpose: the QR/authorizeCode are already renderable
|
|
930
|
-
// and the popup caller can navigate immediately, while the route (a
|
|
931
|
-
// local probe plus at most one delivery round-trip) resolves behind it.
|
|
932
|
-
void this.resolveDeliveryRoute(handle);
|
|
933
|
-
}
|
|
934
|
-
return handle;
|
|
1112
|
+
handle = await this.oxyServices.startCommonsSignIn({ clientId: this.clientId });
|
|
935
1113
|
} catch (error) {
|
|
936
|
-
this.
|
|
1114
|
+
if (!this.isCurrentAttempt(attempt)) return null;
|
|
1115
|
+
this.failSignIn(requestFailureReason(error), errorMessage(error));
|
|
1116
|
+
return null;
|
|
1117
|
+
}
|
|
1118
|
+
if (!this.isCurrentAttempt(attempt)) {
|
|
1119
|
+
// Created after the user walked away. The cancel could not withdraw it (it
|
|
1120
|
+
// had no code yet), so withdraw it now — best effort, like every withdrawal.
|
|
1121
|
+
void this.withdrawRequest(handle.authorizeCode);
|
|
937
1122
|
return null;
|
|
938
1123
|
}
|
|
1124
|
+
this.signInToken = handle.sessionToken;
|
|
1125
|
+
this.setSignIn({
|
|
1126
|
+
...IDLE_SIGN_IN_FACTS,
|
|
1127
|
+
phase: 'waiting',
|
|
1128
|
+
authorizeCode: handle.authorizeCode,
|
|
1129
|
+
qrPayload: handle.qrPayload,
|
|
1130
|
+
expiresAt: handle.expiresAt,
|
|
1131
|
+
// No route yet: the surface shows "Preparing request" until the primary
|
|
1132
|
+
// route is resolved below. It is never guessed in the meantime.
|
|
1133
|
+
route: opts.deliver ? null : 'qr',
|
|
1134
|
+
});
|
|
1135
|
+
// Primary path: an instant `auth_update` wake over the `/auth-session`
|
|
1136
|
+
// socket. The poll below is only the fallback for when the socket can't
|
|
1137
|
+
// connect, so it now runs at the slow fallback cadence.
|
|
1138
|
+
void this.openAuthSessionSocket(handle.sessionToken);
|
|
1139
|
+
this.scheduleNextPoll(handle.sessionToken);
|
|
1140
|
+
if (opts.deliver) {
|
|
1141
|
+
// Non-blocking on purpose: the QR/authorizeCode are already renderable
|
|
1142
|
+
// and the popup caller can navigate immediately, while the route (a
|
|
1143
|
+
// local probe plus at most one delivery round-trip) resolves behind it.
|
|
1144
|
+
void this.resolveDeliveryRoute(handle);
|
|
1145
|
+
}
|
|
1146
|
+
return handle;
|
|
939
1147
|
}
|
|
940
1148
|
|
|
941
1149
|
/**
|
|
@@ -1061,7 +1269,7 @@ export class AccountDialogController {
|
|
|
1061
1269
|
if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
|
|
1062
1270
|
// Closing the surface cancels the REQUEST too, not just this listener.
|
|
1063
1271
|
const pendingCode = this.signIn.authorizeCode;
|
|
1064
|
-
this.failSignIn('Sign-in was cancelled.');
|
|
1272
|
+
this.failSignIn('cancelled', 'Sign-in was cancelled.');
|
|
1065
1273
|
if (pendingCode) {
|
|
1066
1274
|
void this.withdrawRequest(pendingCode);
|
|
1067
1275
|
}
|
|
@@ -1128,8 +1336,15 @@ export class AccountDialogController {
|
|
|
1128
1336
|
* cancel the request, not just stop listening to it. Without the withdrawal a
|
|
1129
1337
|
* dismissed QR would stay approvable until it expired, so a later scan of a
|
|
1130
1338
|
* stale code could authorize a session nobody is waiting for.
|
|
1339
|
+
*
|
|
1340
|
+
* It also ENDS the attempt: anything of it still awaiting a response (the
|
|
1341
|
+
* request being created, a shared-identity mint, a claim) finds itself
|
|
1342
|
+
* superseded when that response arrives, and neither touches the surface nor
|
|
1343
|
+
* installs a session. The withdrawal itself is best effort — cancelling
|
|
1344
|
+
* locally is not proof the server received it (see {@link withdrawRequest}).
|
|
1131
1345
|
*/
|
|
1132
1346
|
cancelSignIn(): void {
|
|
1347
|
+
this.signInAttempt += 1;
|
|
1133
1348
|
// Capture before the teardown clears it, and only for a request that can
|
|
1134
1349
|
// still be approved — a completed/failed flow has nothing to withdraw.
|
|
1135
1350
|
const pendingCode =
|
|
@@ -1140,7 +1355,7 @@ export class AccountDialogController {
|
|
|
1140
1355
|
this.closeAuthSessionSocket();
|
|
1141
1356
|
this.closeActivePopup();
|
|
1142
1357
|
this.signInToken = null;
|
|
1143
|
-
if (this.signIn !==
|
|
1358
|
+
if (this.signIn.phase !== 'idle') {
|
|
1144
1359
|
this.setSignIn(IDLE_SIGN_IN_FACTS);
|
|
1145
1360
|
}
|
|
1146
1361
|
if (pendingCode) {
|
|
@@ -1186,12 +1401,13 @@ export class AccountDialogController {
|
|
|
1186
1401
|
*/
|
|
1187
1402
|
private async pollOnce(sessionToken: string): Promise<void> {
|
|
1188
1403
|
// A superseded / cancelled flow must not act; a poll already running owns the claim.
|
|
1189
|
-
if (this.signInToken !== sessionToken || this.
|
|
1190
|
-
|
|
1404
|
+
if (this.signInToken !== sessionToken || this.pollInFlightToken === sessionToken) return;
|
|
1405
|
+
const attempt = this.signInAttempt;
|
|
1406
|
+
this.pollInFlightToken = sessionToken;
|
|
1191
1407
|
try {
|
|
1192
1408
|
const expiresAt = this.signIn.expiresAt;
|
|
1193
1409
|
if (typeof expiresAt === 'number' && Date.now() > expiresAt) {
|
|
1194
|
-
this.failSignIn('Session expired. Please try again.');
|
|
1410
|
+
this.failSignIn('expired', 'Session expired. Please try again.');
|
|
1195
1411
|
return;
|
|
1196
1412
|
}
|
|
1197
1413
|
try {
|
|
@@ -1206,20 +1422,20 @@ export class AccountDialogController {
|
|
|
1206
1422
|
// OAuth-bound sessions mint no sessionId on approval — they finalize
|
|
1207
1423
|
// into an authorization code. The account dialog only starts device
|
|
1208
1424
|
// sign-in today; stop rather than poll until expiry.
|
|
1209
|
-
this.failSignIn('This sign-in flow cannot be completed here. Use the app\'s OAuth sign-in instead.');
|
|
1425
|
+
this.failSignIn('unsupported-flow', 'This sign-in flow cannot be completed here. Use the app\'s OAuth sign-in instead.');
|
|
1210
1426
|
return;
|
|
1211
1427
|
}
|
|
1212
1428
|
if (status.authorized && status.sessionId) {
|
|
1213
1429
|
this.clearPollTimer();
|
|
1214
|
-
await this.claimAndComplete(status.sessionId, sessionToken);
|
|
1430
|
+
await this.claimAndComplete(attempt, status.sessionId, sessionToken);
|
|
1215
1431
|
return;
|
|
1216
1432
|
}
|
|
1217
1433
|
if (status.status === 'cancelled') {
|
|
1218
|
-
this.failSignIn('Authorization was denied.');
|
|
1434
|
+
this.failSignIn('denied', 'Authorization was denied.');
|
|
1219
1435
|
return;
|
|
1220
1436
|
}
|
|
1221
1437
|
if (status.status === 'expired') {
|
|
1222
|
-
this.failSignIn('Session expired. Please try again.');
|
|
1438
|
+
this.failSignIn('expired', 'Session expired. Please try again.');
|
|
1223
1439
|
return;
|
|
1224
1440
|
}
|
|
1225
1441
|
} catch (error) {
|
|
@@ -1230,7 +1446,7 @@ export class AccountDialogController {
|
|
|
1230
1446
|
this.scheduleNextPoll(sessionToken);
|
|
1231
1447
|
}
|
|
1232
1448
|
} finally {
|
|
1233
|
-
this.
|
|
1449
|
+
if (this.pollInFlightToken === sessionToken) this.pollInFlightToken = null;
|
|
1234
1450
|
}
|
|
1235
1451
|
}
|
|
1236
1452
|
|
|
@@ -1249,7 +1465,7 @@ export class AccountDialogController {
|
|
|
1249
1465
|
this.patchSignIn({ pushSentAt: nextPushSentAt, openedAt: nextOpenedAt });
|
|
1250
1466
|
}
|
|
1251
1467
|
|
|
1252
|
-
private async claimAndComplete(sessionId: string, sessionToken: string): Promise<void> {
|
|
1468
|
+
private async claimAndComplete(attempt: number, sessionId: string, sessionToken: string): Promise<void> {
|
|
1253
1469
|
this.patchSignIn({ phase: 'authorized' });
|
|
1254
1470
|
let claimed: {
|
|
1255
1471
|
accessToken: string;
|
|
@@ -1260,13 +1476,17 @@ export class AccountDialogController {
|
|
|
1260
1476
|
deviceSecret?: string;
|
|
1261
1477
|
};
|
|
1262
1478
|
try {
|
|
1263
|
-
|
|
1479
|
+
// Claimed WITHOUT planting the bearer — `completeSignIn` installs it only
|
|
1480
|
+
// if the user is still waiting for this attempt when the claim returns.
|
|
1481
|
+
claimed = await this.oxyServices.claimSessionByToken(sessionToken, { plantTokens: false });
|
|
1264
1482
|
} catch (error) {
|
|
1265
|
-
this.
|
|
1483
|
+
if (!this.isCurrentAttempt(attempt)) return;
|
|
1484
|
+
this.failSignIn('claim-failed', errorMessage(error));
|
|
1266
1485
|
return;
|
|
1267
1486
|
}
|
|
1487
|
+
if (!this.isCurrentAttempt(attempt)) return;
|
|
1268
1488
|
if (!claimed?.accessToken || !claimed.user) {
|
|
1269
|
-
this.failSignIn('Authorization succeeded but the session could not be claimed. Please try again.');
|
|
1489
|
+
this.failSignIn('claim-failed', 'Authorization succeeded but the session could not be claimed. Please try again.');
|
|
1270
1490
|
return;
|
|
1271
1491
|
}
|
|
1272
1492
|
// `SessionLoginResponse.user` is the minimal session-carried shape; the claim
|
|
@@ -1280,6 +1500,7 @@ export class AccountDialogController {
|
|
|
1280
1500
|
};
|
|
1281
1501
|
try {
|
|
1282
1502
|
await this.completeSignIn(
|
|
1503
|
+
attempt,
|
|
1283
1504
|
{
|
|
1284
1505
|
sessionId: claimed.sessionId || sessionId,
|
|
1285
1506
|
deviceId: claimed.deviceId ?? '',
|
|
@@ -1291,28 +1512,62 @@ export class AccountDialogController {
|
|
|
1291
1512
|
minimalUser,
|
|
1292
1513
|
);
|
|
1293
1514
|
} catch (error) {
|
|
1294
|
-
this.failSignIn(errorMessage(error));
|
|
1515
|
+
if (this.isCurrentAttempt(attempt)) this.failSignIn('unknown', errorMessage(error));
|
|
1295
1516
|
}
|
|
1296
1517
|
}
|
|
1297
1518
|
|
|
1298
1519
|
/**
|
|
1299
|
-
*
|
|
1300
|
-
* by the shared-key
|
|
1520
|
+
* Install an authorized session, notify, and return to the account list.
|
|
1521
|
+
* Shared by the shared-key and QR paths so they cannot drift.
|
|
1522
|
+
*
|
|
1523
|
+
* THE install point, and so the last place an abandoned attempt is stopped:
|
|
1524
|
+
* the bearer is planted and the session committed only while `attempt` is
|
|
1525
|
+
* still current. A commit that fails restores the bearer the client had
|
|
1526
|
+
* before, so a half-installed session never leaves requests going out as an
|
|
1527
|
+
* account the device does not hold.
|
|
1528
|
+
*
|
|
1529
|
+
* A failed commit REJECTS (after that restore), so each caller keeps its own
|
|
1530
|
+
* policy: the QR path fails the attempt, the shared-identity path falls
|
|
1531
|
+
* through to the QR handoff.
|
|
1532
|
+
*
|
|
1533
|
+
* Once the commit has resolved the session IS this device's, whether or not
|
|
1534
|
+
* the user moved on while it ran. It is reported (`onSignedIn`, re-read) either
|
|
1535
|
+
* way; only the surface bookkeeping is skipped for a superseded attempt, so a
|
|
1536
|
+
* newer attempt's state is never overwritten.
|
|
1537
|
+
*
|
|
1538
|
+
* Residual: an attempt abandoned after the server minted its session (a claim
|
|
1539
|
+
* or shared-identity mint that was already in flight) leaves that server
|
|
1540
|
+
* session un-installed on this device — it is never planted or committed here,
|
|
1541
|
+
* but it is not revoked either.
|
|
1301
1542
|
*/
|
|
1302
1543
|
private async completeSignIn(
|
|
1544
|
+
attempt: number,
|
|
1303
1545
|
session: SessionLoginResponse,
|
|
1304
1546
|
user: MinimalUserData,
|
|
1305
1547
|
): Promise<void> {
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1548
|
+
if (!this.isCurrentAttempt(attempt)) return;
|
|
1549
|
+
const previousToken = this.oxyServices.getAccessToken();
|
|
1550
|
+
try {
|
|
1551
|
+
if (session.accessToken) this.oxyServices.setTokens(session.accessToken);
|
|
1552
|
+
await this.commitAuthorizedSession(session, user);
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
if (session.accessToken && this.oxyServices.getAccessToken() === session.accessToken) {
|
|
1555
|
+
if (previousToken) this.oxyServices.setTokens(previousToken);
|
|
1556
|
+
else this.oxyServices.clearTokens();
|
|
1557
|
+
}
|
|
1558
|
+
throw error;
|
|
1559
|
+
}
|
|
1560
|
+
if (this.isCurrentAttempt(attempt)) {
|
|
1561
|
+
this.signInToken = null;
|
|
1562
|
+
this.clearPollTimer();
|
|
1563
|
+
this.closeAuthSessionSocket();
|
|
1564
|
+
this.closeActivePopup();
|
|
1565
|
+
// Terminal SUCCESS, not idle: the surface gets one honest frame to show
|
|
1566
|
+
// "Identity confirmed" before it closes. Cleared on the next view change.
|
|
1567
|
+
this.signIn = buildSignIn(COMPLETED_SIGN_IN_FACTS, this.signInAttempt);
|
|
1568
|
+
this.view = 'accounts';
|
|
1569
|
+
this.emit();
|
|
1570
|
+
}
|
|
1316
1571
|
this.onSignedIn?.(user);
|
|
1317
1572
|
await this.refresh();
|
|
1318
1573
|
}
|
|
@@ -1333,12 +1588,16 @@ export class AccountDialogController {
|
|
|
1333
1588
|
}
|
|
1334
1589
|
}
|
|
1335
1590
|
|
|
1336
|
-
|
|
1591
|
+
/**
|
|
1592
|
+
* End the CURRENT attempt as failed. The attempt keeps its identity (the
|
|
1593
|
+
* failure belongs to it); only a new attempt or a cancel moves it on.
|
|
1594
|
+
*/
|
|
1595
|
+
private failSignIn(failure: SignInFailureReason, message: string): void {
|
|
1337
1596
|
this.clearPollTimer();
|
|
1338
1597
|
this.closeAuthSessionSocket();
|
|
1339
1598
|
this.closeActivePopup();
|
|
1340
1599
|
this.signInToken = null;
|
|
1341
|
-
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message });
|
|
1600
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message, failure });
|
|
1342
1601
|
}
|
|
1343
1602
|
|
|
1344
1603
|
private clearPollTimer(): void {
|
|
@@ -1426,7 +1685,7 @@ export class AccountDialogController {
|
|
|
1426
1685
|
* to advance without a fact behind it.
|
|
1427
1686
|
*/
|
|
1428
1687
|
private setSignIn(facts: SignInFlowFacts): void {
|
|
1429
|
-
this.signIn =
|
|
1688
|
+
this.signIn = buildSignIn(facts, this.signInAttempt);
|
|
1430
1689
|
this.emit();
|
|
1431
1690
|
}
|
|
1432
1691
|
|
|
@@ -1438,6 +1697,7 @@ export class AccountDialogController {
|
|
|
1438
1697
|
qrPayload,
|
|
1439
1698
|
expiresAt,
|
|
1440
1699
|
error,
|
|
1700
|
+
failure,
|
|
1441
1701
|
route,
|
|
1442
1702
|
routeFailed,
|
|
1443
1703
|
pushSentAt,
|
|
@@ -1449,6 +1709,7 @@ export class AccountDialogController {
|
|
|
1449
1709
|
qrPayload,
|
|
1450
1710
|
expiresAt,
|
|
1451
1711
|
error,
|
|
1712
|
+
failure,
|
|
1452
1713
|
route,
|
|
1453
1714
|
routeFailed,
|
|
1454
1715
|
pushSentAt,
|