@blamejs/blamejs-shop 0.4.75 → 0.4.76
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/CHANGELOG.md +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/customers.js +30 -0
- package/lib/storefront.js +150 -40
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.76 (2026-06-21) — **Account security hardening — revoking a passkey ends the account's other sessions, and registration no longer creates an account before the security key is enrolled.** Four hardenings on the customer account surface. Revoking a passkey now ends every other live session for the account — including pending magic-link sign-in tokens — while keeping the device that performed the revoke signed in; previously the credential was deleted but a sealed session cookie a lost or compromised authenticator had already established stayed valid for its full 14-day life. Signing out everywhere now also revokes any unredeemed magic-link token, so a link mailed before sign-out can no longer sign the account back in. Passkey registration no longer creates the customer record until the security-key ceremony actually completes: the pending sign-up is carried in the sealed challenge cookie and the account is created only on a verified finish, so an abandoned or scripted registration can no longer reserve an email address or leave empty accounts behind. And passkey sign-in performs the same credential lookup whether or not the address has an account, removing a response-time difference that could be used to test which emails are registered. No configuration changes; upgrade to pick them up. **Security:** *Revoking a passkey ends the account's other sessions and pending sign-in links* — Revoking a passkey is a response to a lost or compromised authenticator, so it now moves the account's server-side session-revocation boundary forward — invalidating every sealed auth cookie issued before the revoke, including one the compromised authenticator established — and revokes any pending customer-portal (magic-link) token. The browser that performed the revoke is re-issued a fresh cookie so it stays signed in; every other device is signed out on its next request. Previously only the credential row was deleted, leaving already-issued session cookies valid for their full 14-day life. · *Signing out everywhere also revokes pending magic-link tokens* — POST /account/logout already moved the session-revocation boundary forward to kill live sealed cookies; it now also revokes any unredeemed magic-link / customer-portal token for the account. An emailed sign-in link issued before the sign-out can no longer be redeemed to sign the account back in. · *Passkey registration creates the account only after the security key is enrolled* — The public passkey register-begin endpoint no longer writes a customer record before the WebAuthn ceremony completes. The pending sign-up (email + display name + challenge) is sealed into the tamper-proof challenge cookie, and the account is created only on a verified register-finish, where the duplicate-email check runs atomically to settle a begin/finish race. An abandoned or scripted register-begin can no longer reserve (squat) an email address or leave behind empty accounts. · *Passkey sign-in does not reveal whether an email is registered via timing* — Passkey login-begin now runs the same credential lookup on both paths — against the real account for a registered address, against a fixed non-matching id otherwise — so a registered address no longer takes measurably longer to respond. The challenge response shape was already identical on both paths; this closes the remaining response-time signal that could enumerate registered emails.
|
|
12
|
+
|
|
11
13
|
- v0.4.75 (2026-06-21) — **Bundled framework refreshed to blamejs 0.15.15 — more authentication checks fail closed and a blocked-traffic memory leak is fixed.** Refreshes the vendored framework the store is built on from blamejs 0.15.14 to 0.15.15, a correctness-and-hardening release. The protections the store already composes now refuse more malformed or unbound credentials, and a request-pipeline leak under sustained blocked traffic is fixed. The most directly felt change for an operator: when the bot guard, rate limiter, or CSRF check halts a request by writing its response without continuing the chain, the pipeline now settles instead of holding the request closure open forever — under a flood of blocked requests that previously grew memory without bound. On the sign-in surface, the passkey, OAuth, and any token verifiers the store routes through inherit stricter checks: an OAuth ID-token nonce is no longer skipped when empty, an authenticator's current certification status is honored rather than the highest it ever held, a JWT issuer presented as an array no longer satisfies a single-issuer expectation, and a malformed JOSE header raises a typed error instead of crashing the verifier. The content-security-policy builder now refuses a source value containing whitespace or a semicolon (so a malformed provider host can't smuggle a second directive), the SQL builder refuses an always-false equality against NULL and validates every element of an IN-list, and the WebSocket client bounds a fragmented message as it arrives rather than only at the final frame. No store configuration changes; upgrade to pick up the refreshed framework. **Changed:** *Bundled framework updated to blamejs 0.15.15* — The vendored framework is refreshed from 0.15.14 to 0.15.15. The store carries zero runtime npm dependencies and builds entirely on this bundled framework, so its correctness and hardening improvements apply to the store on upgrade with no configuration change. **Security:** *A blocked request no longer leaks its closure for the life of the process* — When the bot guard, rate limiter, CSRF check, or any middleware halts a request by writing the response without advancing the chain — the intended way to block — the request pipeline now settles its promise instead of leaving it pending forever. Previously each blocked request retained its request/response closure, so sustained blocked traffic grew memory without bound. The store's request lifecycle composes this pipeline directly, so the fix applies to every blocked request. · *Sign-in credential verifiers fail closed on more malformed inputs* — The authentication primitives the store routes sign-in through now refuse more unbound or malformed credentials: an OAuth ID-token nonce check is no longer skipped when the supplied nonce is empty, an authenticator's current certification status is honored over the highest level it ever held, a JWT issuer presented as an array no longer satisfies a single-issuer expectation, and a JOSE header or payload that decodes to a non-object raises a typed authentication error instead of an uncaught crash. · *The content-security-policy builder refuses a directive-injecting source* — A CSP source is a single non-whitespace token; the builder now rejects a source containing whitespace or a semicolon. Because sources are space-joined and directives semicolon-joined, a malformed value could otherwise have injected a second, live directive. The store's provider hosts (payment, captcha) are already clean single tokens, so this is defense against a future misconfiguration. · *The SQL builder refuses an always-false NULL equality and validates IN-list elements* — An equality against NULL (always false in SQL; the IS NULL form is required) is now refused rather than silently matching nothing, and every element of an IN-list is validated across all database backends. This narrows the room for a query that quietly returns the wrong rows. · *WebSocket fragmented-message reassembly is bounded as it arrives* — A fragmented incoming message is now bounded by its running total on each frame instead of only when the final frame arrives, so a peer can no longer stream unbounded continuation frames to exhaust memory before the size limit is checked, and a frame arriving after close is ignored.
|
|
12
14
|
|
|
13
15
|
- v0.4.74 (2026-06-21) — **Sign-in links are built from the configured origin, and signing out invalidates every device's session.** Two account-security hardenings. The passwordless sign-in (magic) link was assembled from the request's Host header, so a request that reached the app carrying a forged Host could produce a link pointing at another domain while still carrying the customer's single-use sign-in token. The link is now built from the operator-configured trusted origin — the same source the referral links, back-in-stock links, and the OAuth redirect already use — so the link always points at the real storefront regardless of the request Host. Separately, signing out cleared the session cookie only in the responding browser; because the sealed auth cookie is stateless for its full lifetime, a copy captured before sign-out stayed valid until it naturally expired. Explicit sign-out now moves the account's server-side session-revocation boundary forward, so every live session cookie for the account is invalidated at once. **Fixed:** *Magic-link sign-in URLs are built from the configured origin, not the request Host* — The passwordless sign-in link and the post-checkout account-claim link now derive their origin from the operator's configured trusted origin first, falling back to the request origin only when none is configured (single-host dev). A forged Host header can no longer redirect a credential-bearing sign-in link to another domain. Both email-link sends share one origin resolver, matching the referral, back-in-stock, and OAuth-redirect discipline. · *Signing out invalidates every live session, not just the current browser* — POST /account/logout now moves the customer's server-side session-revocation boundary forward before clearing the cookie, so every sealed auth cookie issued for the account — including a copy captured on another device or before sign-out — fails its next request and signs out. Previously the stateless cookie was only cleared in the responding browser and remained valid elsewhere for its full lifetime.
|
package/lib/asset-manifest.json
CHANGED
package/lib/customers.js
CHANGED
|
@@ -228,6 +228,36 @@ function create(opts) {
|
|
|
228
228
|
};
|
|
229
229
|
},
|
|
230
230
|
|
|
231
|
+
// Create an account AND enroll its first passkey as one logical unit.
|
|
232
|
+
// The passwordless register-finish flow defers account creation to a
|
|
233
|
+
// verified WebAuthn ceremony; creating the customer and the credential
|
|
234
|
+
// must not split, or a passkey-insert failure (transient write error, a
|
|
235
|
+
// duplicate credential id) would leave an empty, credential-less account
|
|
236
|
+
// behind — re-creating the very email-squat the deferred flow prevents.
|
|
237
|
+
// D1 has no interactive transaction, so this composes register +
|
|
238
|
+
// addPasskey and COMPENSATES by deleting the just-created row when the
|
|
239
|
+
// enroll fails. The new row has no related data yet (no passkey, oauth,
|
|
240
|
+
// cart, or order), so the delete is clean and frees the email again.
|
|
241
|
+
registerWithPasskey: async function (input) {
|
|
242
|
+
if (!input || typeof input !== "object") {
|
|
243
|
+
throw new TypeError("customers.registerWithPasskey: input object required");
|
|
244
|
+
}
|
|
245
|
+
if (!input.passkey || typeof input.passkey !== "object") {
|
|
246
|
+
throw new TypeError("customers.registerWithPasskey: passkey object required");
|
|
247
|
+
}
|
|
248
|
+
var customer = await api.register({ email: input.email, display_name: input.display_name });
|
|
249
|
+
var passkey;
|
|
250
|
+
try {
|
|
251
|
+
passkey = await api.addPasskey(customer.id, input.passkey);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
try {
|
|
254
|
+
await query("DELETE FROM customers WHERE id = ?1", [customer.id]);
|
|
255
|
+
} catch (_eDel) { /* best-effort compensation — rethrow the real cause below */ }
|
|
256
|
+
throw e;
|
|
257
|
+
}
|
|
258
|
+
return { customer: customer, passkey: passkey };
|
|
259
|
+
},
|
|
260
|
+
|
|
231
261
|
get: async function (id) {
|
|
232
262
|
_uuid(id, "customer id");
|
|
233
263
|
var r = await query("SELECT * FROM customers WHERE id = ?1", [id]);
|
package/lib/storefront.js
CHANGED
|
@@ -9682,6 +9682,14 @@ var _NOOP_BINDING_STORE = {
|
|
|
9682
9682
|
set: function () { return undefined; },
|
|
9683
9683
|
del: function () { return undefined; },
|
|
9684
9684
|
};
|
|
9685
|
+
// Fixed decoy customer id for passkey login-begin timing parity. A valid v4
|
|
9686
|
+
// UUID (passes guardUuid strict) whose 122 random bits are fixed to a value
|
|
9687
|
+
// that can never collide with a real randomly-generated customer id, so a
|
|
9688
|
+
// listPasskeys lookup against it always returns empty while costing the same
|
|
9689
|
+
// query a real account's lookup costs — closing the account-existence timing
|
|
9690
|
+
// oracle on /account/passkey/login-begin.
|
|
9691
|
+
var _LOGIN_ENUM_DECOY_ID = "5ec0de00-0000-4000-8000-deadbeef0000";
|
|
9692
|
+
|
|
9685
9693
|
var _deviceBinding = null;
|
|
9686
9694
|
function _deviceBindingInstance() {
|
|
9687
9695
|
if (!_deviceBinding) {
|
|
@@ -16215,9 +16223,10 @@ function mount(router, deps) {
|
|
|
16215
16223
|
// sign-in. An owner adding another passkey uses the session-gated
|
|
16216
16224
|
// /account/passkey/add-* flow; a passwordless owner — or one whose
|
|
16217
16225
|
// first ceremony was interrupted — signs in with the magic-link.
|
|
16218
|
-
|
|
16219
|
-
|
|
16220
|
-
);
|
|
16226
|
+
// Validate + hash the address (a malformed email throws TypeError →
|
|
16227
|
+
// 400 via the outer catch) and refuse any existing account up front.
|
|
16228
|
+
var regEmailHash = deps.customers.hashEmail(body.email);
|
|
16229
|
+
var existing = await deps.customers.byEmailHash(regEmailHash);
|
|
16221
16230
|
if (existing) {
|
|
16222
16231
|
res.status(409);
|
|
16223
16232
|
res.setHeader && res.setHeader("content-type", "application/json");
|
|
@@ -16227,31 +16236,31 @@ function mount(router, deps) {
|
|
|
16227
16236
|
});
|
|
16228
16237
|
return res.end ? res.end(dupMsg) : res.send(dupMsg);
|
|
16229
16238
|
}
|
|
16230
|
-
|
|
16231
|
-
|
|
16232
|
-
|
|
16233
|
-
|
|
16239
|
+
// SECURITY: do NOT persist a customer row here. Creating the account
|
|
16240
|
+
// before the WebAuthn ceremony completes let an abandoned begin — or a
|
|
16241
|
+
// scripted begin flood — squat an email (the 409 above then blocks the
|
|
16242
|
+
// real owner's registration) and mint junk customer rows. The pending
|
|
16243
|
+
// signup rides in the sealed, tamper-proof challenge cookie instead;
|
|
16244
|
+
// the row is created only on a verified register-finish, where
|
|
16245
|
+
// customers.register re-checks the duplicate atomically to close the
|
|
16246
|
+
// begin→finish race. userDisplayName is the cosmetic authenticator
|
|
16247
|
+
// label only — register() is the authoritative display_name validator.
|
|
16234
16248
|
var startOpts = await b.auth.passkey.startRegistration({
|
|
16235
16249
|
rpName: rpName,
|
|
16236
16250
|
rpId: rpId,
|
|
16237
|
-
userName:
|
|
16238
|
-
userDisplayName:
|
|
16251
|
+
userName: regEmailHash.slice(0, 16),
|
|
16252
|
+
userDisplayName: (typeof body.display_name === "string" ? body.display_name : ""),
|
|
16239
16253
|
attestationType: "none",
|
|
16240
16254
|
});
|
|
16241
|
-
// Seal the
|
|
16242
|
-
// shop_auth_chal cookie so register-finish verifies against
|
|
16243
|
-
// the
|
|
16255
|
+
// Seal the pending signup (challenge + email + display name) into the
|
|
16256
|
+
// shop_auth_chal cookie so register-finish verifies against the same
|
|
16257
|
+
// challenge and creates the account without server-side state.
|
|
16244
16258
|
_setChallengeCookie(res, {
|
|
16245
|
-
kind:
|
|
16246
|
-
|
|
16247
|
-
|
|
16248
|
-
|
|
16249
|
-
|
|
16250
|
-
// row for a known email). register-finish gates referral
|
|
16251
|
-
// attribution on it — an existing customer re-enrolling a passkey
|
|
16252
|
-
// through a referral link must not be attributed to a referrer
|
|
16253
|
-
// (it isn't a new signup), mirroring the OIDC `rv.created` gate.
|
|
16254
|
-
is_new: !existing,
|
|
16259
|
+
kind: "register",
|
|
16260
|
+
reg_email: body.email,
|
|
16261
|
+
reg_display_name: body.display_name,
|
|
16262
|
+
challenge: startOpts.challenge,
|
|
16263
|
+
created_at: Date.now(),
|
|
16255
16264
|
});
|
|
16256
16265
|
res.status(200);
|
|
16257
16266
|
res.setHeader && res.setHeader("content-type", "application/json");
|
|
@@ -16292,28 +16301,82 @@ function mount(router, deps) {
|
|
|
16292
16301
|
if (att.response && Array.isArray(att.response.transports)) {
|
|
16293
16302
|
transports = att.response.transports.filter(function (t) { return /^[a-z]+$/.test(t); }).join(",");
|
|
16294
16303
|
}
|
|
16295
|
-
|
|
16304
|
+
var passkeyInput = {
|
|
16296
16305
|
credential_id: credentialId,
|
|
16297
16306
|
public_key: publicKey,
|
|
16298
16307
|
counter: info.counter || 0,
|
|
16299
16308
|
transports: transports,
|
|
16300
|
-
}
|
|
16301
|
-
|
|
16302
|
-
|
|
16303
|
-
|
|
16304
|
-
|
|
16305
|
-
|
|
16306
|
-
|
|
16307
|
-
|
|
16308
|
-
|
|
16309
|
-
|
|
16310
|
-
|
|
16311
|
-
|
|
16312
|
-
|
|
16313
|
-
|
|
16314
|
-
|
|
16309
|
+
};
|
|
16310
|
+
var customerId;
|
|
16311
|
+
if (env.reg_email) {
|
|
16312
|
+
// New deferred flow: create the account AND enroll the credential as
|
|
16313
|
+
// one unit — registerWithPasskey rolls the new row back if the
|
|
16314
|
+
// enroll fails, so a transient write error can't leave an empty,
|
|
16315
|
+
// squattable account behind. register() validates the email +
|
|
16316
|
+
// display name and atomically refuses a duplicate, so a racing
|
|
16317
|
+
// second ceremony (or an account created between this begin and
|
|
16318
|
+
// finish) loses here rather than double-creating. Creating the row
|
|
16319
|
+
// only after a verified attestation is what stops an abandoned or
|
|
16320
|
+
// scripted register-begin from squatting an email.
|
|
16321
|
+
var created;
|
|
16322
|
+
try {
|
|
16323
|
+
created = await deps.customers.registerWithPasskey({
|
|
16324
|
+
email: env.reg_email,
|
|
16325
|
+
display_name: env.reg_display_name,
|
|
16326
|
+
passkey: passkeyInput,
|
|
16327
|
+
});
|
|
16328
|
+
} catch (e) {
|
|
16329
|
+
if (e && e.code === "CUSTOMER_DUPLICATE") {
|
|
16330
|
+
_clearChallengeCookie(res);
|
|
16331
|
+
res.status(409);
|
|
16332
|
+
res.setHeader && res.setHeader("content-type", "application/json");
|
|
16333
|
+
var dupFin = JSON.stringify({
|
|
16334
|
+
error: "account_exists",
|
|
16335
|
+
message: "An account with this email already exists. Sign in instead.",
|
|
16336
|
+
});
|
|
16337
|
+
return res.end ? res.end(dupFin) : res.send(dupFin);
|
|
16338
|
+
}
|
|
16339
|
+
if (e instanceof TypeError) {
|
|
16340
|
+
_clearChallengeCookie(res);
|
|
16341
|
+
res.status(400);
|
|
16342
|
+
return res.end ? res.end((e && e.message) || "registration refused") : res.send((e && e.message) || "registration refused");
|
|
16343
|
+
}
|
|
16344
|
+
throw e;
|
|
16345
|
+
}
|
|
16346
|
+
customerId = created.customer.id;
|
|
16347
|
+
_clearChallengeCookie(res);
|
|
16348
|
+
_setAuthCookie(req, res, {
|
|
16349
|
+
customer_id: customerId,
|
|
16350
|
+
exp: Date.now() + b.constants.TIME.days(14),
|
|
16351
|
+
});
|
|
16352
|
+
// A verified deferred register-finish always lands a genuinely new
|
|
16353
|
+
// account, so attribute the signup to a referrer when the visitor
|
|
16354
|
+
// arrived through a /r/<code> link. Best-effort; never blocks.
|
|
16355
|
+
await _attributeReferral(req, res, customerId);
|
|
16356
|
+
} else if (env.customer_id) {
|
|
16357
|
+
// Legacy challenge cookie minted by the previous (pre-defer)
|
|
16358
|
+
// register-begin, still in flight across a rolling deploy: that
|
|
16359
|
+
// begin already created the customer row, so enroll the credential
|
|
16360
|
+
// onto it (the prior finish behavior) and gate referral attribution
|
|
16361
|
+
// on the begin-stamped is_new exactly as before. This branch only
|
|
16362
|
+
// matches cookies issued before this version shipped; it ages out
|
|
16363
|
+
// with the challenge cookie's TTL.
|
|
16364
|
+
await deps.customers.addPasskey(env.customer_id, passkeyInput);
|
|
16365
|
+
customerId = env.customer_id;
|
|
16366
|
+
_clearChallengeCookie(res);
|
|
16367
|
+
_setAuthCookie(req, res, {
|
|
16368
|
+
customer_id: customerId,
|
|
16369
|
+
exp: Date.now() + b.constants.TIME.days(14),
|
|
16370
|
+
});
|
|
16371
|
+
if (env.is_new === true) {
|
|
16372
|
+
await _attributeReferral(req, res, customerId);
|
|
16373
|
+
} else {
|
|
16374
|
+
try { _clearReferralCookie(res); } catch (_e) { /* best-effort */ }
|
|
16375
|
+
}
|
|
16315
16376
|
} else {
|
|
16316
|
-
|
|
16377
|
+
_clearChallengeCookie(res);
|
|
16378
|
+
res.status(400);
|
|
16379
|
+
return res.end ? res.end("bad challenge") : res.send("bad challenge");
|
|
16317
16380
|
}
|
|
16318
16381
|
res.status(200);
|
|
16319
16382
|
return res.end ? res.end("ok") : res.send("ok");
|
|
@@ -16345,9 +16408,17 @@ function mount(router, deps) {
|
|
|
16345
16408
|
// challenge with an empty allowCredentials list — the client
|
|
16346
16409
|
// can't distinguish "no such address" from "wrong passkey",
|
|
16347
16410
|
// protecting against email-enumeration.
|
|
16411
|
+
//
|
|
16412
|
+
// Timing parity: a real account ran a second query (listPasskeys)
|
|
16413
|
+
// that a non-existent address skipped, leaving a measurable
|
|
16414
|
+
// response-time delta that re-introduced an account-existence
|
|
16415
|
+
// oracle. Run the same lookup on both paths — against the real id
|
|
16416
|
+
// for an existing account, against a fixed non-matching decoy id
|
|
16417
|
+
// (a valid v4 UUID that can never collide with a random customer
|
|
16418
|
+
// id) otherwise — so the work, and the timing, match.
|
|
16348
16419
|
var allow = [];
|
|
16420
|
+
var pks = await deps.customers.listPasskeys(customer ? customer.id : _LOGIN_ENUM_DECOY_ID);
|
|
16349
16421
|
if (customer) {
|
|
16350
|
-
var pks = await deps.customers.listPasskeys(customer.id);
|
|
16351
16422
|
allow = pks.map(function (p) {
|
|
16352
16423
|
return {
|
|
16353
16424
|
id: p.credential_id,
|
|
@@ -16720,6 +16791,16 @@ function mount(router, deps) {
|
|
|
16720
16791
|
if (auth && auth.customer_id && deps.customers &&
|
|
16721
16792
|
typeof deps.customers.revokeSessionsForCustomer === "function") {
|
|
16722
16793
|
await deps.customers.revokeSessionsForCustomer(auth.customer_id);
|
|
16794
|
+
// Also revoke any pending customer-portal token (an unredeemed
|
|
16795
|
+
// magic link issued before sign-out). Bumping the cookie boundary
|
|
16796
|
+
// kills live sealed cookies, but a pending portal token is a
|
|
16797
|
+
// separate credential that would otherwise still redeem after a
|
|
16798
|
+
// sign-out-everywhere. Best-effort + drop-silent.
|
|
16799
|
+
try {
|
|
16800
|
+
if (deps.customerPortal && typeof deps.customerPortal.revokeAllForCustomer === "function") {
|
|
16801
|
+
await deps.customerPortal.revokeAllForCustomer(auth.customer_id, "sign-out");
|
|
16802
|
+
}
|
|
16803
|
+
} catch (_ePortal) { /* drop-silent — best-effort pending-token teardown */ }
|
|
16723
16804
|
}
|
|
16724
16805
|
} catch (_eRev) { /* drop-silent — the cookie clear below still signs out this browser */ }
|
|
16725
16806
|
// Wipe the browser's client-side state for the origin on sign-out
|
|
@@ -16868,6 +16949,35 @@ function mount(router, deps) {
|
|
|
16868
16949
|
if (e instanceof TypeError) return _renderPasskeysPage(req, res, auth, (e && e.message) || "Could not revoke that passkey.", 400);
|
|
16869
16950
|
throw e;
|
|
16870
16951
|
}
|
|
16952
|
+
// Revoking a passkey is a security action — the authenticator may be
|
|
16953
|
+
// lost or compromised. Deleting the credential stops it minting a NEW
|
|
16954
|
+
// session, but the sealed auth cookie is stateless for its 14-day TTL,
|
|
16955
|
+
// so a copy captured before the revoke (e.g. a session the compromised
|
|
16956
|
+
// authenticator established) stays live unless we move the server-side
|
|
16957
|
+
// revocation boundary forward now. We then RE-MINT this browser's auth
|
|
16958
|
+
// cookie with an `iat` at the new boundary, so the trusted device
|
|
16959
|
+
// actively performing the revoke stays signed in while every OTHER live
|
|
16960
|
+
// cookie — older `iat`, behind the boundary — dies on its next request
|
|
16961
|
+
// ("sign out other sessions"). Also revoke every pending customer-portal
|
|
16962
|
+
// token (an unredeemed magic link issued before the revoke) so the
|
|
16963
|
+
// compromise can't be re-entered through a still-valid link.
|
|
16964
|
+
// Best-effort + drop-silent: a failed teardown must not leave the
|
|
16965
|
+
// customer staring at a 500 after the credential is already gone.
|
|
16966
|
+
try {
|
|
16967
|
+
var rev = await deps.customers.revokeSessionsForCustomer(auth.customer_id);
|
|
16968
|
+
var boundary = (rev && rev.sessions_valid_from) || Date.now();
|
|
16969
|
+
// Keep the current (trusted) session alive across the boundary bump.
|
|
16970
|
+
_setAuthCookie(req, res, {
|
|
16971
|
+
customer_id: auth.customer_id,
|
|
16972
|
+
iat: boundary,
|
|
16973
|
+
exp: Date.now() + b.constants.TIME.days(14),
|
|
16974
|
+
});
|
|
16975
|
+
} catch (_eRev) { /* drop-silent — credential is gone; boundary bump is defense-in-depth */ }
|
|
16976
|
+
try {
|
|
16977
|
+
if (deps.customerPortal && typeof deps.customerPortal.revokeAllForCustomer === "function") {
|
|
16978
|
+
await deps.customerPortal.revokeAllForCustomer(auth.customer_id, "passkey-revoke");
|
|
16979
|
+
}
|
|
16980
|
+
} catch (_ePortal) { /* drop-silent — best-effort pending-token teardown */ }
|
|
16871
16981
|
res.status(303); res.setHeader && res.setHeader("location", "/account/passkeys?ok=revoked");
|
|
16872
16982
|
return res.end ? res.end() : res.send("");
|
|
16873
16983
|
});
|
package/package.json
CHANGED