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