@oxy.so/core 1.12.1 → 1.16.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/webIdentityCarrier.js +1 -1
- package/dist/cjs/i18n/locales/ar-SA.json +33 -88
- package/dist/cjs/i18n/locales/ca-ES.json +35 -90
- package/dist/cjs/i18n/locales/de-DE.json +33 -88
- package/dist/cjs/i18n/locales/en-US.json +45 -141
- package/dist/cjs/i18n/locales/es-ES.json +44 -140
- package/dist/cjs/i18n/locales/fr-FR.json +33 -88
- package/dist/cjs/i18n/locales/it-IT.json +33 -88
- package/dist/cjs/i18n/locales/ja-JP.json +33 -88
- package/dist/cjs/i18n/locales/ko-KR.json +33 -88
- package/dist/cjs/i18n/locales/locales/ar-SA.json +33 -88
- package/dist/cjs/i18n/locales/locales/ca-ES.json +35 -90
- package/dist/cjs/i18n/locales/locales/de-DE.json +33 -88
- package/dist/cjs/i18n/locales/locales/en-US.json +45 -141
- package/dist/cjs/i18n/locales/locales/es-ES.json +44 -140
- package/dist/cjs/i18n/locales/locales/fr-FR.json +33 -88
- package/dist/cjs/i18n/locales/locales/it-IT.json +33 -88
- package/dist/cjs/i18n/locales/locales/ja-JP.json +33 -88
- package/dist/cjs/i18n/locales/locales/ko-KR.json +33 -88
- package/dist/cjs/i18n/locales/locales/pt-PT.json +33 -88
- package/dist/cjs/i18n/locales/locales/zh-CN.json +34 -89
- package/dist/cjs/i18n/locales/pt-PT.json +33 -88
- package/dist/cjs/i18n/locales/zh-CN.json +34 -89
- package/dist/cjs/index.js +10 -7
- package/dist/cjs/mixins/OxyServices.utility.js +140 -0
- package/dist/cjs/server/securityHeaders.js +19 -1
- package/dist/cjs/session/accountDialogController.js +37 -142
- package/dist/cjs/utils/authWebUrl.js +7 -6
- package/dist/cjs/utils/oauthPkce.js +5 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/webIdentityCarrier.js +1 -1
- package/dist/esm/i18n/locales/ar-SA.json +33 -88
- package/dist/esm/i18n/locales/ca-ES.json +35 -90
- package/dist/esm/i18n/locales/de-DE.json +33 -88
- package/dist/esm/i18n/locales/en-US.json +45 -141
- package/dist/esm/i18n/locales/es-ES.json +44 -140
- package/dist/esm/i18n/locales/fr-FR.json +33 -88
- package/dist/esm/i18n/locales/it-IT.json +33 -88
- package/dist/esm/i18n/locales/ja-JP.json +33 -88
- package/dist/esm/i18n/locales/ko-KR.json +33 -88
- package/dist/esm/i18n/locales/locales/ar-SA.json +33 -88
- package/dist/esm/i18n/locales/locales/ca-ES.json +35 -90
- package/dist/esm/i18n/locales/locales/de-DE.json +33 -88
- package/dist/esm/i18n/locales/locales/en-US.json +45 -141
- package/dist/esm/i18n/locales/locales/es-ES.json +44 -140
- package/dist/esm/i18n/locales/locales/fr-FR.json +33 -88
- package/dist/esm/i18n/locales/locales/it-IT.json +33 -88
- package/dist/esm/i18n/locales/locales/ja-JP.json +33 -88
- package/dist/esm/i18n/locales/locales/ko-KR.json +33 -88
- package/dist/esm/i18n/locales/locales/pt-PT.json +33 -88
- package/dist/esm/i18n/locales/locales/zh-CN.json +34 -89
- package/dist/esm/i18n/locales/pt-PT.json +33 -88
- package/dist/esm/i18n/locales/zh-CN.json +34 -89
- package/dist/esm/index.js +2 -1
- package/dist/esm/mixins/OxyServices.utility.js +138 -0
- package/dist/esm/server/securityHeaders.js +19 -1
- package/dist/esm/session/accountDialogController.js +37 -142
- package/dist/esm/utils/authWebUrl.js +6 -5
- package/dist/esm/utils/oauthPkce.js +5 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/webIdentityCarrier.d.ts +1 -1
- package/dist/types/index.d.ts +5 -3
- package/dist/types/mixins/OxyServices.utility.d.ts +84 -1
- package/dist/types/server/requesterAssertion.d.ts +2 -2
- package/dist/types/server/securityHeaders.d.ts +9 -12
- package/dist/types/session/accountDialogController.d.ts +29 -98
- package/dist/types/utils/authWebUrl.d.ts +6 -5
- package/dist/types/utils/oauthPkce.d.ts +10 -0
- package/package.json +2 -2
- package/src/crypto/webIdentityCarrier.ts +1 -1
- package/src/i18n/locales/ar-SA.json +33 -88
- package/src/i18n/locales/ca-ES.json +35 -90
- package/src/i18n/locales/de-DE.json +33 -88
- package/src/i18n/locales/en-US.json +45 -141
- package/src/i18n/locales/es-ES.json +44 -140
- package/src/i18n/locales/fr-FR.json +33 -88
- package/src/i18n/locales/it-IT.json +33 -88
- package/src/i18n/locales/ja-JP.json +33 -88
- package/src/i18n/locales/ko-KR.json +33 -88
- package/src/i18n/locales/pt-PT.json +33 -88
- package/src/i18n/locales/zh-CN.json +34 -89
- package/src/index.ts +9 -3
- package/src/mixins/OxyServices.utility.ts +208 -0
- package/src/mixins/__tests__/accountEvents.test.ts +158 -0
- package/src/server/__tests__/securityHeaders.test.ts +17 -0
- package/src/server/securityHeaders.ts +29 -1
- package/src/session/__tests__/accountDialogController.test.ts +42 -184
- package/src/session/accountDialogController.ts +50 -185
- package/src/utils/__tests__/oauthPkce.test.ts +7 -0
- package/src/utils/authWebUrl.ts +6 -5
- package/src/utils/oauthPkce.ts +16 -1
|
@@ -36,12 +36,6 @@
|
|
|
36
36
|
* - `commonsAvailability` — whether Commons is installed on this device
|
|
37
37
|
* (native only, via the injected `canOpenApp` probe), so the QR view can
|
|
38
38
|
* offer a "Get Commons" fallback instead of a same-device dead end.
|
|
39
|
-
* - `startPasskeyHubSignIn` — on a non-Oxy web origin (where a WebAuthn
|
|
40
|
-
* ceremony can't run locally, the RP-ID is bound to oxy.so), open a popup
|
|
41
|
-
* at the auth.oxy.so passkey hub instead, scoped to the SAME device-flow
|
|
42
|
-
* session `showQr` creates, and complete it via the SAME poll/socket/claim
|
|
43
|
-
* engine. Falls back to `showQr`'s plain QR rendering if the popup is
|
|
44
|
-
* blocked.
|
|
45
39
|
*
|
|
46
40
|
* Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff —
|
|
47
41
|
* password, social login, and 2FA were removed ecosystem-wide. Account
|
|
@@ -62,7 +56,6 @@ import type { SessionLoginResponse, MinimalUserData } from '../models/session';
|
|
|
62
56
|
import type { User } from '../models/interfaces';
|
|
63
57
|
import { logger } from '../logger';
|
|
64
58
|
import { extractErrorStatus } from '../utils/errorUtils';
|
|
65
|
-
import { IDENTITY_WEB_ORIGIN } from '../utils/authWebUrl';
|
|
66
59
|
import type { SessionClient } from './SessionClient';
|
|
67
60
|
import { getSocketIO, type MinimalSocket, type SocketIOFactory } from './socketLoader';
|
|
68
61
|
import { resolveActiveContext, type DeviceContext } from './deviceDirectory';
|
|
@@ -143,8 +136,6 @@ export type SignInProgress =
|
|
|
143
136
|
* WHY a sign-in attempt ended in `'error'` — a machine-readable reason the UI
|
|
144
137
|
* turns into localized copy (the controller ships no user-facing prose).
|
|
145
138
|
*
|
|
146
|
-
* - `'cancelled'` — the USER walked away (closed the passkey hub popup). A
|
|
147
|
-
* voluntary outcome, not a failure to report.
|
|
148
139
|
* - `'denied'` — the approver declined the request in Commons.
|
|
149
140
|
* - `'expired'` — the request outlived its server-authoritative expiry.
|
|
150
141
|
* - `'network'` — Oxy could not be reached (no response, timeout).
|
|
@@ -154,7 +145,6 @@ export type SignInProgress =
|
|
|
154
145
|
* - `'unknown'` — anything else; the raw message stays on `error` for logs.
|
|
155
146
|
*/
|
|
156
147
|
export type SignInFailureReason =
|
|
157
|
-
| 'cancelled'
|
|
158
148
|
| 'denied'
|
|
159
149
|
| 'expired'
|
|
160
150
|
| 'network'
|
|
@@ -163,20 +153,6 @@ export type SignInFailureReason =
|
|
|
163
153
|
| 'claim-failed'
|
|
164
154
|
| 'unknown';
|
|
165
155
|
|
|
166
|
-
/**
|
|
167
|
-
* Minimal structural handle over a popup `Window` — just enough for the
|
|
168
|
-
* cross-origin passkey hub flow ({@link AccountDialogController.startPasskeyHubSignIn}):
|
|
169
|
-
* navigate it once the device-flow session's `authorizeCode` is known, detect
|
|
170
|
-
* the user closing it early (`closed`), and close it programmatically on
|
|
171
|
-
* completion/failure. Satisfied directly by `window.open()`'s return value on
|
|
172
|
-
* web; native has no popup concept and never injects an opener.
|
|
173
|
-
*/
|
|
174
|
-
export interface PopupWindowHandle {
|
|
175
|
-
readonly closed: boolean;
|
|
176
|
-
close(): void;
|
|
177
|
-
location: { href: string };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
156
|
/**
|
|
181
157
|
* State of the "Sign in with Oxy" (shared-key / QR) device flow.
|
|
182
158
|
*
|
|
@@ -254,6 +230,14 @@ export interface SignInFlowState {
|
|
|
254
230
|
* attempt failing with the same message is).
|
|
255
231
|
*/
|
|
256
232
|
attempt: number;
|
|
233
|
+
/**
|
|
234
|
+
* `true` when this attempt is the sign-in entry's EMBEDDED QR
|
|
235
|
+
* ({@link AccountDialogController.startInlineQr}): a request the surface
|
|
236
|
+
* started on its own, not one the person asked for. Its failures (an expired
|
|
237
|
+
* code, above all) belong to that QR, which renews itself, and are not news
|
|
238
|
+
* to report.
|
|
239
|
+
*/
|
|
240
|
+
inline: boolean;
|
|
257
241
|
}
|
|
258
242
|
|
|
259
243
|
/**
|
|
@@ -261,10 +245,10 @@ export interface SignInFlowState {
|
|
|
261
245
|
* values the controller derives. Every mutation of the flow goes through this
|
|
262
246
|
* shape, which is what makes `progress` (and `attempt`) impossible to set by hand.
|
|
263
247
|
*/
|
|
264
|
-
type SignInFlowFacts = Omit<SignInFlowState, 'progress' | 'attempt'>;
|
|
248
|
+
type SignInFlowFacts = Omit<SignInFlowState, 'progress' | 'attempt' | 'inline'>;
|
|
265
249
|
|
|
266
250
|
/** How the current sign-in attempt was started — what "Try again" repeats. */
|
|
267
|
-
type SignInMethod = 'oxy' | 'qr' | '
|
|
251
|
+
type SignInMethod = 'oxy' | 'qr' | 'inline-qr';
|
|
268
252
|
|
|
269
253
|
/**
|
|
270
254
|
* Derive the surface-facing progress from the flow's real facts. Pure, total,
|
|
@@ -422,28 +406,6 @@ export interface AccountDialogControllerOptions {
|
|
|
422
406
|
* `showQr` behaves exactly as before (render QR only).
|
|
423
407
|
*/
|
|
424
408
|
canOpenApp?: (url: string) => Promise<boolean>;
|
|
425
|
-
/**
|
|
426
|
-
* Open a new, empty popup window SYNCHRONOUSLY (before any `await`) so the
|
|
427
|
-
* browser attributes it to the click gesture that triggered it, returning a
|
|
428
|
-
* handle to navigate once the device-flow session is known — or `null` if
|
|
429
|
-
* the popup was blocked. Injected by the provider (web only:
|
|
430
|
-
* `window.open('', ...)`; absent on native, where the hub-popup flow does
|
|
431
|
-
* not apply). Used by {@link AccountDialogController.startPasskeyHubSignIn}.
|
|
432
|
-
*/
|
|
433
|
-
openPopup?: () => PopupWindowHandle | null;
|
|
434
|
-
/**
|
|
435
|
-
* Origin of the web identity carrier (default `IDENTITY_WEB_ORIGIN`,
|
|
436
|
-
* `https://id.oxy.so`) — where a passkey sign-in or sign-up runs
|
|
437
|
-
* and the account's identity is kept sealed under the passkey. The popup opens
|
|
438
|
-
* `<identityOrigin>/continue?code=…`. Overridable for local/staging testing.
|
|
439
|
-
*/
|
|
440
|
-
identityOrigin?: string;
|
|
441
|
-
/**
|
|
442
|
-
* @deprecated Alias of {@link identityOrigin}, kept for existing
|
|
443
|
-
* configuration. The popup used to open `auth.oxy.so/hub-passkey`; it now opens
|
|
444
|
-
* the identity origin's `/continue`, so a value here must be that origin.
|
|
445
|
-
*/
|
|
446
|
-
hubBaseUrl?: string;
|
|
447
409
|
/**
|
|
448
410
|
* Which surface the sign-in is initiated from — a FACT supplied by the
|
|
449
411
|
* consumer, because only the consumer can classify its own environment
|
|
@@ -497,8 +459,8 @@ const IDLE_SIGN_IN_FACTS: SignInFlowFacts = {
|
|
|
497
459
|
const COMPLETED_SIGN_IN_FACTS: SignInFlowFacts = { ...IDLE_SIGN_IN_FACTS, phase: 'completed' };
|
|
498
460
|
|
|
499
461
|
/** The full flow state for `facts`, stamped with the attempt it belongs to. */
|
|
500
|
-
function buildSignIn(facts: SignInFlowFacts, attempt: number): SignInFlowState {
|
|
501
|
-
return { ...facts, progress: deriveSignInProgress(facts), attempt };
|
|
462
|
+
function buildSignIn(facts: SignInFlowFacts, attempt: number, inline: boolean): SignInFlowState {
|
|
463
|
+
return { ...facts, progress: deriveSignInProgress(facts), attempt, inline };
|
|
502
464
|
}
|
|
503
465
|
|
|
504
466
|
function errorMessage(error: unknown): string {
|
|
@@ -529,8 +491,6 @@ export class AccountDialogController {
|
|
|
529
491
|
private readonly openUrl?: (url: string) => void;
|
|
530
492
|
private readonly canOpenApp?: (url: string) => Promise<boolean>;
|
|
531
493
|
private readonly socketFactory?: SocketIOFactory;
|
|
532
|
-
private readonly openPopup?: () => PopupWindowHandle | null;
|
|
533
|
-
private readonly identityOrigin: string;
|
|
534
494
|
private readonly platform: CommonsDeliveryPlatform;
|
|
535
495
|
|
|
536
496
|
private readonly listeners = new Set<SnapshotListener>();
|
|
@@ -548,7 +508,7 @@ export class AccountDialogController {
|
|
|
548
508
|
* alongside the three flags above.
|
|
549
509
|
*/
|
|
550
510
|
private exclusiveMutationInFlight = false;
|
|
551
|
-
private signIn: SignInFlowState = buildSignIn(IDLE_SIGN_IN_FACTS, 0);
|
|
511
|
+
private signIn: SignInFlowState = buildSignIn(IDLE_SIGN_IN_FACTS, 0, false);
|
|
552
512
|
private commonsAvailability: CommonsAvailability = 'unknown';
|
|
553
513
|
|
|
554
514
|
// --- Sign-in device-flow bookkeeping ---
|
|
@@ -578,10 +538,6 @@ export class AccountDialogController {
|
|
|
578
538
|
* ABANDONED request never blocks the next request's first poll.
|
|
579
539
|
*/
|
|
580
540
|
private pollInFlightToken: string | null = null;
|
|
581
|
-
/** The popup opened by {@link startPasskeyHubSignIn}, or `null`. */
|
|
582
|
-
private activePopup: PopupWindowHandle | null = null;
|
|
583
|
-
/** Watches {@link activePopup}'s `closed` state; see {@link watchPopup}. */
|
|
584
|
-
private popupWatchTimer: ReturnType<typeof setInterval> | null = null;
|
|
585
541
|
|
|
586
542
|
// --- Store plumbing ---
|
|
587
543
|
private unsubscribeSession: (() => void) | null = null;
|
|
@@ -602,8 +558,6 @@ export class AccountDialogController {
|
|
|
602
558
|
this.openUrl = options.openUrl;
|
|
603
559
|
this.canOpenApp = options.canOpenApp;
|
|
604
560
|
this.socketFactory = options.socketFactory;
|
|
605
|
-
this.openPopup = options.openPopup;
|
|
606
|
-
this.identityOrigin = options.identityOrigin ?? options.hubBaseUrl ?? IDENTITY_WEB_ORIGIN;
|
|
607
561
|
this.platform = options.platform ?? 'unknown';
|
|
608
562
|
this.snapshot = this.computeSnapshot();
|
|
609
563
|
}
|
|
@@ -686,7 +640,6 @@ export class AccountDialogController {
|
|
|
686
640
|
}
|
|
687
641
|
this.clearPollTimer();
|
|
688
642
|
this.closeAuthSessionSocket();
|
|
689
|
-
this.closeActivePopup();
|
|
690
643
|
this.signInToken = null;
|
|
691
644
|
this.listeners.clear();
|
|
692
645
|
}
|
|
@@ -754,12 +707,12 @@ export class AccountDialogController {
|
|
|
754
707
|
const view = this.resolveView(requested);
|
|
755
708
|
if (this.view === view) return;
|
|
756
709
|
this.view = view;
|
|
757
|
-
// A `'completed'` flow owns no timers, socket,
|
|
710
|
+
// A `'completed'` flow owns no timers, socket, or token — it is only
|
|
758
711
|
// the terminal "Identity confirmed" the finished surface showed. Moving to
|
|
759
712
|
// another view is a NEW intention, so drop it; otherwise a later `add()`
|
|
760
713
|
// would open on the previous sign-in's terminal state.
|
|
761
714
|
if (this.signIn.phase === 'completed') {
|
|
762
|
-
this.signIn =
|
|
715
|
+
this.signIn = this.stampSignIn(IDLE_SIGN_IN_FACTS);
|
|
763
716
|
}
|
|
764
717
|
this.emit();
|
|
765
718
|
}
|
|
@@ -1105,82 +1058,35 @@ export class AccountDialogController {
|
|
|
1105
1058
|
}
|
|
1106
1059
|
|
|
1107
1060
|
/**
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
* passkey-hub attempt reopens the hub popup; a failed "Sign in with Oxy"
|
|
1111
|
-
* retries that; an explicit QR request shows the QR.
|
|
1061
|
+
* The sign-in entry's EMBEDDED QR: a request whose only route is the QR,
|
|
1062
|
+
* started without leaving the current view.
|
|
1112
1063
|
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1064
|
+
* Unlike {@link showQr} it runs no delivery selection. The surface starts it
|
|
1065
|
+
* by itself, on mount, so it must not push to the person's phone or open
|
|
1066
|
+
* Commons — nobody asked for either. Any other sign-in the person then
|
|
1067
|
+
* chooses supersedes it (and withdraws its request) like any new attempt.
|
|
1068
|
+
*/
|
|
1069
|
+
async startInlineQr(): Promise<void> {
|
|
1070
|
+
const attempt = this.beginSignInAttempt('inline-qr');
|
|
1071
|
+
await this.startDeviceFlowSession(attempt, { deliver: false });
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Start a NEW attempt the same way the last one was started — "Try again"
|
|
1076
|
+
* repeats the user's choice rather than silently switching method: a failed
|
|
1077
|
+
* "Sign in with Oxy" retries that; an explicit QR request shows the QR.
|
|
1116
1078
|
*/
|
|
1117
1079
|
retrySignIn(): Promise<void> {
|
|
1118
1080
|
switch (this.signInMethod) {
|
|
1119
|
-
case 'passkey-hub':
|
|
1120
|
-
return this.startPasskeyHubSignIn();
|
|
1121
1081
|
case 'qr':
|
|
1122
1082
|
return this.showQr();
|
|
1083
|
+
case 'inline-qr':
|
|
1084
|
+
return this.startInlineQr();
|
|
1123
1085
|
default:
|
|
1124
1086
|
return this.signInWithOxy();
|
|
1125
1087
|
}
|
|
1126
1088
|
}
|
|
1127
1089
|
|
|
1128
|
-
/**
|
|
1129
|
-
* Web-only: sign in (or create an account) with a passkey at the web identity
|
|
1130
|
-
* carrier. The ceremony runs on `id.oxy.so`, never on the calling origin, for
|
|
1131
|
-
* two reasons: a credential minted with `WEBAUTHN_RP_ID=oxy.so` can only be
|
|
1132
|
-
* asserted from `oxy.so`/a subdomain/loopback (a browser-enforced boundary),
|
|
1133
|
-
* and only the identity origin may unseal or create the account's identity
|
|
1134
|
-
* (one identity, two carriers). Opens a popup at `<identityOrigin>/continue`,
|
|
1135
|
-
* scoped to the SAME device-flow
|
|
1136
|
-
* session {@link showQr} would create (same `authorizeCode`/`sessionToken`
|
|
1137
|
-
* pair), and let the SAME poll/socket/claim engine complete it once the hub
|
|
1138
|
-
* authorizes the session (`POST /auth/session/authorize-code/:authorizeCode`,
|
|
1139
|
-
* bearer-authed, server-side — this method never sees or transmits the
|
|
1140
|
-
* secret `sessionToken`).
|
|
1141
|
-
*
|
|
1142
|
-
* The popup MUST be opened synchronously, before any `await`, or the
|
|
1143
|
-
* browser's popup blocker loses the click-gesture attribution and silently
|
|
1144
|
-
* blocks it — so {@link openPopup} is invoked as the very first step, before
|
|
1145
|
-
* the device-flow session (which needs an async call) even exists. If the
|
|
1146
|
-
* popup is blocked (or no `openPopup` was injected), this falls back to
|
|
1147
|
-
* `showQr`'s plain QR rendering — no dead end.
|
|
1148
|
-
*/
|
|
1149
|
-
async startPasskeyHubSignIn(): Promise<void> {
|
|
1150
|
-
const attempt = this.beginSignInAttempt('passkey-hub');
|
|
1151
|
-
this.setView('qr');
|
|
1152
|
-
const popup = this.openPopup?.() ?? null;
|
|
1153
|
-
if (!popup) {
|
|
1154
|
-
await this.startDeviceFlowSession(attempt, { deliver: true });
|
|
1155
|
-
return;
|
|
1156
|
-
}
|
|
1157
|
-
// Owned by the attempt from the moment it exists, so cancelling while the
|
|
1158
|
-
// session is still being created closes it instead of leaving a blank
|
|
1159
|
-
// window behind.
|
|
1160
|
-
this.activePopup = popup;
|
|
1161
|
-
// The hub popup IS the primary surface here, chosen explicitly by the user —
|
|
1162
|
-
// so this flow does NOT run automatic Commons delivery (ringing the user's
|
|
1163
|
-
// phone because they asked for a passkey would be exactly the "menu of
|
|
1164
|
-
// methods" the one-primary-action rule forbids). The underlying request is
|
|
1165
|
-
// the same `AuthSession`, and its Commons route stays the QR the view
|
|
1166
|
-
// renders beneath the popup.
|
|
1167
|
-
const handle = await this.startDeviceFlowSession(attempt, { deliver: false });
|
|
1168
|
-
// `null` means the attempt failed (its teardown closed the popup) or was
|
|
1169
|
-
// abandoned (the cancel closed it).
|
|
1170
|
-
if (!handle) return;
|
|
1171
|
-
// The user may have closed the popup during the async session creation
|
|
1172
|
-
// above, before any watcher was attached to catch it — guard rather than
|
|
1173
|
-
// navigate a dead window (browsers vary on whether that throws).
|
|
1174
|
-
if (popup.closed) {
|
|
1175
|
-
const pendingCode = this.signIn.authorizeCode;
|
|
1176
|
-
this.failSignIn('cancelled', 'Sign-in was cancelled.');
|
|
1177
|
-
if (pendingCode) void this.withdrawRequest(pendingCode);
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
popup.location.href = `${this.identityOrigin}/continue?code=${encodeURIComponent(handle.authorizeCode)}`;
|
|
1181
|
-
this.watchPopup(popup);
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
1090
|
/**
|
|
1185
1091
|
* End whatever attempt is running (withdrawing its request) and begin a new
|
|
1186
1092
|
* one. Returns the new attempt's identity for the caller to re-check after
|
|
@@ -1198,9 +1104,9 @@ export class AccountDialogController {
|
|
|
1198
1104
|
}
|
|
1199
1105
|
|
|
1200
1106
|
/**
|
|
1201
|
-
* Shared device-flow session creation for
|
|
1202
|
-
*
|
|
1203
|
-
*
|
|
1107
|
+
* Shared device-flow session creation for {@link signInWithOxy},
|
|
1108
|
+
* {@link showQr} and {@link startInlineQr} — the same `startCommonsSignIn` →
|
|
1109
|
+
* poll/socket wiring every time. Returns the
|
|
1204
1110
|
* handle on success (already reflected in `signIn`), or `null` when the
|
|
1205
1111
|
* attempt failed (already set as `signIn.failure`) or was abandoned while the
|
|
1206
1112
|
* request was being created.
|
|
@@ -1211,8 +1117,8 @@ export class AccountDialogController {
|
|
|
1211
1117
|
* request, a socket, and a poll behind it.
|
|
1212
1118
|
* @param opts.deliver - Whether to run automatic Commons delivery selection
|
|
1213
1119
|
* ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
|
|
1214
|
-
* entry; `false`
|
|
1215
|
-
*
|
|
1120
|
+
* entry; `false` for the embedded QR, which the screen starts by itself,
|
|
1121
|
+
* where the request's Commons route is simply the QR.
|
|
1216
1122
|
*/
|
|
1217
1123
|
private async startDeviceFlowSession(
|
|
1218
1124
|
attempt: number,
|
|
@@ -1255,9 +1161,9 @@ export class AccountDialogController {
|
|
|
1255
1161
|
void this.openAuthSessionSocket(handle.sessionToken);
|
|
1256
1162
|
this.scheduleNextPoll(handle.sessionToken);
|
|
1257
1163
|
if (opts.deliver) {
|
|
1258
|
-
// Non-blocking on purpose: the QR/authorizeCode are already renderable
|
|
1259
|
-
//
|
|
1260
|
-
//
|
|
1164
|
+
// Non-blocking on purpose: the QR/authorizeCode are already renderable,
|
|
1165
|
+
// while the route (a local probe plus at most one delivery round-trip)
|
|
1166
|
+
// resolves behind it.
|
|
1261
1167
|
void this.resolveDeliveryRoute(handle);
|
|
1262
1168
|
}
|
|
1263
1169
|
return handle;
|
|
@@ -1371,49 +1277,6 @@ export class AccountDialogController {
|
|
|
1371
1277
|
}
|
|
1372
1278
|
}
|
|
1373
1279
|
|
|
1374
|
-
/**
|
|
1375
|
-
* Poll {@link PopupWindowHandle.closed} so a user who dismisses the hub
|
|
1376
|
-
* popup without completing sign-in gets prompt feedback (there is no DOM
|
|
1377
|
-
* event for a cross-origin popup closing — polling is the only mechanism).
|
|
1378
|
-
* Cleared by {@link closeActivePopup}, which every sign-in exit path already
|
|
1379
|
-
* calls.
|
|
1380
|
-
*/
|
|
1381
|
-
private watchPopup(popup: PopupWindowHandle): void {
|
|
1382
|
-
this.clearPopupWatchTimer();
|
|
1383
|
-
this.popupWatchTimer = setInterval(() => {
|
|
1384
|
-
if (!popup.closed) return;
|
|
1385
|
-
this.clearPopupWatchTimer();
|
|
1386
|
-
if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
|
|
1387
|
-
// Closing the surface cancels the REQUEST too, not just this listener.
|
|
1388
|
-
const pendingCode = this.signIn.authorizeCode;
|
|
1389
|
-
this.failSignIn('cancelled', 'Sign-in was cancelled.');
|
|
1390
|
-
if (pendingCode) {
|
|
1391
|
-
void this.withdrawRequest(pendingCode);
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1394
|
-
}, 1000);
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
private clearPopupWatchTimer(): void {
|
|
1398
|
-
if (this.popupWatchTimer !== null) {
|
|
1399
|
-
clearInterval(this.popupWatchTimer);
|
|
1400
|
-
this.popupWatchTimer = null;
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
/** Tear down the popup watch timer and close the popup, if any. Idempotent. */
|
|
1405
|
-
private closeActivePopup(): void {
|
|
1406
|
-
this.clearPopupWatchTimer();
|
|
1407
|
-
const popup = this.activePopup;
|
|
1408
|
-
this.activePopup = null;
|
|
1409
|
-
if (!popup || popup.closed) return;
|
|
1410
|
-
try {
|
|
1411
|
-
popup.close();
|
|
1412
|
-
} catch (error) {
|
|
1413
|
-
logger.debug('[AccountDialogController] popup close failed', { component: 'AccountDialogController' }, error);
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
1280
|
/**
|
|
1418
1281
|
* Resolve whether Commons is installed on this device via the injected
|
|
1419
1282
|
* `canOpenApp` probe, updating {@link commonsAvailability} as durable,
|
|
@@ -1446,7 +1309,7 @@ export class AccountDialogController {
|
|
|
1446
1309
|
}
|
|
1447
1310
|
|
|
1448
1311
|
/**
|
|
1449
|
-
* Tear down the active sign-in device flow (timers + socket +
|
|
1312
|
+
* Tear down the active sign-in device flow (timers + socket + token),
|
|
1450
1313
|
* WITHDRAW the request server-side, and reset to idle.
|
|
1451
1314
|
*
|
|
1452
1315
|
* Cancellation has to converge in both directions: the surface closing must
|
|
@@ -1470,7 +1333,6 @@ export class AccountDialogController {
|
|
|
1470
1333
|
: null;
|
|
1471
1334
|
this.clearPollTimer();
|
|
1472
1335
|
this.closeAuthSessionSocket();
|
|
1473
|
-
this.closeActivePopup();
|
|
1474
1336
|
this.signInToken = null;
|
|
1475
1337
|
if (this.signIn.phase !== 'idle') {
|
|
1476
1338
|
this.setSignIn(IDLE_SIGN_IN_FACTS);
|
|
@@ -1678,10 +1540,9 @@ export class AccountDialogController {
|
|
|
1678
1540
|
this.signInToken = null;
|
|
1679
1541
|
this.clearPollTimer();
|
|
1680
1542
|
this.closeAuthSessionSocket();
|
|
1681
|
-
|
|
1682
|
-
// Terminal SUCCESS, not idle: the surface gets one honest frame to show
|
|
1543
|
+
// Terminal SUCCESS, not idle: the surface gets one honest frame to show
|
|
1683
1544
|
// "Identity confirmed" before it closes. Cleared on the next view change.
|
|
1684
|
-
this.signIn =
|
|
1545
|
+
this.signIn = this.stampSignIn(COMPLETED_SIGN_IN_FACTS);
|
|
1685
1546
|
this.view = 'accounts';
|
|
1686
1547
|
this.emit();
|
|
1687
1548
|
}
|
|
@@ -1712,7 +1573,6 @@ export class AccountDialogController {
|
|
|
1712
1573
|
private failSignIn(failure: SignInFailureReason, message: string): void {
|
|
1713
1574
|
this.clearPollTimer();
|
|
1714
1575
|
this.closeAuthSessionSocket();
|
|
1715
|
-
this.closeActivePopup();
|
|
1716
1576
|
this.signInToken = null;
|
|
1717
1577
|
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message, failure });
|
|
1718
1578
|
}
|
|
@@ -1802,10 +1662,15 @@ export class AccountDialogController {
|
|
|
1802
1662
|
* to advance without a fact behind it.
|
|
1803
1663
|
*/
|
|
1804
1664
|
private setSignIn(facts: SignInFlowFacts): void {
|
|
1805
|
-
this.signIn =
|
|
1665
|
+
this.signIn = this.stampSignIn(facts);
|
|
1806
1666
|
this.emit();
|
|
1807
1667
|
}
|
|
1808
1668
|
|
|
1669
|
+
/** `facts` as the current attempt's state. */
|
|
1670
|
+
private stampSignIn(facts: SignInFlowFacts): SignInFlowState {
|
|
1671
|
+
return buildSignIn(facts, this.signInAttempt, this.signInMethod === 'inline-qr');
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1809
1674
|
/** Update a subset of the device-flow facts, re-deriving `progress`. */
|
|
1810
1675
|
private patchSignIn(patch: Partial<SignInFlowFacts>): void {
|
|
1811
1676
|
const {
|
|
@@ -122,6 +122,13 @@ describe('buildOAuthAuthorizeUrl', () => {
|
|
|
122
122
|
expect(OXY_AUTHORIZE_URL).toBe('https://auth.oxy.so/authorize');
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
it('names the IdP screen to land on only when asked', () => {
|
|
126
|
+
expect(new URL(buildOAuthAuthorizeUrl(base)).searchParams.has('screen')).toBe(false);
|
|
127
|
+
for (const screen of ['signin', 'signup', 'recover'] as const) {
|
|
128
|
+
expect(new URL(buildOAuthAuthorizeUrl({ ...base, screen })).searchParams.get('screen')).toBe(screen);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
125
132
|
it('includes every required OAuth authorization-code + PKCE parameter', () => {
|
|
126
133
|
const params = new URL(buildOAuthAuthorizeUrl(base)).searchParams;
|
|
127
134
|
expect(params.get('client_id')).toBe(base.clientId);
|
package/src/utils/authWebUrl.ts
CHANGED
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
export const CENTRAL_IDP_APEX = 'oxy.so';
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* The
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* The IdP's origin, `auth.oxy.so` — the OAuth authorize/consent surface AND the
|
|
21
|
+
* web identity carrier: where a person signs in or creates an account with a
|
|
22
|
+
* passkey from another origin's popup, and the one origin that may create,
|
|
23
|
+
* open, recover or move their self-custody identity (one identity, two
|
|
24
|
+
* carriers). Apps open it; they never handle the identity themselves.
|
|
24
25
|
*/
|
|
25
|
-
export const
|
|
26
|
+
export const AUTH_WEB_ORIGIN = `https://auth.${CENTRAL_IDP_APEX}`;
|
package/src/utils/oauthPkce.ts
CHANGED
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
* React Native. No `require()`, so the ESM build stays bundler-clean.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { AUTH_WEB_ORIGIN } from './authWebUrl';
|
|
19
20
|
import { isNodeJS, isReactNative, loadExpoCrypto, loadNodeCrypto, sha256 } from '@oxy.so/protocol';
|
|
20
21
|
import { logger } from '../logger';
|
|
21
22
|
|
|
22
23
|
/** The central Oxy IdP authorization endpoint used by default. */
|
|
23
|
-
export const OXY_AUTHORIZE_URL =
|
|
24
|
+
export const OXY_AUTHORIZE_URL = `${AUTH_WEB_ORIGIN}/authorize`;
|
|
24
25
|
|
|
25
26
|
/** Default OAuth scope requested for a "Sign in with Oxy" third-party flow. */
|
|
26
27
|
export const DEFAULT_OAUTH_SCOPE = 'openid profile';
|
|
@@ -86,8 +87,19 @@ export interface BuildOAuthAuthorizeUrlParams {
|
|
|
86
87
|
* a token, device secret, or the PKCE verifier, which the opener keeps.
|
|
87
88
|
*/
|
|
88
89
|
responseMode?: 'web_message';
|
|
90
|
+
/**
|
|
91
|
+
* Which of the IdP's screens a person with no session there lands on:
|
|
92
|
+
* `signin` (sign in with a passkey), `signup` (create an account with its
|
|
93
|
+
* root), `recover` (recover it from the recovery phrase). Omitted, the IdP
|
|
94
|
+
* offers its Commons request first. An Oxy app sends this when it sends the
|
|
95
|
+
* person to auth.oxy.so for what only that origin can do, in the same tab.
|
|
96
|
+
*/
|
|
97
|
+
screen?: OxyAuthScreen;
|
|
89
98
|
}
|
|
90
99
|
|
|
100
|
+
/** The IdP screens {@link BuildOAuthAuthorizeUrlParams.screen} can open. */
|
|
101
|
+
export type OxyAuthScreen = 'signin' | 'signup' | 'recover';
|
|
102
|
+
|
|
91
103
|
/**
|
|
92
104
|
* Cryptographically-secure random bytes, cross-platform.
|
|
93
105
|
*
|
|
@@ -215,6 +227,9 @@ export function buildOAuthAuthorizeUrl(params: BuildOAuthAuthorizeUrlParams): st
|
|
|
215
227
|
if (params.responseMode) {
|
|
216
228
|
url.searchParams.set('response_mode', params.responseMode);
|
|
217
229
|
}
|
|
230
|
+
if (params.screen) {
|
|
231
|
+
url.searchParams.set('screen', params.screen);
|
|
232
|
+
}
|
|
218
233
|
|
|
219
234
|
return url.toString();
|
|
220
235
|
}
|