@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
|
@@ -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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
*
|
|
682
|
-
*
|
|
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
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
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
|
|
773
|
-
*
|
|
774
|
-
*
|
|
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`
|
|
786
|
-
*
|
|
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
|
-
//
|
|
831
|
-
//
|
|
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 +
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
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 =
|
|
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. */
|