@oxyhq/core 12.10.5 → 12.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +142 -37
- package/dist/cjs/i18n/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/cjs/i18n/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/zh-CN.json +13 -1
- package/dist/cjs/index.js +31 -5
- package/dist/cjs/mixins/OxyServices.auth.js +257 -17
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/index.js +5 -0
- package/dist/cjs/session/SessionClient.js +40 -4
- package/dist/cjs/session/accountDialogController.js +288 -41
- package/dist/cjs/session/hubSync.js +1 -1
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +54 -15
- package/dist/cjs/session/refresh.js +73 -8
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/oauthPkce.js +33 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +142 -37
- package/dist/esm/i18n/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/esm/i18n/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/zh-CN.json +13 -1
- package/dist/esm/index.js +14 -1
- package/dist/esm/mixins/OxyServices.auth.js +257 -17
- package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/index.js +5 -0
- package/dist/esm/session/SessionClient.js +40 -4
- package/dist/esm/session/accountDialogController.js +288 -41
- package/dist/esm/session/hubSync.js +1 -1
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +54 -15
- package/dist/esm/session/refresh.js +73 -8
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/oauthPkce.js +31 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +38 -1
- package/dist/types/index.d.ts +12 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/session/SessionClient.d.ts +28 -0
- package/dist/types/session/accountDialogController.d.ts +187 -9
- package/dist/types/session/identityPin.d.ts +87 -0
- package/dist/types/session/identitySession.d.ts +105 -0
- package/dist/types/session/projectSessionState.d.ts +21 -19
- package/dist/types/session/refresh.d.ts +32 -1
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/oauthPkce.d.ts +21 -1
- package/package.json +2 -2
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/sessionColdBoot.ts +183 -37
- package/src/i18n/locales/ar-SA.json +13 -1
- package/src/i18n/locales/ca-ES.json +13 -1
- package/src/i18n/locales/de-DE.json +13 -1
- package/src/i18n/locales/en-US.json +13 -4
- package/src/i18n/locales/es-ES.json +13 -4
- package/src/i18n/locales/fr-FR.json +13 -1
- package/src/i18n/locales/it-IT.json +13 -1
- package/src/i18n/locales/ja-JP.json +13 -1
- package/src/i18n/locales/ko-KR.json +13 -1
- package/src/i18n/locales/pt-PT.json +13 -1
- package/src/i18n/locales/zh-CN.json +13 -1
- package/src/index.ts +49 -0
- package/src/mixins/OxyServices.auth.ts +464 -24
- package/src/mixins/OxyServices.deviceBoot.ts +54 -2
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
- package/src/mixins/index.ts +6 -0
- package/src/session/SessionClient.ts +57 -4
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/accountDialogController.test.ts +812 -1
- package/src/session/__tests__/hubSync.test.ts +51 -0
- package/src/session/__tests__/identityPin.test.ts +131 -0
- package/src/session/__tests__/identitySession.test.ts +194 -0
- package/src/session/__tests__/projectSessionState.test.ts +88 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
- package/src/session/accountDialogController.ts +435 -44
- package/src/session/hubSync.ts +1 -1
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +61 -13
- package/src/session/refresh.ts +103 -9
- package/src/utils/__tests__/oauthPkce.test.ts +51 -0
- package/src/utils/commonsDelivery.ts +132 -0
- package/src/utils/oauthPkce.ts +56 -3
|
@@ -22,6 +22,17 @@
|
|
|
22
22
|
* - the "Sign in with Oxy" device flow (same-device shared-keychain via
|
|
23
23
|
* `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
|
|
24
24
|
* via `startCommonsSignIn` → poll → `claimSessionByToken`);
|
|
25
|
+
* - AUTOMATIC delivery selection for that flow (issue #691): the user presses
|
|
26
|
+
* ONE primary action and the controller — not the user — picks how the
|
|
27
|
+
* request reaches their Commons identity, by gathering the facts
|
|
28
|
+
* (`platform`, `commonsAvailability`, and the `targets` a bearer-authorized
|
|
29
|
+
* `deliverCommonsSignIn` reached) and handing them to the pure
|
|
30
|
+
* `selectCommonsDelivery`. Exactly one route is primary; alternatives are
|
|
31
|
+
* never chained behind it, they are state (`signIn.route`,
|
|
32
|
+
* `signIn.routeFailed`) the UI reveals on its own terms;
|
|
33
|
+
* - honest, non-sensitive PROGRESS for that flow (`signIn.progress`), derived
|
|
34
|
+
* only from real signals — the chosen route, `pushSentAt`, `openedAt`,
|
|
35
|
+
* `authorized`, and the commit — never from an optimistic timeline;
|
|
25
36
|
* - `commonsAvailability` — whether Commons is installed on this device
|
|
26
37
|
* (native only, via the injected `canOpenApp` probe), so the QR view can
|
|
27
38
|
* offer a "Get Commons" fallback instead of a same-device dead end.
|
|
@@ -51,6 +62,37 @@ const logger_1 = require("../logger");
|
|
|
51
62
|
const errorUtils_1 = require("../utils/errorUtils");
|
|
52
63
|
const authWebUrl_1 = require("../utils/authWebUrl");
|
|
53
64
|
const accountProjection_1 = require("./accountProjection");
|
|
65
|
+
const commonsDelivery_1 = require("../utils/commonsDelivery");
|
|
66
|
+
/**
|
|
67
|
+
* Derive the surface-facing progress from the flow's real facts. Pure, total,
|
|
68
|
+
* and the single place the ladder is defined.
|
|
69
|
+
*
|
|
70
|
+
* Ordering within `'waiting'` is most-specific-first, so a late-arriving weaker
|
|
71
|
+
* signal can never pull the display backwards.
|
|
72
|
+
*/
|
|
73
|
+
function deriveSignInProgress(facts) {
|
|
74
|
+
switch (facts.phase) {
|
|
75
|
+
case 'idle':
|
|
76
|
+
case 'error':
|
|
77
|
+
return 'idle';
|
|
78
|
+
case 'starting':
|
|
79
|
+
return 'preparing';
|
|
80
|
+
case 'authorized':
|
|
81
|
+
return 'confirming-identity';
|
|
82
|
+
case 'completed':
|
|
83
|
+
return 'identity-confirmed';
|
|
84
|
+
case 'waiting':
|
|
85
|
+
if (facts.openedAt !== null)
|
|
86
|
+
return 'opened-in-commons';
|
|
87
|
+
// `route === 'await-push'` is itself a server-confirmed dispatch (the
|
|
88
|
+
// route is only chosen when `deliverCommonsSignIn` reported ≥1 target),
|
|
89
|
+
// so it is a real signal — not an optimistic assumption that a push will
|
|
90
|
+
// arrive. `pushSentAt` is the same fact re-confirmed by the status poll.
|
|
91
|
+
if (facts.pushSentAt !== null || facts.route === 'await-push')
|
|
92
|
+
return 'delivered-to-commons';
|
|
93
|
+
return facts.route === null ? 'preparing' : 'awaiting-approval';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
54
96
|
/**
|
|
55
97
|
* Slow FALLBACK poll cadence for the QR flow. The `/auth-session` socket delivers
|
|
56
98
|
* the approval instantly via `auth_update`; this poll only covers the case where
|
|
@@ -66,12 +108,30 @@ const AUTH_SESSION_NAMESPACE = '/auth-session';
|
|
|
66
108
|
* itself is the flow's `qrPayload`.
|
|
67
109
|
*/
|
|
68
110
|
const COMMONS_APP_SCHEME = 'oxycommons://';
|
|
69
|
-
const
|
|
111
|
+
const IDLE_SIGN_IN_FACTS = {
|
|
70
112
|
phase: 'idle',
|
|
71
113
|
authorizeCode: null,
|
|
72
114
|
qrPayload: null,
|
|
73
115
|
expiresAt: null,
|
|
74
116
|
error: null,
|
|
117
|
+
route: null,
|
|
118
|
+
routeFailed: false,
|
|
119
|
+
pushSentAt: null,
|
|
120
|
+
openedAt: null,
|
|
121
|
+
};
|
|
122
|
+
const IDLE_SIGN_IN = {
|
|
123
|
+
...IDLE_SIGN_IN_FACTS,
|
|
124
|
+
progress: deriveSignInProgress(IDLE_SIGN_IN_FACTS),
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Terminal SUCCESS state: the session was claimed and committed. Holds no live
|
|
128
|
+
* resources and no request handles — only the terminal progress the surface
|
|
129
|
+
* shows ("Identity confirmed") before it closes.
|
|
130
|
+
*/
|
|
131
|
+
const COMPLETED_SIGN_IN = {
|
|
132
|
+
...IDLE_SIGN_IN_FACTS,
|
|
133
|
+
phase: 'completed',
|
|
134
|
+
progress: deriveSignInProgress({ ...IDLE_SIGN_IN_FACTS, phase: 'completed' }),
|
|
75
135
|
};
|
|
76
136
|
function errorMessage(error) {
|
|
77
137
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -128,6 +188,7 @@ class AccountDialogController {
|
|
|
128
188
|
this.socketFactory = options.socketFactory;
|
|
129
189
|
this.openPopup = options.openPopup;
|
|
130
190
|
this.hubBaseUrl = options.hubBaseUrl ?? `https://auth.${authWebUrl_1.CENTRAL_IDP_APEX}`;
|
|
191
|
+
this.platform = options.platform ?? 'unknown';
|
|
131
192
|
this.snapshot = this.computeSnapshot();
|
|
132
193
|
}
|
|
133
194
|
// =========================================================================
|
|
@@ -257,6 +318,13 @@ class AccountDialogController {
|
|
|
257
318
|
if (this.view === view)
|
|
258
319
|
return;
|
|
259
320
|
this.view = view;
|
|
321
|
+
// A `'completed'` flow owns no timers, socket, popup, or token — it is only
|
|
322
|
+
// the terminal "Identity confirmed" the finished surface showed. Moving to
|
|
323
|
+
// another view is a NEW intention, so drop it; otherwise a later `add()`
|
|
324
|
+
// would open on the previous sign-in's terminal state.
|
|
325
|
+
if (this.signIn.phase === 'completed') {
|
|
326
|
+
this.signIn = IDLE_SIGN_IN;
|
|
327
|
+
}
|
|
260
328
|
this.emit();
|
|
261
329
|
}
|
|
262
330
|
/** Switch to the "add account" view (the sign-in entry chooser). */
|
|
@@ -440,7 +508,7 @@ class AccountDialogController {
|
|
|
440
508
|
*/
|
|
441
509
|
async signInWithOxy() {
|
|
442
510
|
this.setView('qr');
|
|
443
|
-
this.setSignIn({ ...
|
|
511
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
|
|
444
512
|
try {
|
|
445
513
|
const session = await this.oxyServices.signInWithSharedIdentity();
|
|
446
514
|
if (session) {
|
|
@@ -464,7 +532,7 @@ class AccountDialogController {
|
|
|
464
532
|
async showQr() {
|
|
465
533
|
this.cancelSignIn();
|
|
466
534
|
this.setView('qr');
|
|
467
|
-
await this.startDeviceFlowSession();
|
|
535
|
+
await this.startDeviceFlowSession({ deliver: true });
|
|
468
536
|
}
|
|
469
537
|
/**
|
|
470
538
|
* Web-only: "Sign in with a passkey" on a non-Oxy origin cannot run the
|
|
@@ -490,10 +558,16 @@ class AccountDialogController {
|
|
|
490
558
|
this.setView('qr');
|
|
491
559
|
const popup = this.openPopup?.() ?? null;
|
|
492
560
|
if (!popup) {
|
|
493
|
-
await this.startDeviceFlowSession();
|
|
561
|
+
await this.startDeviceFlowSession({ deliver: true });
|
|
494
562
|
return;
|
|
495
563
|
}
|
|
496
|
-
|
|
564
|
+
// The hub popup IS the primary surface here, chosen explicitly by the user —
|
|
565
|
+
// so this flow does NOT run automatic Commons delivery (ringing the user's
|
|
566
|
+
// phone because they asked for a passkey would be exactly the "menu of
|
|
567
|
+
// methods" the one-primary-action rule forbids). The underlying request is
|
|
568
|
+
// the same `AuthSession`, and its Commons route stays the QR the view
|
|
569
|
+
// renders beneath the popup.
|
|
570
|
+
const handle = await this.startDeviceFlowSession({ deliver: false });
|
|
497
571
|
if (!handle) {
|
|
498
572
|
popup.close();
|
|
499
573
|
return;
|
|
@@ -515,42 +589,148 @@ class AccountDialogController {
|
|
|
515
589
|
* same `startCommonsSignIn` → poll/socket wiring either way. Returns the
|
|
516
590
|
* handle on success (already reflected in `signIn`), or `null` on failure
|
|
517
591
|
* (already set as `signIn.error`).
|
|
592
|
+
*
|
|
593
|
+
* @param opts.deliver - Whether to run automatic Commons delivery selection
|
|
594
|
+
* ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
|
|
595
|
+
* entry; `false` when the caller already owns the primary surface (the
|
|
596
|
+
* passkey hub popup), where the request's Commons route is simply the QR.
|
|
518
597
|
*/
|
|
519
|
-
async startDeviceFlowSession() {
|
|
598
|
+
async startDeviceFlowSession(opts) {
|
|
520
599
|
if (!this.clientId) {
|
|
521
|
-
this.setSignIn({ ...
|
|
600
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: 'This app is not configured for sign-in (missing clientId).' });
|
|
522
601
|
return null;
|
|
523
602
|
}
|
|
524
|
-
this.setSignIn({ ...
|
|
603
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
|
|
525
604
|
try {
|
|
526
605
|
const handle = await this.oxyServices.startCommonsSignIn({ clientId: this.clientId });
|
|
527
606
|
this.signInToken = handle.sessionToken;
|
|
528
607
|
this.setSignIn({
|
|
608
|
+
...IDLE_SIGN_IN_FACTS,
|
|
529
609
|
phase: 'waiting',
|
|
530
610
|
authorizeCode: handle.authorizeCode,
|
|
531
611
|
qrPayload: handle.qrPayload,
|
|
532
612
|
expiresAt: handle.expiresAt,
|
|
533
|
-
|
|
613
|
+
// No route yet: the surface shows "Preparing request" until the primary
|
|
614
|
+
// route is resolved below. It is never guessed in the meantime.
|
|
615
|
+
route: opts.deliver ? null : 'qr',
|
|
534
616
|
});
|
|
535
617
|
// Primary path: an instant `auth_update` wake over the `/auth-session`
|
|
536
618
|
// socket. The poll below is only the fallback for when the socket can't
|
|
537
619
|
// connect, so it now runs at the slow fallback cadence.
|
|
538
620
|
this.openAuthSessionSocket(handle.sessionToken);
|
|
539
621
|
this.scheduleNextPoll(handle.sessionToken);
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
void this.deepLinkIntoCommonsIfAvailable(handle.qrPayload);
|
|
622
|
+
if (opts.deliver) {
|
|
623
|
+
// Non-blocking on purpose: the QR/authorizeCode are already renderable
|
|
624
|
+
// and the popup caller can navigate immediately, while the route (a
|
|
625
|
+
// local probe plus at most one delivery round-trip) resolves behind it.
|
|
626
|
+
void this.resolveDeliveryRoute(handle);
|
|
627
|
+
}
|
|
547
628
|
return handle;
|
|
548
629
|
}
|
|
549
630
|
catch (error) {
|
|
550
|
-
this.setSignIn({ ...
|
|
631
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: errorMessage(error) });
|
|
551
632
|
return null;
|
|
552
633
|
}
|
|
553
634
|
}
|
|
635
|
+
/**
|
|
636
|
+
* Choose and carry out the ONE primary delivery route for the active request
|
|
637
|
+
* (issue #691, "Automatic delivery selection").
|
|
638
|
+
*
|
|
639
|
+
* The controller gathers the facts — is a verified Commons link openable on
|
|
640
|
+
* THIS device, and how many known Commons installations did Oxy actually push
|
|
641
|
+
* to — and hands them to the pure {@link selectCommonsDelivery}. The selector
|
|
642
|
+
* owns the decision; this method owns only the observations and the single
|
|
643
|
+
* action the chosen route implies. It never cascades: a route that yields
|
|
644
|
+
* nothing resolves to QR *before* a route is chosen, never after.
|
|
645
|
+
*/
|
|
646
|
+
async resolveDeliveryRoute(handle) {
|
|
647
|
+
if (!this.isAwaitingApproval(handle.sessionToken))
|
|
648
|
+
return;
|
|
649
|
+
// Fact 1 — a VERIFIED Commons link openable on this very device. Native
|
|
650
|
+
// only; on web `commonsAvailability` stays `'unknown'` (a browser cannot be
|
|
651
|
+
// asked whether a custom scheme is registered) and this is simply `false`.
|
|
652
|
+
if (this.canOpenApp &&
|
|
653
|
+
(this.commonsAvailability === 'unknown' || this.commonsAvailability === 'checking')) {
|
|
654
|
+
await this.resolveCommonsAvailability();
|
|
655
|
+
if (!this.isAwaitingApproval(handle.sessionToken))
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const commonsAvailable = this.commonsAvailability === 'available';
|
|
659
|
+
// Fact 2 — how many known Commons installations the server pushed to.
|
|
660
|
+
const pushTargets = await this.deliverToKnownCommons(handle.authorizeCode, commonsAvailable);
|
|
661
|
+
if (!this.isAwaitingApproval(handle.sessionToken))
|
|
662
|
+
return;
|
|
663
|
+
const route = (0, commonsDelivery_1.selectCommonsDelivery)({ platform: this.platform, commonsAvailable, pushTargets });
|
|
664
|
+
// The only route that has an action to perform on this device — and the only
|
|
665
|
+
// one that can fail here. `'await-push'` was already dispatched server-side;
|
|
666
|
+
// `'qr'` is rendered by the surface from `qrPayload`.
|
|
667
|
+
const routeFailed = route === 'open-commons' ? !this.openCommonsLink(handle.qrPayload) : false;
|
|
668
|
+
this.patchSignIn({ route, routeFailed });
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Whether `sessionToken` is still THE request this surface is waiting on.
|
|
672
|
+
*
|
|
673
|
+
* Guards every step of the asynchronous route resolution: a cancelled,
|
|
674
|
+
* superseded, failed, or already-approved flow must neither open Commons nor
|
|
675
|
+
* mutate the surface — an approval that lands mid-resolution would otherwise
|
|
676
|
+
* be followed by a pointless app switch.
|
|
677
|
+
*/
|
|
678
|
+
isAwaitingApproval(sessionToken) {
|
|
679
|
+
return this.signInToken === sessionToken && this.signIn.phase === 'waiting';
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Ask Oxy to deliver the pending request to the identity's known Commons
|
|
683
|
+
* installations, returning how many it reached (`0` when delivery is not
|
|
684
|
+
* applicable, not permitted, or reached nobody).
|
|
685
|
+
*
|
|
686
|
+
* Two hard rules, both from the issue:
|
|
687
|
+
* - **Never push from an unauthenticated surface.** `deliverCommonsSignIn`
|
|
688
|
+
* is bearer-required precisely because a request that merely carries a
|
|
689
|
+
* typed-in username must never be able to ring somebody's phone. Without a
|
|
690
|
+
* planted bearer the call is not made AT ALL — not made-and-failed.
|
|
691
|
+
* - **Zero targets and a failed delivery are the same normal outcome.** Both
|
|
692
|
+
* return `0`, which resolves the primary route to QR silently. Neither is
|
|
693
|
+
* surfaced as an error: there is nothing the user did wrong and nothing for
|
|
694
|
+
* them to fix.
|
|
695
|
+
*/
|
|
696
|
+
async deliverToKnownCommons(authorizeCode, commonsAvailable) {
|
|
697
|
+
// Route 1 (mobile with a verified local Commons link) reaches the identity
|
|
698
|
+
// on this very device — pushing as well would notify a second surface for a
|
|
699
|
+
// request the user is about to confirm here.
|
|
700
|
+
if (this.platform === 'mobile' && commonsAvailable)
|
|
701
|
+
return 0;
|
|
702
|
+
if (!this.isAuthenticated())
|
|
703
|
+
return 0;
|
|
704
|
+
try {
|
|
705
|
+
const result = await this.oxyServices.deliverCommonsSignIn(authorizeCode);
|
|
706
|
+
// `delivered: false` with `targets > 0` is a transport failure, not a
|
|
707
|
+
// reachable install — the shared helper owns that rule so the surface
|
|
708
|
+
// can never park a user on "check your phone" for a push that failed.
|
|
709
|
+
return (0, commonsDelivery_1.pushTargetsFromDelivery)(result);
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
logger_1.logger.debug('[AccountDialogController] Commons delivery unavailable (QR route)', { component: 'AccountDialogController' }, error);
|
|
713
|
+
return 0;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Open the verified Commons link for the `'open-commons'` route. Returns
|
|
718
|
+
* whether the navigation was actually handed off, so a missing opener or a
|
|
719
|
+
* throwing one becomes an observable `routeFailed` instead of a silent dead
|
|
720
|
+
* end the user stares at.
|
|
721
|
+
*/
|
|
722
|
+
openCommonsLink(qrPayload) {
|
|
723
|
+
if (!this.openUrl)
|
|
724
|
+
return false;
|
|
725
|
+
try {
|
|
726
|
+
this.openUrl(qrPayload);
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
logger_1.logger.debug('[AccountDialogController] Commons deep link failed', { component: 'AccountDialogController' }, error);
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
554
734
|
/**
|
|
555
735
|
* Poll {@link PopupWindowHandle.closed} so a user who dismisses the hub
|
|
556
736
|
* popup without completing sign-in gets prompt feedback (there is no DOM
|
|
@@ -565,7 +745,12 @@ class AccountDialogController {
|
|
|
565
745
|
return;
|
|
566
746
|
this.clearPopupWatchTimer();
|
|
567
747
|
if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
|
|
748
|
+
// Closing the surface cancels the REQUEST too, not just this listener.
|
|
749
|
+
const pendingCode = this.signIn.authorizeCode;
|
|
568
750
|
this.failSignIn('Sign-in was cancelled.');
|
|
751
|
+
if (pendingCode) {
|
|
752
|
+
void this.withdrawRequest(pendingCode);
|
|
753
|
+
}
|
|
569
754
|
}
|
|
570
755
|
}, 1000);
|
|
571
756
|
}
|
|
@@ -618,32 +803,43 @@ class AccountDialogController {
|
|
|
618
803
|
this.emit();
|
|
619
804
|
}
|
|
620
805
|
/**
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
806
|
+
* Tear down the active sign-in device flow (timers + socket + popup + token),
|
|
807
|
+
* WITHDRAW the request server-side, and reset to idle.
|
|
808
|
+
*
|
|
809
|
+
* Cancellation has to converge in both directions: the surface closing must
|
|
810
|
+
* cancel the request, not just stop listening to it. Without the withdrawal a
|
|
811
|
+
* dismissed QR would stay approvable until it expired, so a later scan of a
|
|
812
|
+
* stale code could authorize a session nobody is waiting for.
|
|
625
813
|
*/
|
|
626
|
-
async deepLinkIntoCommonsIfAvailable(qrPayload) {
|
|
627
|
-
if (!this.openUrl)
|
|
628
|
-
return;
|
|
629
|
-
if (this.commonsAvailability === 'unknown' || this.commonsAvailability === 'checking') {
|
|
630
|
-
// The eager `start()` probe hasn't resolved yet (or was never run, e.g.
|
|
631
|
-
// `showQr` called without a prior `start()`) — resolve it now rather
|
|
632
|
-
// than skipping the deep link.
|
|
633
|
-
await this.resolveCommonsAvailability();
|
|
634
|
-
}
|
|
635
|
-
if (this.commonsAvailability === 'available') {
|
|
636
|
-
this.openUrl(qrPayload);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
/** Tear down the active sign-in device flow (timers + socket + popup + token) and reset to idle. */
|
|
640
814
|
cancelSignIn() {
|
|
815
|
+
// Capture before the teardown clears it, and only for a request that can
|
|
816
|
+
// still be approved — a completed/failed flow has nothing to withdraw.
|
|
817
|
+
const pendingCode = this.signIn.phase === 'starting' || this.signIn.phase === 'waiting'
|
|
818
|
+
? this.signIn.authorizeCode
|
|
819
|
+
: null;
|
|
641
820
|
this.clearPollTimer();
|
|
642
821
|
this.closeAuthSessionSocket();
|
|
643
822
|
this.closeActivePopup();
|
|
644
823
|
this.signInToken = null;
|
|
645
824
|
if (this.signIn !== IDLE_SIGN_IN) {
|
|
646
|
-
this.setSignIn(
|
|
825
|
+
this.setSignIn(IDLE_SIGN_IN_FACTS);
|
|
826
|
+
}
|
|
827
|
+
if (pendingCode) {
|
|
828
|
+
void this.withdrawRequest(pendingCode);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Best-effort server-side withdrawal of a request this surface abandoned
|
|
833
|
+
* (`POST /auth/session/deny/:authorizeCode`). Fire-and-forget by design: the
|
|
834
|
+
* local teardown already happened, and a race with an approval that just
|
|
835
|
+
* landed legitimately rejects here — neither outcome is worth surfacing.
|
|
836
|
+
*/
|
|
837
|
+
async withdrawRequest(authorizeCode) {
|
|
838
|
+
try {
|
|
839
|
+
await this.oxyServices.denyCommonsSignIn(authorizeCode);
|
|
840
|
+
}
|
|
841
|
+
catch (error) {
|
|
842
|
+
logger_1.logger.debug('[AccountDialogController] request withdrawal failed', { component: 'AccountDialogController' }, error);
|
|
647
843
|
}
|
|
648
844
|
}
|
|
649
845
|
// =========================================================================
|
|
@@ -677,6 +873,18 @@ class AccountDialogController {
|
|
|
677
873
|
const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
|
|
678
874
|
if (this.signInToken !== sessionToken)
|
|
679
875
|
return; // cancelled mid-request
|
|
876
|
+
// Delivery PROGRESS first: it is reported alongside every status, and
|
|
877
|
+
// recording it before the terminal branches means a poll that also
|
|
878
|
+
// carries the approval still leaves an honest trail behind it.
|
|
879
|
+
this.recordDeliveryProgress(status.pushSentAt, status.openedAt);
|
|
880
|
+
const purpose = status.purpose === 'oauth_authorization' ? 'oauth_authorization' : 'device_sign_in';
|
|
881
|
+
if (status.authorized && purpose === 'oauth_authorization') {
|
|
882
|
+
// OAuth-bound sessions mint no sessionId on approval — they finalize
|
|
883
|
+
// into an authorization code. The account dialog only starts device
|
|
884
|
+
// sign-in today; stop rather than poll until expiry.
|
|
885
|
+
this.failSignIn('This sign-in flow cannot be completed here. Use the app\'s OAuth sign-in instead.');
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
680
888
|
if (status.authorized && status.sessionId) {
|
|
681
889
|
this.clearPollTimer();
|
|
682
890
|
await this.claimAndComplete(status.sessionId, sessionToken);
|
|
@@ -703,8 +911,23 @@ class AccountDialogController {
|
|
|
703
911
|
this.pollInFlight = false;
|
|
704
912
|
}
|
|
705
913
|
}
|
|
914
|
+
/**
|
|
915
|
+
* Record server-reported delivery progress on the active flow.
|
|
916
|
+
*
|
|
917
|
+
* Monotone and additive: a timestamp is only ever adopted, never replaced or
|
|
918
|
+
* cleared, so an older API build (or a partial payload) that omits a field can
|
|
919
|
+
* at most fail to advance the surface — it can never walk it backwards.
|
|
920
|
+
* Emits only on a real change, so a steady poll does not churn the snapshot.
|
|
921
|
+
*/
|
|
922
|
+
recordDeliveryProgress(pushSentAt, openedAt) {
|
|
923
|
+
const nextPushSentAt = this.signIn.pushSentAt ?? pushSentAt ?? null;
|
|
924
|
+
const nextOpenedAt = this.signIn.openedAt ?? openedAt ?? null;
|
|
925
|
+
if (nextPushSentAt === this.signIn.pushSentAt && nextOpenedAt === this.signIn.openedAt)
|
|
926
|
+
return;
|
|
927
|
+
this.patchSignIn({ pushSentAt: nextPushSentAt, openedAt: nextOpenedAt });
|
|
928
|
+
}
|
|
706
929
|
async claimAndComplete(sessionId, sessionToken) {
|
|
707
|
-
this.
|
|
930
|
+
this.patchSignIn({ phase: 'authorized' });
|
|
708
931
|
let claimed;
|
|
709
932
|
try {
|
|
710
933
|
claimed = await this.oxyServices.claimSessionByToken(sessionToken);
|
|
@@ -750,7 +973,9 @@ class AccountDialogController {
|
|
|
750
973
|
this.clearPollTimer();
|
|
751
974
|
this.closeAuthSessionSocket();
|
|
752
975
|
this.closeActivePopup();
|
|
753
|
-
|
|
976
|
+
// Terminal SUCCESS, not idle: the surface gets one honest frame to show
|
|
977
|
+
// "Identity confirmed" before it closes. Cleared on the next view change.
|
|
978
|
+
this.signIn = COMPLETED_SIGN_IN;
|
|
754
979
|
this.view = 'accounts';
|
|
755
980
|
this.emit();
|
|
756
981
|
this.onSignedIn?.(user);
|
|
@@ -783,7 +1008,7 @@ class AccountDialogController {
|
|
|
783
1008
|
this.closeAuthSessionSocket();
|
|
784
1009
|
this.closeActivePopup();
|
|
785
1010
|
this.signInToken = null;
|
|
786
|
-
this.setSignIn({ ...
|
|
1011
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message });
|
|
787
1012
|
}
|
|
788
1013
|
clearPollTimer() {
|
|
789
1014
|
if (this.pollTimer !== null) {
|
|
@@ -865,10 +1090,32 @@ class AccountDialogController {
|
|
|
865
1090
|
// =========================================================================
|
|
866
1091
|
// Snapshot plumbing
|
|
867
1092
|
// =========================================================================
|
|
868
|
-
|
|
869
|
-
|
|
1093
|
+
/**
|
|
1094
|
+
* Replace the device-flow state from its FACTS, re-deriving `progress`. The
|
|
1095
|
+
* only writer of `this.signIn` besides the two terminal constants — which is
|
|
1096
|
+
* what keeps `progress` impossible to set by hand, and therefore impossible
|
|
1097
|
+
* to advance without a fact behind it.
|
|
1098
|
+
*/
|
|
1099
|
+
setSignIn(facts) {
|
|
1100
|
+
this.signIn = { ...facts, progress: deriveSignInProgress(facts) };
|
|
870
1101
|
this.emit();
|
|
871
1102
|
}
|
|
1103
|
+
/** Update a subset of the device-flow facts, re-deriving `progress`. */
|
|
1104
|
+
patchSignIn(patch) {
|
|
1105
|
+
const { phase, authorizeCode, qrPayload, expiresAt, error, route, routeFailed, pushSentAt, openedAt, } = this.signIn;
|
|
1106
|
+
this.setSignIn({
|
|
1107
|
+
phase,
|
|
1108
|
+
authorizeCode,
|
|
1109
|
+
qrPayload,
|
|
1110
|
+
expiresAt,
|
|
1111
|
+
error,
|
|
1112
|
+
route,
|
|
1113
|
+
routeFailed,
|
|
1114
|
+
pushSentAt,
|
|
1115
|
+
openedAt,
|
|
1116
|
+
...patch,
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
872
1119
|
computeSnapshot() {
|
|
873
1120
|
const state = this.sessionClient.getState();
|
|
874
1121
|
return {
|
|
@@ -33,7 +33,7 @@ async function syncHubAfterSignIn(oxy, opts) {
|
|
|
33
33
|
}
|
|
34
34
|
const hubOrigin = (0, officialOrigins_1.buildIdpHubOrigin)();
|
|
35
35
|
const issued = await oxy.issueHubTicket(hubOrigin);
|
|
36
|
-
const returnUrl = `${location.origin}${location.pathname}${location.search}`;
|
|
36
|
+
const returnUrl = `${location.origin}${location.pathname}${location.search}${location.hash}`;
|
|
37
37
|
const syncUrl = (0, officialOrigins_1.buildHubSyncUrl)(issued.ticket, returnUrl);
|
|
38
38
|
window.location.assign(syncUrl);
|
|
39
39
|
return true;
|