@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.
Files changed (92) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/webIdentityCarrier.js +1 -1
  3. package/dist/cjs/i18n/locales/ar-SA.json +33 -88
  4. package/dist/cjs/i18n/locales/ca-ES.json +35 -90
  5. package/dist/cjs/i18n/locales/de-DE.json +33 -88
  6. package/dist/cjs/i18n/locales/en-US.json +45 -141
  7. package/dist/cjs/i18n/locales/es-ES.json +44 -140
  8. package/dist/cjs/i18n/locales/fr-FR.json +33 -88
  9. package/dist/cjs/i18n/locales/it-IT.json +33 -88
  10. package/dist/cjs/i18n/locales/ja-JP.json +33 -88
  11. package/dist/cjs/i18n/locales/ko-KR.json +33 -88
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +33 -88
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +35 -90
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +33 -88
  15. package/dist/cjs/i18n/locales/locales/en-US.json +45 -141
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +44 -140
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +33 -88
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +33 -88
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +33 -88
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +33 -88
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +33 -88
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +34 -89
  23. package/dist/cjs/i18n/locales/pt-PT.json +33 -88
  24. package/dist/cjs/i18n/locales/zh-CN.json +34 -89
  25. package/dist/cjs/index.js +10 -7
  26. package/dist/cjs/mixins/OxyServices.utility.js +140 -0
  27. package/dist/cjs/server/securityHeaders.js +19 -1
  28. package/dist/cjs/session/accountDialogController.js +37 -142
  29. package/dist/cjs/utils/authWebUrl.js +7 -6
  30. package/dist/cjs/utils/oauthPkce.js +5 -1
  31. package/dist/esm/.tsbuildinfo +1 -1
  32. package/dist/esm/crypto/webIdentityCarrier.js +1 -1
  33. package/dist/esm/i18n/locales/ar-SA.json +33 -88
  34. package/dist/esm/i18n/locales/ca-ES.json +35 -90
  35. package/dist/esm/i18n/locales/de-DE.json +33 -88
  36. package/dist/esm/i18n/locales/en-US.json +45 -141
  37. package/dist/esm/i18n/locales/es-ES.json +44 -140
  38. package/dist/esm/i18n/locales/fr-FR.json +33 -88
  39. package/dist/esm/i18n/locales/it-IT.json +33 -88
  40. package/dist/esm/i18n/locales/ja-JP.json +33 -88
  41. package/dist/esm/i18n/locales/ko-KR.json +33 -88
  42. package/dist/esm/i18n/locales/locales/ar-SA.json +33 -88
  43. package/dist/esm/i18n/locales/locales/ca-ES.json +35 -90
  44. package/dist/esm/i18n/locales/locales/de-DE.json +33 -88
  45. package/dist/esm/i18n/locales/locales/en-US.json +45 -141
  46. package/dist/esm/i18n/locales/locales/es-ES.json +44 -140
  47. package/dist/esm/i18n/locales/locales/fr-FR.json +33 -88
  48. package/dist/esm/i18n/locales/locales/it-IT.json +33 -88
  49. package/dist/esm/i18n/locales/locales/ja-JP.json +33 -88
  50. package/dist/esm/i18n/locales/locales/ko-KR.json +33 -88
  51. package/dist/esm/i18n/locales/locales/pt-PT.json +33 -88
  52. package/dist/esm/i18n/locales/locales/zh-CN.json +34 -89
  53. package/dist/esm/i18n/locales/pt-PT.json +33 -88
  54. package/dist/esm/i18n/locales/zh-CN.json +34 -89
  55. package/dist/esm/index.js +2 -1
  56. package/dist/esm/mixins/OxyServices.utility.js +138 -0
  57. package/dist/esm/server/securityHeaders.js +19 -1
  58. package/dist/esm/session/accountDialogController.js +37 -142
  59. package/dist/esm/utils/authWebUrl.js +6 -5
  60. package/dist/esm/utils/oauthPkce.js +5 -1
  61. package/dist/types/.tsbuildinfo +1 -1
  62. package/dist/types/crypto/webIdentityCarrier.d.ts +1 -1
  63. package/dist/types/index.d.ts +5 -3
  64. package/dist/types/mixins/OxyServices.utility.d.ts +84 -1
  65. package/dist/types/server/requesterAssertion.d.ts +2 -2
  66. package/dist/types/server/securityHeaders.d.ts +9 -12
  67. package/dist/types/session/accountDialogController.d.ts +29 -98
  68. package/dist/types/utils/authWebUrl.d.ts +6 -5
  69. package/dist/types/utils/oauthPkce.d.ts +10 -0
  70. package/package.json +2 -2
  71. package/src/crypto/webIdentityCarrier.ts +1 -1
  72. package/src/i18n/locales/ar-SA.json +33 -88
  73. package/src/i18n/locales/ca-ES.json +35 -90
  74. package/src/i18n/locales/de-DE.json +33 -88
  75. package/src/i18n/locales/en-US.json +45 -141
  76. package/src/i18n/locales/es-ES.json +44 -140
  77. package/src/i18n/locales/fr-FR.json +33 -88
  78. package/src/i18n/locales/it-IT.json +33 -88
  79. package/src/i18n/locales/ja-JP.json +33 -88
  80. package/src/i18n/locales/ko-KR.json +33 -88
  81. package/src/i18n/locales/pt-PT.json +33 -88
  82. package/src/i18n/locales/zh-CN.json +34 -89
  83. package/src/index.ts +9 -3
  84. package/src/mixins/OxyServices.utility.ts +208 -0
  85. package/src/mixins/__tests__/accountEvents.test.ts +158 -0
  86. package/src/server/__tests__/securityHeaders.test.ts +17 -0
  87. package/src/server/securityHeaders.ts +29 -1
  88. package/src/session/__tests__/accountDialogController.test.ts +42 -184
  89. package/src/session/accountDialogController.ts +50 -185
  90. package/src/utils/__tests__/oauthPkce.test.ts +7 -0
  91. package/src/utils/authWebUrl.ts +6 -5
  92. package/src/utils/oauthPkce.ts +16 -1
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OxyAccountEventError = exports.OXY_ACCOUNT_DELETED_EVENT_URI = void 0;
3
4
  exports.OxyServicesUtilityMixin = OxyServicesUtilityMixin;
4
5
  /**
5
6
  * Utility Methods Mixin
@@ -10,6 +11,16 @@ const jwt_decode_1 = require("jwt-decode");
10
11
  const protocol_1 = require("@oxy.so/protocol");
11
12
  const logger_1 = require("../logger");
12
13
  const oxyServiceEnvironment_1 = require("../utils/oxyServiceEnvironment");
14
+ /** The SET `events` member Oxy uses for an account deletion. */
15
+ exports.OXY_ACCOUNT_DELETED_EVENT_URI = 'https://oxy.so/events/account.deleted';
16
+ /** Why an account event token was refused. `message` never contains the token. */
17
+ class OxyAccountEventError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'OxyAccountEventError';
21
+ }
22
+ }
23
+ exports.OxyAccountEventError = OxyAccountEventError;
13
24
  /**
14
25
  * Expected JWT audience for tokens issued by the Oxy auth service.
15
26
  */
@@ -136,6 +147,135 @@ function OxyServicesUtilityMixin(Base) {
136
147
  return null;
137
148
  }
138
149
  }
150
+ /**
151
+ * Verify an account event token from Oxy — the body of an account-event
152
+ * webhook, or an entry's `token` from `listAccountEvents` — and return the
153
+ * event. Throws {@link OxyAccountEventError} unless the token is an
154
+ * EdDSA-signed `secevent+jwt` from Oxy's published key set, issued by
155
+ * `oxy-auth`, addressed to this application, carrying exactly one known
156
+ * event. A service token (`typ: JWT`) is refused here, as this token is
157
+ * refused as a service token.
158
+ *
159
+ * Node only (it needs `node:crypto`). The caller must still be idempotent on
160
+ * `eventId`: Oxy delivers at least once, by push and by pull.
161
+ */
162
+ async verifyAccountEvent(token, options = {}) {
163
+ if (typeof token !== 'string' || token.length === 0 || token.length > 16 * 1024) {
164
+ throw new OxyAccountEventError('Account event token is missing or oversized');
165
+ }
166
+ const parts = token.split('.');
167
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
168
+ throw new OxyAccountEventError('Account event token is not a compact JWS');
169
+ }
170
+ const [headerB64, payloadB64, signatureB64] = parts;
171
+ let header;
172
+ let payload;
173
+ try {
174
+ header = parseJsonSegment(headerB64);
175
+ payload = parseJsonSegment(payloadB64);
176
+ }
177
+ catch {
178
+ throw new OxyAccountEventError('Account event token is malformed');
179
+ }
180
+ if (Object.keys(header).length !== 3
181
+ || header.alg !== 'EdDSA'
182
+ || header.typ !== 'secevent+jwt'
183
+ || typeof header.kid !== 'string'
184
+ || !/^[A-Za-z0-9._-]{1,128}$/.test(header.kid)) {
185
+ throw new OxyAccountEventError('Account event token header is not supported');
186
+ }
187
+ const nodeCrypto = await (0, protocol_1.loadNodeCrypto)();
188
+ let jwk;
189
+ try {
190
+ jwk = await resolveServiceTokenPublicKey(header.kid, options.jwksUrl ?? new URL('/.well-known/jwks.json', this.getBaseURL()).toString(), this._serviceTokenJwksCache);
191
+ }
192
+ catch (error) {
193
+ throw new OxyAccountEventError(error instanceof Error ? error.message.replace('Service token', 'Account event') : 'Signing key is unavailable');
194
+ }
195
+ const signature = Buffer.from(signatureB64, 'base64url');
196
+ if (signature.toString('base64url') !== signatureB64 || signature.length !== 64) {
197
+ throw new OxyAccountEventError('Account event token signature is malformed');
198
+ }
199
+ let verified = false;
200
+ try {
201
+ const publicKey = nodeCrypto.createPublicKey({ key: jwk, format: 'jwk' });
202
+ verified = nodeCrypto.verify(null, Buffer.from(`${headerB64}.${payloadB64}`), publicKey, signature);
203
+ }
204
+ catch {
205
+ verified = false;
206
+ }
207
+ if (!verified)
208
+ throw new OxyAccountEventError('Account event token signature is invalid');
209
+ if (payload.iss !== OXY_JWT_ISSUER) {
210
+ throw new OxyAccountEventError('Account event token issuer is not Oxy');
211
+ }
212
+ const audience = options.audience ?? await this._configuredServiceAppId();
213
+ if (typeof payload.aud !== 'string' || payload.aud !== audience) {
214
+ throw new OxyAccountEventError('Account event token is addressed to another application');
215
+ }
216
+ if (typeof payload.jti !== 'string' || payload.jti.length === 0 || payload.jti.length > 128) {
217
+ throw new OxyAccountEventError('Account event token has no event id');
218
+ }
219
+ if (typeof payload.iat !== 'number' || !Number.isFinite(payload.iat)) {
220
+ throw new OxyAccountEventError('Account event token has no issue time');
221
+ }
222
+ const events = payload.events;
223
+ if (typeof events !== 'object' || events === null || Array.isArray(events)) {
224
+ throw new OxyAccountEventError('Account event token carries no events');
225
+ }
226
+ const entries = Object.entries(events);
227
+ const deleted = events[exports.OXY_ACCOUNT_DELETED_EVENT_URI];
228
+ if (entries.length !== 1 || typeof deleted !== 'object' || deleted === null || Array.isArray(deleted)) {
229
+ throw new OxyAccountEventError('Account event token carries an unknown event');
230
+ }
231
+ const body = deleted;
232
+ if (typeof body.userId !== 'string' || body.userId.length === 0 || body.userId.length > 128) {
233
+ throw new OxyAccountEventError('Account event names no account');
234
+ }
235
+ if (typeof body.occurredAt !== 'string' || Number.isNaN(Date.parse(body.occurredAt))) {
236
+ throw new OxyAccountEventError('Account event has no occurrence time');
237
+ }
238
+ if (body.username !== undefined && body.username !== null && typeof body.username !== 'string') {
239
+ throw new OxyAccountEventError('Account event username is malformed');
240
+ }
241
+ return {
242
+ eventId: payload.jti,
243
+ type: 'account.deleted',
244
+ userId: body.userId,
245
+ username: typeof body.username === 'string' ? body.username : null,
246
+ occurredAt: body.occurredAt,
247
+ retained: body.retained === true,
248
+ applicationId: payload.aud,
249
+ issuedAt: payload.iat,
250
+ };
251
+ }
252
+ /**
253
+ * One page of account events addressed to this application, oldest first,
254
+ * from Oxy's pull feed — the reconciliation path behind the webhook. Needs
255
+ * the service credential (`configureServiceAuth`). Verify each entry's
256
+ * `token` with {@link verifyAccountEvent} before acting on it.
257
+ */
258
+ async listAccountEvents(options = {}) {
259
+ const serviceToken = await this.getServiceToken();
260
+ const query = {};
261
+ if (options.after)
262
+ query.after = options.after;
263
+ if (options.limit !== undefined)
264
+ query.limit = String(options.limit);
265
+ return this.makeRequest('GET', '/account-events', query, {
266
+ cache: false,
267
+ headers: { Authorization: `Bearer ${serviceToken}` },
268
+ });
269
+ }
270
+ /** @internal The `appId` claim of this client's own service token. */
271
+ async _configuredServiceAppId() {
272
+ const serviceToken = await this.getServiceToken();
273
+ const appId = (0, jwt_decode_1.jwtDecode)(serviceToken).appId;
274
+ if (typeof appId !== 'string' || appId.length === 0) {
275
+ throw new OxyAccountEventError('No audience given and the service credential names no application');
276
+ }
277
+ return appId;
278
+ }
139
279
  /**
140
280
  * Express.js authentication middleware
141
281
  *
@@ -326,6 +326,20 @@ const MAX_INLINE_SCRIPT_HASHES = 8;
326
326
  * react-native-web's runtime stylesheet, and a style hash would silently switch
327
327
  * that off and render every Oxy web app unstyled.)
328
328
  */
329
+ /** Passkeys for this origin itself; every other powerful feature off. */
330
+ const SENSITIVE_PERMISSIONS_POLICY = [
331
+ 'publickey-credentials-get=(self)',
332
+ 'publickey-credentials-create=(self)',
333
+ 'camera=()',
334
+ 'microphone=()',
335
+ 'geolocation=()',
336
+ 'payment=()',
337
+ 'usb=()',
338
+ 'serial=()',
339
+ 'hid=()',
340
+ 'bluetooth=()',
341
+ 'display-capture=()',
342
+ ].join(', ');
329
343
  function buildOxyPagesHeaders(options = {}) {
330
344
  const hashes = [
331
345
  ...new Set((options.html ?? []).flatMap(extractInlineScripts).map(inlineScriptCspHash)),
@@ -341,6 +355,8 @@ function buildOxyPagesHeaders(options = {}) {
341
355
  for (const name of ['script-src', 'connect-src']) {
342
356
  directives[name] = (directives[name] ?? []).filter((source) => !beacon.has(source));
343
357
  }
358
+ directives['base-uri'] = ["'none'"];
359
+ directives['form-action'] = ["'none'"];
344
360
  }
345
361
  const csp = formatOxyCspPolicy(directives);
346
362
  const lines = [
@@ -348,13 +364,15 @@ function buildOxyPagesHeaders(options = {}) {
348
364
  ` Content-Security-Policy: ${csp}`,
349
365
  ' X-Frame-Options: DENY',
350
366
  ' X-Content-Type-Options: nosniff',
351
- ' Referrer-Policy: strict-origin-when-cross-origin',
367
+ ` Referrer-Policy: ${options.sensitive ? 'no-referrer' : 'strict-origin-when-cross-origin'}`,
352
368
  ];
353
369
  if (options.hsts !== false) {
354
370
  lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
355
371
  }
356
372
  if (options.sensitive) {
357
373
  lines.push(' Cache-Control: public, max-age=0, must-revalidate, no-transform');
374
+ lines.push(` Permissions-Policy: ${SENSITIVE_PERMISSIONS_POLICY}`);
375
+ lines.push(' Cross-Origin-Resource-Policy: same-origin');
358
376
  }
359
377
  lines.push('');
360
378
  return lines.join('\n');
@@ -37,12 +37,6 @@
37
37
  * - `commonsAvailability` — whether Commons is installed on this device
38
38
  * (native only, via the injected `canOpenApp` probe), so the QR view can
39
39
  * offer a "Get Commons" fallback instead of a same-device dead end.
40
- * - `startPasskeyHubSignIn` — on a non-Oxy web origin (where a WebAuthn
41
- * ceremony can't run locally, the RP-ID is bound to oxy.so), open a popup
42
- * at the auth.oxy.so passkey hub instead, scoped to the SAME device-flow
43
- * session `showQr` creates, and complete it via the SAME poll/socket/claim
44
- * engine. Falls back to `showQr`'s plain QR rendering if the popup is
45
- * blocked.
46
40
  *
47
41
  * Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff —
48
42
  * password, social login, and 2FA were removed ecosystem-wide. Account
@@ -62,7 +56,6 @@ exports.backViewOf = backViewOf;
62
56
  exports.createAccountDialogController = createAccountDialogController;
63
57
  const logger_1 = require("../logger");
64
58
  const errorUtils_1 = require("../utils/errorUtils");
65
- const authWebUrl_1 = require("../utils/authWebUrl");
66
59
  const socketLoader_1 = require("./socketLoader");
67
60
  const deviceDirectory_1 = require("./deviceDirectory");
68
61
  const commonsDelivery_1 = require("../utils/commonsDelivery");
@@ -130,8 +123,8 @@ const IDLE_SIGN_IN_FACTS = {
130
123
  */
131
124
  const COMPLETED_SIGN_IN_FACTS = { ...IDLE_SIGN_IN_FACTS, phase: 'completed' };
132
125
  /** The full flow state for `facts`, stamped with the attempt it belongs to. */
133
- function buildSignIn(facts, attempt) {
134
- return { ...facts, progress: deriveSignInProgress(facts), attempt };
126
+ function buildSignIn(facts, attempt, inline) {
127
+ return { ...facts, progress: deriveSignInProgress(facts), attempt, inline };
135
128
  }
136
129
  function errorMessage(error) {
137
130
  return error instanceof Error ? error.message : String(error);
@@ -163,7 +156,7 @@ class AccountDialogController {
163
156
  * alongside the three flags above.
164
157
  */
165
158
  this.exclusiveMutationInFlight = false;
166
- this.signIn = buildSignIn(IDLE_SIGN_IN_FACTS, 0);
159
+ this.signIn = buildSignIn(IDLE_SIGN_IN_FACTS, 0, false);
167
160
  this.commonsAvailability = 'unknown';
168
161
  // --- Sign-in device-flow bookkeeping ---
169
162
  /**
@@ -192,10 +185,6 @@ class AccountDialogController {
192
185
  * ABANDONED request never blocks the next request's first poll.
193
186
  */
194
187
  this.pollInFlightToken = null;
195
- /** The popup opened by {@link startPasskeyHubSignIn}, or `null`. */
196
- this.activePopup = null;
197
- /** Watches {@link activePopup}'s `closed` state; see {@link watchPopup}. */
198
- this.popupWatchTimer = null;
199
188
  // --- Store plumbing ---
200
189
  this.unsubscribeSession = null;
201
190
  this.unsubscribeTokens = null;
@@ -212,8 +201,6 @@ class AccountDialogController {
212
201
  this.openUrl = options.openUrl;
213
202
  this.canOpenApp = options.canOpenApp;
214
203
  this.socketFactory = options.socketFactory;
215
- this.openPopup = options.openPopup;
216
- this.identityOrigin = options.identityOrigin ?? options.hubBaseUrl ?? authWebUrl_1.IDENTITY_WEB_ORIGIN;
217
204
  this.platform = options.platform ?? 'unknown';
218
205
  this.snapshot = this.computeSnapshot();
219
206
  }
@@ -291,7 +278,6 @@ class AccountDialogController {
291
278
  }
292
279
  this.clearPollTimer();
293
280
  this.closeAuthSessionSocket();
294
- this.closeActivePopup();
295
281
  this.signInToken = null;
296
282
  this.listeners.clear();
297
283
  }
@@ -356,12 +342,12 @@ class AccountDialogController {
356
342
  if (this.view === view)
357
343
  return;
358
344
  this.view = view;
359
- // A `'completed'` flow owns no timers, socket, popup, or token — it is only
345
+ // A `'completed'` flow owns no timers, socket, or token — it is only
360
346
  // the terminal "Identity confirmed" the finished surface showed. Moving to
361
347
  // another view is a NEW intention, so drop it; otherwise a later `add()`
362
348
  // would open on the previous sign-in's terminal state.
363
349
  if (this.signIn.phase === 'completed') {
364
- this.signIn = buildSignIn(IDLE_SIGN_IN_FACTS, this.signInAttempt);
350
+ this.signIn = this.stampSignIn(IDLE_SIGN_IN_FACTS);
365
351
  }
366
352
  this.emit();
367
353
  }
@@ -678,82 +664,33 @@ class AccountDialogController {
678
664
  await this.startDeviceFlowSession(attempt, { deliver: true });
679
665
  }
680
666
  /**
681
- * Start a NEW attempt the same way the last one was started — "Try again"
682
- * repeats the user's choice rather than silently switching method. A failed
683
- * passkey-hub attempt reopens the hub popup; a failed "Sign in with Oxy"
684
- * retries that; an explicit QR request shows the QR.
667
+ * The sign-in entry's EMBEDDED QR: a request whose only route is the QR,
668
+ * started without leaving the current view.
685
669
  *
686
- * Call it straight from the press handler: the hub route opens its popup
687
- * synchronously, before this method's first `await`, so the browser still
688
- * attributes it to the click.
670
+ * Unlike {@link showQr} it runs no delivery selection. The surface starts it
671
+ * by itself, on mount, so it must not push to the person's phone or open
672
+ * Commons — nobody asked for either. Any other sign-in the person then
673
+ * chooses supersedes it (and withdraws its request) like any new attempt.
674
+ */
675
+ async startInlineQr() {
676
+ const attempt = this.beginSignInAttempt('inline-qr');
677
+ await this.startDeviceFlowSession(attempt, { deliver: false });
678
+ }
679
+ /**
680
+ * Start a NEW attempt the same way the last one was started — "Try again"
681
+ * repeats the user's choice rather than silently switching method: a failed
682
+ * "Sign in with Oxy" retries that; an explicit QR request shows the QR.
689
683
  */
690
684
  retrySignIn() {
691
685
  switch (this.signInMethod) {
692
- case 'passkey-hub':
693
- return this.startPasskeyHubSignIn();
694
686
  case 'qr':
695
687
  return this.showQr();
688
+ case 'inline-qr':
689
+ return this.startInlineQr();
696
690
  default:
697
691
  return this.signInWithOxy();
698
692
  }
699
693
  }
700
- /**
701
- * Web-only: sign in (or create an account) with a passkey at the web identity
702
- * carrier. The ceremony runs on `id.oxy.so`, never on the calling origin, for
703
- * two reasons: a credential minted with `WEBAUTHN_RP_ID=oxy.so` can only be
704
- * asserted from `oxy.so`/a subdomain/loopback (a browser-enforced boundary),
705
- * and only the identity origin may unseal or create the account's identity
706
- * (one identity, two carriers). Opens a popup at `<identityOrigin>/continue`,
707
- * scoped to the SAME device-flow
708
- * session {@link showQr} would create (same `authorizeCode`/`sessionToken`
709
- * pair), and let the SAME poll/socket/claim engine complete it once the hub
710
- * authorizes the session (`POST /auth/session/authorize-code/:authorizeCode`,
711
- * bearer-authed, server-side — this method never sees or transmits the
712
- * secret `sessionToken`).
713
- *
714
- * The popup MUST be opened synchronously, before any `await`, or the
715
- * browser's popup blocker loses the click-gesture attribution and silently
716
- * blocks it — so {@link openPopup} is invoked as the very first step, before
717
- * the device-flow session (which needs an async call) even exists. If the
718
- * popup is blocked (or no `openPopup` was injected), this falls back to
719
- * `showQr`'s plain QR rendering — no dead end.
720
- */
721
- async startPasskeyHubSignIn() {
722
- const attempt = this.beginSignInAttempt('passkey-hub');
723
- this.setView('qr');
724
- const popup = this.openPopup?.() ?? null;
725
- if (!popup) {
726
- await this.startDeviceFlowSession(attempt, { deliver: true });
727
- return;
728
- }
729
- // Owned by the attempt from the moment it exists, so cancelling while the
730
- // session is still being created closes it instead of leaving a blank
731
- // window behind.
732
- this.activePopup = popup;
733
- // The hub popup IS the primary surface here, chosen explicitly by the user —
734
- // so this flow does NOT run automatic Commons delivery (ringing the user's
735
- // phone because they asked for a passkey would be exactly the "menu of
736
- // methods" the one-primary-action rule forbids). The underlying request is
737
- // the same `AuthSession`, and its Commons route stays the QR the view
738
- // renders beneath the popup.
739
- const handle = await this.startDeviceFlowSession(attempt, { deliver: false });
740
- // `null` means the attempt failed (its teardown closed the popup) or was
741
- // abandoned (the cancel closed it).
742
- if (!handle)
743
- return;
744
- // The user may have closed the popup during the async session creation
745
- // above, before any watcher was attached to catch it — guard rather than
746
- // navigate a dead window (browsers vary on whether that throws).
747
- if (popup.closed) {
748
- const pendingCode = this.signIn.authorizeCode;
749
- this.failSignIn('cancelled', 'Sign-in was cancelled.');
750
- if (pendingCode)
751
- void this.withdrawRequest(pendingCode);
752
- return;
753
- }
754
- popup.location.href = `${this.identityOrigin}/continue?code=${encodeURIComponent(handle.authorizeCode)}`;
755
- this.watchPopup(popup);
756
- }
757
694
  /**
758
695
  * End whatever attempt is running (withdrawing its request) and begin a new
759
696
  * one. Returns the new attempt's identity for the caller to re-check after
@@ -769,9 +706,9 @@ class AccountDialogController {
769
706
  return attempt === this.signInAttempt;
770
707
  }
771
708
  /**
772
- * Shared device-flow session creation for both {@link showQr} (renders the
773
- * QR) and {@link startPasskeyHubSignIn} (also opens the hub popup) — the
774
- * same `startCommonsSignIn` → poll/socket wiring either way. Returns the
709
+ * Shared device-flow session creation for {@link signInWithOxy},
710
+ * {@link showQr} and {@link startInlineQr} — the same `startCommonsSignIn` →
711
+ * poll/socket wiring every time. Returns the
775
712
  * handle on success (already reflected in `signIn`), or `null` when the
776
713
  * attempt failed (already set as `signIn.failure`) or was abandoned while the
777
714
  * request was being created.
@@ -782,8 +719,8 @@ class AccountDialogController {
782
719
  * request, a socket, and a poll behind it.
783
720
  * @param opts.deliver - Whether to run automatic Commons delivery selection
784
721
  * ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
785
- * entry; `false` when the caller already owns the primary surface (the
786
- * passkey hub popup), where the request's Commons route is simply the QR.
722
+ * entry; `false` for the embedded QR, which the screen starts by itself,
723
+ * where the request's Commons route is simply the QR.
787
724
  */
788
725
  async startDeviceFlowSession(attempt, opts) {
789
726
  if (!this.isCurrentAttempt(attempt))
@@ -826,9 +763,9 @@ class AccountDialogController {
826
763
  void this.openAuthSessionSocket(handle.sessionToken);
827
764
  this.scheduleNextPoll(handle.sessionToken);
828
765
  if (opts.deliver) {
829
- // Non-blocking on purpose: the QR/authorizeCode are already renderable
830
- // and the popup caller can navigate immediately, while the route (a
831
- // local probe plus at most one delivery round-trip) resolves behind it.
766
+ // Non-blocking on purpose: the QR/authorizeCode are already renderable,
767
+ // while the route (a local probe plus at most one delivery round-trip)
768
+ // resolves behind it.
832
769
  void this.resolveDeliveryRoute(handle);
833
770
  }
834
771
  return handle;
@@ -932,49 +869,6 @@ class AccountDialogController {
932
869
  return false;
933
870
  }
934
871
  }
935
- /**
936
- * Poll {@link PopupWindowHandle.closed} so a user who dismisses the hub
937
- * popup without completing sign-in gets prompt feedback (there is no DOM
938
- * event for a cross-origin popup closing — polling is the only mechanism).
939
- * Cleared by {@link closeActivePopup}, which every sign-in exit path already
940
- * calls.
941
- */
942
- watchPopup(popup) {
943
- this.clearPopupWatchTimer();
944
- this.popupWatchTimer = setInterval(() => {
945
- if (!popup.closed)
946
- return;
947
- this.clearPopupWatchTimer();
948
- if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
949
- // Closing the surface cancels the REQUEST too, not just this listener.
950
- const pendingCode = this.signIn.authorizeCode;
951
- this.failSignIn('cancelled', 'Sign-in was cancelled.');
952
- if (pendingCode) {
953
- void this.withdrawRequest(pendingCode);
954
- }
955
- }
956
- }, 1000);
957
- }
958
- clearPopupWatchTimer() {
959
- if (this.popupWatchTimer !== null) {
960
- clearInterval(this.popupWatchTimer);
961
- this.popupWatchTimer = null;
962
- }
963
- }
964
- /** Tear down the popup watch timer and close the popup, if any. Idempotent. */
965
- closeActivePopup() {
966
- this.clearPopupWatchTimer();
967
- const popup = this.activePopup;
968
- this.activePopup = null;
969
- if (!popup || popup.closed)
970
- return;
971
- try {
972
- popup.close();
973
- }
974
- catch (error) {
975
- logger_1.logger.debug('[AccountDialogController] popup close failed', { component: 'AccountDialogController' }, error);
976
- }
977
- }
978
872
  /**
979
873
  * Resolve whether Commons is installed on this device via the injected
980
874
  * `canOpenApp` probe, updating {@link commonsAvailability} as durable,
@@ -1004,7 +898,7 @@ class AccountDialogController {
1004
898
  this.emit();
1005
899
  }
1006
900
  /**
1007
- * Tear down the active sign-in device flow (timers + socket + popup + token),
901
+ * Tear down the active sign-in device flow (timers + socket + token),
1008
902
  * WITHDRAW the request server-side, and reset to idle.
1009
903
  *
1010
904
  * Cancellation has to converge in both directions: the surface closing must
@@ -1027,7 +921,6 @@ class AccountDialogController {
1027
921
  : null;
1028
922
  this.clearPollTimer();
1029
923
  this.closeAuthSessionSocket();
1030
- this.closeActivePopup();
1031
924
  this.signInToken = null;
1032
925
  if (this.signIn.phase !== 'idle') {
1033
926
  this.setSignIn(IDLE_SIGN_IN_FACTS);
@@ -1226,10 +1119,9 @@ class AccountDialogController {
1226
1119
  this.signInToken = null;
1227
1120
  this.clearPollTimer();
1228
1121
  this.closeAuthSessionSocket();
1229
- this.closeActivePopup();
1230
1122
  // Terminal SUCCESS, not idle: the surface gets one honest frame to show
1231
1123
  // "Identity confirmed" before it closes. Cleared on the next view change.
1232
- this.signIn = buildSignIn(COMPLETED_SIGN_IN_FACTS, this.signInAttempt);
1124
+ this.signIn = this.stampSignIn(COMPLETED_SIGN_IN_FACTS);
1233
1125
  this.view = 'accounts';
1234
1126
  this.emit();
1235
1127
  }
@@ -1256,7 +1148,6 @@ class AccountDialogController {
1256
1148
  failSignIn(failure, message) {
1257
1149
  this.clearPollTimer();
1258
1150
  this.closeAuthSessionSocket();
1259
- this.closeActivePopup();
1260
1151
  this.signInToken = null;
1261
1152
  this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message, failure });
1262
1153
  }
@@ -1348,9 +1239,13 @@ class AccountDialogController {
1348
1239
  * to advance without a fact behind it.
1349
1240
  */
1350
1241
  setSignIn(facts) {
1351
- this.signIn = buildSignIn(facts, this.signInAttempt);
1242
+ this.signIn = this.stampSignIn(facts);
1352
1243
  this.emit();
1353
1244
  }
1245
+ /** `facts` as the current attempt's state. */
1246
+ stampSignIn(facts) {
1247
+ return buildSignIn(facts, this.signInAttempt, this.signInMethod === 'inline-qr');
1248
+ }
1354
1249
  /** Update a subset of the device-flow facts, re-deriving `progress`. */
1355
1250
  patchSignIn(patch) {
1356
1251
  const { phase, authorizeCode, qrPayload, expiresAt, error, failure, route, routeFailed, pushSentAt, openedAt, } = this.signIn;
@@ -10,7 +10,7 @@
10
10
  * past the SSO/FedCM teardown.
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.IDENTITY_WEB_ORIGIN = exports.CENTRAL_IDP_APEX = void 0;
13
+ exports.AUTH_WEB_ORIGIN = exports.CENTRAL_IDP_APEX = void 0;
14
14
  /**
15
15
  * The registrable apex (eTLD+1) of the Oxy ecosystem's central Identity
16
16
  * Provider, reachable at `auth.${CENTRAL_IDP_APEX}`. Single source of truth so
@@ -18,9 +18,10 @@ exports.IDENTITY_WEB_ORIGIN = exports.CENTRAL_IDP_APEX = void 0;
18
18
  */
19
19
  exports.CENTRAL_IDP_APEX = 'oxy.so';
20
20
  /**
21
- * The web identity carrier's origin — where a person signs in or creates an
22
- * account with a passkey, and where their self-custody identity is kept sealed
23
- * under that passkey (one identity, two carriers). Apps open it; they never
24
- * handle the identity themselves.
21
+ * The IdP's origin, `auth.oxy.so` — the OAuth authorize/consent surface AND the
22
+ * web identity carrier: where a person signs in or creates an account with a
23
+ * passkey from another origin's popup, and the one origin that may create,
24
+ * open, recover or move their self-custody identity (one identity, two
25
+ * carriers). Apps open it; they never handle the identity themselves.
25
26
  */
26
- exports.IDENTITY_WEB_ORIGIN = `https://id.${exports.CENTRAL_IDP_APEX}`;
27
+ exports.AUTH_WEB_ORIGIN = `https://auth.${exports.CENTRAL_IDP_APEX}`;
@@ -29,10 +29,11 @@ exports.consumeOAuthReturnPath = consumeOAuthReturnPath;
29
29
  exports.persistOAuthHandshake = persistOAuthHandshake;
30
30
  exports.readOAuthHandshake = readOAuthHandshake;
31
31
  exports.clearOAuthHandshake = clearOAuthHandshake;
32
+ const authWebUrl_1 = require("./authWebUrl");
32
33
  const protocol_1 = require("@oxy.so/protocol");
33
34
  const logger_1 = require("../logger");
34
35
  /** The central Oxy IdP authorization endpoint used by default. */
35
- exports.OXY_AUTHORIZE_URL = 'https://auth.oxy.so/authorize';
36
+ exports.OXY_AUTHORIZE_URL = `${authWebUrl_1.AUTH_WEB_ORIGIN}/authorize`;
36
37
  /** Default OAuth scope requested for a "Sign in with Oxy" third-party flow. */
37
38
  exports.DEFAULT_OAUTH_SCOPE = 'openid profile';
38
39
  /**
@@ -151,6 +152,9 @@ function buildOAuthAuthorizeUrl(params) {
151
152
  if (params.responseMode) {
152
153
  url.searchParams.set('response_mode', params.responseMode);
153
154
  }
155
+ if (params.screen) {
156
+ url.searchParams.set('screen', params.screen);
157
+ }
154
158
  return url.toString();
155
159
  }
156
160
  /** `sessionStorage` key for the OAuth CSRF `state` across an authorize redirect. */