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