@corbado/observe 1.9.1 → 1.10.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/analysis/client.mjs +183 -4
- package/dist/analysis/config.mjs +4 -1
- package/dist/analysis/cross-origin-session.mjs +422 -0
- package/dist/analysis/queue.mjs +9 -0
- package/dist/analysis/readers/page.mjs +4 -0
- package/dist/analysis/version.mjs +1 -1
- package/dist/cdn/script.global.js +4 -4
- package/dist/index.d.mts +125 -3
- package/dist/index.d.ts +125 -3
- package/dist/index.js +4 -4
- package/dist/index.mjs +4 -4
- package/package.json +1 -1
package/dist/analysis/client.mjs
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
} from "./utils/trusted-device-operation-full.mjs";
|
|
63
63
|
import { ProvideDataOperationFull } from "./utils/provide-data-operation-full.mjs";
|
|
64
64
|
import { PasswordManagerSampler } from "./utils/password-manager-sampler.mjs";
|
|
65
|
+
import { CrossOriginSession, isValidCrossOriginContext } from "./cross-origin-session.mjs";
|
|
65
66
|
var SDK_INFO = {
|
|
66
67
|
name: SDK_NAME,
|
|
67
68
|
version: SDK_VERSION
|
|
@@ -161,7 +162,8 @@ var CorbadoTracker = class _CorbadoTracker {
|
|
|
161
162
|
logger: this.logger,
|
|
162
163
|
url
|
|
163
164
|
});
|
|
164
|
-
this.
|
|
165
|
+
this.crossOrigin = this.createCrossOriginSession();
|
|
166
|
+
this.sessionId = this.resolveSession();
|
|
165
167
|
this.queue = new RequestQueue(
|
|
166
168
|
this.logger,
|
|
167
169
|
transport,
|
|
@@ -173,6 +175,7 @@ var CorbadoTracker = class _CorbadoTracker {
|
|
|
173
175
|
requestConfig: false,
|
|
174
176
|
outboxLockName: namespacedKey(OUTBOX_LOCK_NAME, namespace),
|
|
175
177
|
getDataPolicy: () => this.dataPolicy,
|
|
178
|
+
getCrossOriginSession: (sessionId) => this.crossOriginSessionMeta(sessionId),
|
|
176
179
|
fixedData: this.fixedData !== void 0 || typeof __CBO_DATA_PMD__ === "boolean" || typeof __CBO_DATA_PRIVATE_MODE__ === "boolean",
|
|
177
180
|
beforeEventBatchSend: (events) => this.attachUndeliveredDeviceInfo(events),
|
|
178
181
|
// The queue is created before the device info manager; both are only called once it exists.
|
|
@@ -290,6 +293,10 @@ var CorbadoTracker = class _CorbadoTracker {
|
|
|
290
293
|
probeSeqs = /* @__PURE__ */ new Map();
|
|
291
294
|
windowObserver;
|
|
292
295
|
passwordManagerSampler;
|
|
296
|
+
/** Cross-origin session cookie (`fixedDataConfig.crossOriginSession`); undefined when off. */
|
|
297
|
+
crossOrigin;
|
|
298
|
+
/** The session this load took from the cross-origin cookie, if any (`meta.crossOriginSession`). */
|
|
299
|
+
crossOriginAdoptedId;
|
|
293
300
|
/** @internal Shared passive page observation and per-operation context registration. */
|
|
294
301
|
getWindowObserver() {
|
|
295
302
|
return this.windowObserver ??= new SharedWindowObserver(
|
|
@@ -526,6 +533,108 @@ var CorbadoTracker = class _CorbadoTracker {
|
|
|
526
533
|
})
|
|
527
534
|
);
|
|
528
535
|
}
|
|
536
|
+
/** The cross-origin session cookie when `fixedDataConfig.crossOriginSession` is set and usable here. */
|
|
537
|
+
createCrossOriginSession() {
|
|
538
|
+
const option = this.fixedData?.crossOriginSession;
|
|
539
|
+
if (!option) {
|
|
540
|
+
return void 0;
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
if (this.isTrackingBlocked()) {
|
|
544
|
+
return void 0;
|
|
545
|
+
}
|
|
546
|
+
return CrossOriginSession.create({
|
|
547
|
+
projectId: this.options.projectId,
|
|
548
|
+
cookieDomain: option.cookieDomain,
|
|
549
|
+
logger: this.logger,
|
|
550
|
+
inactivityMs: () => this.config.sessionInactivityMs,
|
|
551
|
+
report: (level, message) => this.reportTelemetry(level, message)
|
|
552
|
+
});
|
|
553
|
+
} catch (error) {
|
|
554
|
+
this.logger.error("Failed to set up the cross-origin session", error);
|
|
555
|
+
return void 0;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Last activity of the local session records BEFORE this load rewrites them (both paths, by id), so the
|
|
560
|
+
* cross-origin boot can tell a session that was active here from one this load just created.
|
|
561
|
+
*/
|
|
562
|
+
readLocalSessionActivity() {
|
|
563
|
+
const activity = /* @__PURE__ */ new Map();
|
|
564
|
+
try {
|
|
565
|
+
const continuity = this.persistentStorage.getItem(SESSION_CONTINUITY_KEY);
|
|
566
|
+
if (continuity?.id && typeof continuity.lastActiveAt === "number") {
|
|
567
|
+
activity.set(continuity.id, continuity.lastActiveAt);
|
|
568
|
+
}
|
|
569
|
+
const perTab = this.sessionStorage.getItem(SESSION_STORAGE_KEY);
|
|
570
|
+
if (perTab && typeof perTab === "object" && perTab.id && typeof perTab.lastActiveAt === "number") {
|
|
571
|
+
activity.set(perTab.id, Math.max(activity.get(perTab.id) ?? 0, perTab.lastActiveAt));
|
|
572
|
+
}
|
|
573
|
+
} catch (error) {
|
|
574
|
+
this.logger.error("Failed to read local session activity", error);
|
|
575
|
+
}
|
|
576
|
+
return activity;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Reconcile the session this load resolved locally with the cross-origin cookie before the first event:
|
|
580
|
+
* adopt the cookie's session (and persist it locally, so reloads and same-origin tabs follow) or keep
|
|
581
|
+
* the local one, and continue the seq at or above the cookie's mark. Never throws.
|
|
582
|
+
*/
|
|
583
|
+
bootCrossOriginSession(localActivity) {
|
|
584
|
+
try {
|
|
585
|
+
this.crossOriginAdoptedId = void 0;
|
|
586
|
+
const now = Date.now();
|
|
587
|
+
const continuity = this.config.sessionContinuity;
|
|
588
|
+
const localSeq = continuity ? Math.max(this.seq, this.persistentStorage.getItem(SEQ_STORAGE_KEY) ?? 0) : this.seq;
|
|
589
|
+
const result = this.crossOrigin?.boot(
|
|
590
|
+
{
|
|
591
|
+
sessionId: this.sessionId,
|
|
592
|
+
lastActiveAt: this.sessionId && localActivity.get(this.sessionId) || 0,
|
|
593
|
+
seq: localSeq
|
|
594
|
+
},
|
|
595
|
+
now
|
|
596
|
+
);
|
|
597
|
+
if (!result || !result.sessionId) {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (result.adopted) {
|
|
601
|
+
this.sessionId = result.sessionId;
|
|
602
|
+
this.crossOriginAdoptedId = result.sessionId;
|
|
603
|
+
if (continuity) {
|
|
604
|
+
this.persistContinuitySession(result.sessionId, now);
|
|
605
|
+
} else {
|
|
606
|
+
this.persistPerTabSession(result.sessionId, now);
|
|
607
|
+
}
|
|
608
|
+
this.logger.debug("Adopted cross-origin session id", result.sessionId);
|
|
609
|
+
}
|
|
610
|
+
this.seq = result.seq;
|
|
611
|
+
if (continuity) {
|
|
612
|
+
this.persistentStorage.setItem(SEQ_STORAGE_KEY, result.seq);
|
|
613
|
+
}
|
|
614
|
+
} catch (error) {
|
|
615
|
+
this.logger.error("Failed to apply the cross-origin session", error);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Resolve the session of this load: locally ({@link resolveSessionId}), then reconciled with the
|
|
620
|
+
* cross-origin cookie when that option is on. Used at boot and by the recovery in `getSessionId`.
|
|
621
|
+
*/
|
|
622
|
+
resolveSession() {
|
|
623
|
+
if (!this.crossOrigin) {
|
|
624
|
+
return this.resolveSessionId();
|
|
625
|
+
}
|
|
626
|
+
const localActivity = this.readLocalSessionActivity();
|
|
627
|
+
this.sessionId = this.resolveSessionId();
|
|
628
|
+
this.bootCrossOriginSession(localActivity);
|
|
629
|
+
return this.sessionId;
|
|
630
|
+
}
|
|
631
|
+
/** `meta.crossOriginSession` of a batch of `sessionId`; undefined while the option is off on this page. */
|
|
632
|
+
crossOriginSessionMeta(sessionId) {
|
|
633
|
+
if (!this.crossOrigin?.isEnabled()) {
|
|
634
|
+
return void 0;
|
|
635
|
+
}
|
|
636
|
+
return sessionId && sessionId === this.crossOriginAdoptedId ? "adopted" : "own";
|
|
637
|
+
}
|
|
529
638
|
resolveSessionId() {
|
|
530
639
|
try {
|
|
531
640
|
if (this.config.sessionContinuity) {
|
|
@@ -659,7 +768,9 @@ var CorbadoTracker = class _CorbadoTracker {
|
|
|
659
768
|
if (this.sessionId && now - this.lastActivityWriteAt >= SESSION_ACTIVITY_WRITE_INTERVAL_MS) {
|
|
660
769
|
this.persistPerTabSession(this.sessionId, now);
|
|
661
770
|
}
|
|
662
|
-
|
|
771
|
+
const current = this.crossOrigin ? this.crossOrigin.claim(this.sessionId, this.seq, now) : this.seq;
|
|
772
|
+
this.seq = current + 1;
|
|
773
|
+
return current;
|
|
663
774
|
}
|
|
664
775
|
const allocate = () => {
|
|
665
776
|
const now = Date.now();
|
|
@@ -689,7 +800,10 @@ var CorbadoTracker = class _CorbadoTracker {
|
|
|
689
800
|
if (sessionChanged) {
|
|
690
801
|
this.seq = typeof stored === "number" ? stored : 0;
|
|
691
802
|
}
|
|
692
|
-
|
|
803
|
+
let current = typeof stored === "number" && stored > this.seq ? stored : this.seq;
|
|
804
|
+
if (this.crossOrigin) {
|
|
805
|
+
current = this.crossOrigin.claim(this.sessionId, current, now);
|
|
806
|
+
}
|
|
693
807
|
this.seq = current + 1;
|
|
694
808
|
this.persistentStorage.setItem(SEQ_STORAGE_KEY, this.seq);
|
|
695
809
|
return current;
|
|
@@ -1780,6 +1894,7 @@ var CorbadoTracker = class _CorbadoTracker {
|
|
|
1780
1894
|
* resources are released either way.
|
|
1781
1895
|
*/
|
|
1782
1896
|
async destroy() {
|
|
1897
|
+
this.crossOrigin?.stop();
|
|
1783
1898
|
this.configManager?.destroy();
|
|
1784
1899
|
this.passwordManagerSampler?.destroy();
|
|
1785
1900
|
this.windowObserver?.destroy();
|
|
@@ -1827,7 +1942,7 @@ var CorbadoTracker = class _CorbadoTracker {
|
|
|
1827
1942
|
*/
|
|
1828
1943
|
getSessionId() {
|
|
1829
1944
|
if (!this.sessionId) {
|
|
1830
|
-
const recovered = this.
|
|
1945
|
+
const recovered = this.resolveSession();
|
|
1831
1946
|
if (recovered) {
|
|
1832
1947
|
this.sessionId = recovered;
|
|
1833
1948
|
this.queue.setSessionId(recovered);
|
|
@@ -1835,6 +1950,69 @@ var CorbadoTracker = class _CorbadoTracker {
|
|
|
1835
1950
|
}
|
|
1836
1951
|
return this.sessionId || void 0;
|
|
1837
1952
|
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Store an opaque context string with the cross-origin session, for the integration's own use on the
|
|
1955
|
+
* next origin (e.g. a journey id and stage). Requires `fixedDataConfig.crossOriginSession`.
|
|
1956
|
+
*
|
|
1957
|
+
* @remarks
|
|
1958
|
+
* The value travels in the shared cookie only (see {@link CrossOriginSessionConfig}): it is never sent
|
|
1959
|
+
* to Corbado, but the browser sends the cookie with every request to every subdomain of the configured
|
|
1960
|
+
* domain. It belongs to the current session: a tracker that adopts the session reads it with
|
|
1961
|
+
* {@link CorbadoTracker.getCrossOriginContext}, and a new session (inactivity, `resetSession`) starts
|
|
1962
|
+
* without it. At most 128 characters of `A-Z a-z 0-9 . _ ~ -`; never put personal data in it.
|
|
1963
|
+
* `null`, `undefined` or `""` clears it.
|
|
1964
|
+
*
|
|
1965
|
+
* Never throws.
|
|
1966
|
+
*
|
|
1967
|
+
* @param context - The value to store; `null`, `undefined` or `""` clears it.
|
|
1968
|
+
* @returns `true` when the value was accepted and written; `false` when it is invalid or the feature
|
|
1969
|
+
* is off on this page (not configured, cookie blocked, domain mismatch, not HTTPS).
|
|
1970
|
+
*
|
|
1971
|
+
* @example
|
|
1972
|
+
* ```typescript
|
|
1973
|
+
* tracker.setCrossOriginContext(`v1.${journeyId}.passkey.b`);
|
|
1974
|
+
* ```
|
|
1975
|
+
*/
|
|
1976
|
+
setCrossOriginContext(context) {
|
|
1977
|
+
try {
|
|
1978
|
+
if (!this.crossOrigin) {
|
|
1979
|
+
return false;
|
|
1980
|
+
}
|
|
1981
|
+
const value = context === null || context === void 0 || context === "" ? void 0 : context;
|
|
1982
|
+
if (value !== void 0 && !isValidCrossOriginContext(value)) {
|
|
1983
|
+
this.logger.debug("Ignoring invalid cross-origin context (expected <= 128 chars of [A-Za-z0-9._~-])");
|
|
1984
|
+
return false;
|
|
1985
|
+
}
|
|
1986
|
+
const seq = this.config.sessionContinuity ? Math.max(this.seq, this.persistentStorage.getItem(SEQ_STORAGE_KEY) ?? 0) : this.seq;
|
|
1987
|
+
const next = this.crossOrigin.setContext(this.sessionId, seq, value);
|
|
1988
|
+
if (next === void 0) {
|
|
1989
|
+
return false;
|
|
1990
|
+
}
|
|
1991
|
+
if (next > this.seq) {
|
|
1992
|
+
this.seq = next;
|
|
1993
|
+
if (this.config.sessionContinuity) {
|
|
1994
|
+
this.persistentStorage.setItem(SEQ_STORAGE_KEY, next);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
return true;
|
|
1998
|
+
} catch (error) {
|
|
1999
|
+
this.logger.error("Failed to set the cross-origin context", error);
|
|
2000
|
+
return false;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* The context string stored with the current cross-origin session (see
|
|
2005
|
+
* {@link CorbadoTracker.setCrossOriginContext}), or `undefined` when none is set, the session has no
|
|
2006
|
+
* context, or the feature is off on this page. Never throws.
|
|
2007
|
+
*/
|
|
2008
|
+
getCrossOriginContext() {
|
|
2009
|
+
try {
|
|
2010
|
+
return this.crossOrigin?.getContext(this.sessionId);
|
|
2011
|
+
} catch (error) {
|
|
2012
|
+
this.logger.error("Failed to read the cross-origin context", error);
|
|
2013
|
+
return void 0;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
1838
2016
|
/** @internal */
|
|
1839
2017
|
resetSession() {
|
|
1840
2018
|
const newSessionId = uuidv7();
|
|
@@ -1847,6 +2025,7 @@ var CorbadoTracker = class _CorbadoTracker {
|
|
|
1847
2025
|
this.sessionId = newSessionId;
|
|
1848
2026
|
this.seq = 0;
|
|
1849
2027
|
this.queue.setSessionId(newSessionId);
|
|
2028
|
+
this.crossOrigin?.reset(newSessionId);
|
|
1850
2029
|
this.logger.debug(`Session reset: ${newSessionId}`);
|
|
1851
2030
|
return newSessionId;
|
|
1852
2031
|
}
|
package/dist/analysis/config.mjs
CHANGED
|
@@ -185,10 +185,13 @@ function resolveFixedDataConfig(raw) {
|
|
|
185
185
|
const value = raw;
|
|
186
186
|
const pmd = value.pmd;
|
|
187
187
|
const pmdExplicit = typeof pmd === "boolean";
|
|
188
|
+
const crossOrigin = value.crossOriginSession;
|
|
189
|
+
const cookieDomain = crossOrigin && typeof crossOrigin === "object" && typeof crossOrigin.cookieDomain === "string" ? crossOrigin.cookieDomain : void 0;
|
|
188
190
|
return {
|
|
189
191
|
pmd: pmdExplicit ? pmd : false,
|
|
190
192
|
pmdExplicit,
|
|
191
|
-
privateMode: asBoolean(value.privateMode, true)
|
|
193
|
+
privateMode: asBoolean(value.privateMode, true),
|
|
194
|
+
...cookieDomain !== void 0 && { crossOriginSession: { cookieDomain } }
|
|
192
195
|
};
|
|
193
196
|
} catch {
|
|
194
197
|
return void 0;
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// src/cross-origin-session.ts
|
|
2
|
+
import { readCookieScope } from "./readers/page.mjs";
|
|
3
|
+
import { cookieJar } from "./readers/storage.mjs";
|
|
4
|
+
var CROSS_ORIGIN_COOKIE_PREFIX = "cbo_xs";
|
|
5
|
+
var CROSS_ORIGIN_COOKIE_VERSION = "1";
|
|
6
|
+
var CROSS_ORIGIN_SEQ_RESERVE = 20;
|
|
7
|
+
var CROSS_ORIGIN_CONTEXT_MAX = 128;
|
|
8
|
+
var CONTEXT_RE = /^[A-Za-z0-9._~-]{1,128}$/;
|
|
9
|
+
var CROSS_ORIGIN_ACTIVITY_WRITE_INTERVAL_MS = 1e3;
|
|
10
|
+
var FUTURE_TOLERANCE_MS = 5 * 60 * 1e3;
|
|
11
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
+
var PROJECT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
13
|
+
var DOMAIN_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
|
14
|
+
var INT_RE = /^\d{1,15}$/;
|
|
15
|
+
var CROSS_ORIGIN_MAX_SEQ_MARK = 1e9;
|
|
16
|
+
var CROSS_ORIGIN_FOREIGN_GAP = 1e3;
|
|
17
|
+
var CROSS_ORIGIN_MAX_SEQ_JUMP = 1e5;
|
|
18
|
+
function isValidCrossOriginContext(value) {
|
|
19
|
+
return typeof value === "string" && CONTEXT_RE.test(value);
|
|
20
|
+
}
|
|
21
|
+
function parseCrossOriginCookie(value) {
|
|
22
|
+
const parts = value.split(".");
|
|
23
|
+
const [version, sessionId, lastActiveAt, seqMark] = parts;
|
|
24
|
+
if (parts.length < 4 || version !== CROSS_ORIGIN_COOKIE_VERSION || !sessionId || !UUID_RE.test(sessionId) || !lastActiveAt || !INT_RE.test(lastActiveAt) || !seqMark || !INT_RE.test(seqMark) || Number(seqMark) > CROSS_ORIGIN_MAX_SEQ_MARK) {
|
|
25
|
+
return void 0;
|
|
26
|
+
}
|
|
27
|
+
const context = parts.length > 4 ? parts.slice(4).join(".") : void 0;
|
|
28
|
+
if (context !== void 0 && !isValidCrossOriginContext(context)) {
|
|
29
|
+
return void 0;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
sessionId: sessionId.toLowerCase(),
|
|
33
|
+
lastActiveAt: Number(lastActiveAt),
|
|
34
|
+
seqMark: Number(seqMark),
|
|
35
|
+
...context !== void 0 && { context }
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function formatCrossOriginCookie(record) {
|
|
39
|
+
const parts = [
|
|
40
|
+
CROSS_ORIGIN_COOKIE_VERSION,
|
|
41
|
+
record.sessionId,
|
|
42
|
+
String(Math.floor(record.lastActiveAt)),
|
|
43
|
+
String(Math.floor(record.seqMark))
|
|
44
|
+
];
|
|
45
|
+
if (record.context !== void 0) {
|
|
46
|
+
parts.push(record.context);
|
|
47
|
+
}
|
|
48
|
+
return parts.join(".");
|
|
49
|
+
}
|
|
50
|
+
function resolveCrossOriginDomain(raw, hostname) {
|
|
51
|
+
const domain = raw.trim().toLowerCase().replace(/^\./, "");
|
|
52
|
+
const host = hostname.toLowerCase();
|
|
53
|
+
if (!DOMAIN_RE.test(domain)) {
|
|
54
|
+
return void 0;
|
|
55
|
+
}
|
|
56
|
+
return host === domain || host.endsWith(`.${domain}`) ? domain : void 0;
|
|
57
|
+
}
|
|
58
|
+
var CrossOriginSession = class _CrossOriginSession {
|
|
59
|
+
constructor(name, domain, jar, contextJar, deps) {
|
|
60
|
+
this.name = name;
|
|
61
|
+
this.domain = domain;
|
|
62
|
+
this.jar = jar;
|
|
63
|
+
this.contextJar = contextJar;
|
|
64
|
+
this.deps = deps;
|
|
65
|
+
}
|
|
66
|
+
enabled = true;
|
|
67
|
+
/** Session the in-memory reservation, highest seen mark and context belong to. */
|
|
68
|
+
reservedFor;
|
|
69
|
+
/** Seq this page may allocate up to (exclusive) without rewriting the cookie. */
|
|
70
|
+
reservedMark = 0;
|
|
71
|
+
/** Highest plausible mark of {@link reservedFor} this page has seen or written. */
|
|
72
|
+
highestSeen = 0;
|
|
73
|
+
lastWriteAt = 0;
|
|
74
|
+
/** Context of {@link reservedFor} as last known to this page. */
|
|
75
|
+
context;
|
|
76
|
+
/**
|
|
77
|
+
* The feature for this page, or `undefined` when it cannot work here (invalid project id or domain, a
|
|
78
|
+
* domain that is not the page's host or a parent of it, a non-HTTPS page, no document). Never throws.
|
|
79
|
+
*/
|
|
80
|
+
static create(deps) {
|
|
81
|
+
try {
|
|
82
|
+
if (typeof document === "undefined" || typeof window === "undefined") {
|
|
83
|
+
return void 0;
|
|
84
|
+
}
|
|
85
|
+
if (!PROJECT_ID_RE.test(deps.projectId)) {
|
|
86
|
+
deps.logger.debug("Cross-origin session off: project id is not cookie-safe");
|
|
87
|
+
return void 0;
|
|
88
|
+
}
|
|
89
|
+
const scope = deps.scope ? deps.scope() : readCookieScope({
|
|
90
|
+
key: "page:location.cookie-scope",
|
|
91
|
+
title: "Page host name for the shared session cookie",
|
|
92
|
+
accessNote: "location.hostname and location.protocol, compared with the configured shared cookie domain; not sent",
|
|
93
|
+
group: "Observe SDK state",
|
|
94
|
+
origin: "observe-external",
|
|
95
|
+
fields: [
|
|
96
|
+
{
|
|
97
|
+
path: "hostname",
|
|
98
|
+
label: "Host name of the page",
|
|
99
|
+
exposure: "none",
|
|
100
|
+
transform: { note: "only compared with the configured parent domain; never sent or stored" }
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
path: "https",
|
|
104
|
+
label: "Whether the page uses a secure connection",
|
|
105
|
+
exposure: "none",
|
|
106
|
+
transform: { note: "the shared cookie is only used on secure pages; never sent or stored" }
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
});
|
|
110
|
+
if (!scope.https) {
|
|
111
|
+
deps.logger.debug("Cross-origin session off: page is not served over HTTPS");
|
|
112
|
+
return void 0;
|
|
113
|
+
}
|
|
114
|
+
const domain = resolveCrossOriginDomain(deps.cookieDomain, scope.hostname);
|
|
115
|
+
if (!domain) {
|
|
116
|
+
deps.logger.warn(`Cross-origin session off: "${deps.cookieDomain}" is not the page's domain or a parent of it`);
|
|
117
|
+
deps.report("info", "cross-origin session: cookieDomain does not match the page");
|
|
118
|
+
return void 0;
|
|
119
|
+
}
|
|
120
|
+
const jar = deps.jar ?? cookieJar({
|
|
121
|
+
key: "storage:sdk.cross-origin-session",
|
|
122
|
+
title: "Observe session shared across your subdomains",
|
|
123
|
+
accessNote: "document.cookie, Observe's own cbo_xs_ cookie on the configured parent domain (Secure, SameSite Lax, Path /, expires after the session inactivity window); the browser sends it with every request to every subdomain of that domain",
|
|
124
|
+
group: "Observe SDK state",
|
|
125
|
+
origin: "observe-internal",
|
|
126
|
+
fields: [
|
|
127
|
+
{
|
|
128
|
+
path: "sessionId",
|
|
129
|
+
label: "Random Observe session id",
|
|
130
|
+
exposure: "token",
|
|
131
|
+
transform: {
|
|
132
|
+
pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
133
|
+
max: 36,
|
|
134
|
+
note: "generated by the SDK; sent to Corbado as the session id of every batch; the cookie carries it to every subdomain of the configured domain"
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
path: "lastActiveAt",
|
|
139
|
+
label: "Time of the last tracked activity",
|
|
140
|
+
exposure: "none",
|
|
141
|
+
transform: {
|
|
142
|
+
note: "decides whether the session is still active; not sent to Corbado, but the cookie carries it to every subdomain of the configured domain"
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
path: "seqMark",
|
|
147
|
+
label: "Event counter position",
|
|
148
|
+
exposure: "none",
|
|
149
|
+
transform: {
|
|
150
|
+
note: "the next page continues event numbering above it; not sent to Corbado, but the cookie carries it to every subdomain of the configured domain"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
]
|
|
154
|
+
});
|
|
155
|
+
const contextJar = deps.jar ?? cookieJar({
|
|
156
|
+
key: "storage:sdk.cross-origin-context",
|
|
157
|
+
title: "Reference your integration keeps with the shared session",
|
|
158
|
+
accessNote: "document.cookie, the optional context part of Observe's cbo_xs_ cookie, set and read by the integration",
|
|
159
|
+
group: "Observe SDK state",
|
|
160
|
+
origin: "host-config",
|
|
161
|
+
fields: [
|
|
162
|
+
{
|
|
163
|
+
path: "context",
|
|
164
|
+
label: "Optional reference set by your integration",
|
|
165
|
+
exposure: "none",
|
|
166
|
+
transform: {
|
|
167
|
+
note: "opaque string of at most 128 cookie-safe characters chosen by the integration; not sent to Corbado, but the cookie carries it to every subdomain of the configured domain"
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
});
|
|
172
|
+
return new _CrossOriginSession(`${CROSS_ORIGIN_COOKIE_PREFIX}_${deps.projectId}`, domain, jar, contextJar, deps);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
deps.logger.error("Cross-origin session setup failed", error);
|
|
175
|
+
return void 0;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Whether the cookie is in use on this page (false after a failed write check or {@link stop}). */
|
|
179
|
+
isEnabled() {
|
|
180
|
+
return this.enabled;
|
|
181
|
+
}
|
|
182
|
+
/** No cookie access from now on (tracker destroyed). */
|
|
183
|
+
stop() {
|
|
184
|
+
this.enabled = false;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Decide the session of this load before its first event and claim the cookie for it.
|
|
188
|
+
*
|
|
189
|
+
* `local.lastActiveAt` is the local session's last activity BEFORE this load (0 when the local session
|
|
190
|
+
* was created by this load). The cookie's session is adopted when the local one is new or was active
|
|
191
|
+
* earlier than the cookie's; the same session continues at or above the cookie's mark. A local session
|
|
192
|
+
* that was active more recently takes the cookie over. Never throws; `undefined` when the feature is off.
|
|
193
|
+
*/
|
|
194
|
+
boot(local, now = Date.now()) {
|
|
195
|
+
if (!this.enabled) {
|
|
196
|
+
return void 0;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const cookie = this.read(this.jar, now);
|
|
200
|
+
let sessionId = local.sessionId;
|
|
201
|
+
let adopted = false;
|
|
202
|
+
if (cookie && cookie.sessionId !== sessionId && (!sessionId || local.lastActiveAt <= 0 || cookie.lastActiveAt > local.lastActiveAt)) {
|
|
203
|
+
sessionId = cookie.sessionId;
|
|
204
|
+
adopted = true;
|
|
205
|
+
}
|
|
206
|
+
let seq = adopted ? cookie?.seqMark ?? 0 : local.seq;
|
|
207
|
+
if (sessionId) {
|
|
208
|
+
const written = this.sync(this.jar, cookie, sessionId, seq, now, true, false);
|
|
209
|
+
seq = written.seq;
|
|
210
|
+
if (!this.isStored(written.value)) {
|
|
211
|
+
this.enabled = false;
|
|
212
|
+
this.deps.logger.debug("Cross-origin session off: the cookie could not be stored");
|
|
213
|
+
this.deps.report("info", "cross-origin session: cookie not stored");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { sessionId, seq, adopted };
|
|
217
|
+
} catch (error) {
|
|
218
|
+
this.enabled = false;
|
|
219
|
+
this.deps.logger.error("Cross-origin session boot failed", error);
|
|
220
|
+
this.deps.report("error", "cross-origin session: boot failed");
|
|
221
|
+
return void 0;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Called for every seq allocation with the seq about to be used; returns the seq to use instead. Reads
|
|
226
|
+
* the cookie on every call (a string scan).
|
|
227
|
+
*
|
|
228
|
+
* - Cookie of this session: continue above another page's reservation once one exists above this
|
|
229
|
+
* page's, renew the reservation when it is used up, otherwise refresh the activity at most once per
|
|
230
|
+
* {@link CROSS_ORIGIN_ACTIVITY_WRITE_INTERVAL_MS}. The mark is never lowered.
|
|
231
|
+
* - No (valid) cookie: written as for this session.
|
|
232
|
+
* - Cookie of another session: left alone (see the module notes); the seq stays above the highest mark
|
|
233
|
+
* this page saw for its session.
|
|
234
|
+
*
|
|
235
|
+
* Never throws.
|
|
236
|
+
*/
|
|
237
|
+
claim(sessionId, seq, now = Date.now()) {
|
|
238
|
+
if (!this.enabled || !sessionId) {
|
|
239
|
+
return seq;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const cookie = this.read(this.jar, now);
|
|
243
|
+
this.follow(sessionId);
|
|
244
|
+
const same = cookie?.sessionId === sessionId;
|
|
245
|
+
const mark = same ? this.plausibleMark(cookie.seqMark, seq) : void 0;
|
|
246
|
+
if (mark !== void 0) {
|
|
247
|
+
this.highestSeen = Math.max(this.highestSeen, mark);
|
|
248
|
+
}
|
|
249
|
+
if (cookie && !same) {
|
|
250
|
+
if (seq < this.reservedMark) {
|
|
251
|
+
return seq;
|
|
252
|
+
}
|
|
253
|
+
const next = Math.max(seq, this.highestSeen) + CROSS_ORIGIN_FOREIGN_GAP;
|
|
254
|
+
this.reservedMark = next + CROSS_ORIGIN_FOREIGN_GAP;
|
|
255
|
+
this.highestSeen = this.reservedMark;
|
|
256
|
+
return next;
|
|
257
|
+
}
|
|
258
|
+
const renew = seq >= this.reservedMark || mark !== void 0 && mark > this.reservedMark;
|
|
259
|
+
if (!renew && now - this.lastWriteAt < CROSS_ORIGIN_ACTIVITY_WRITE_INTERVAL_MS) {
|
|
260
|
+
return seq;
|
|
261
|
+
}
|
|
262
|
+
return this.sync(this.jar, cookie, sessionId, seq, now, renew, false).seq;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
this.deps.logger.error("Cross-origin session cookie update failed", error);
|
|
265
|
+
return seq;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** A new session of this page (`resetSession`): the cookie starts over with it, without context. */
|
|
269
|
+
reset(sessionId, now = Date.now()) {
|
|
270
|
+
if (!this.enabled || !sessionId) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
this.reservedFor = void 0;
|
|
275
|
+
this.follow(sessionId);
|
|
276
|
+
this.sync(this.jar, this.read(this.jar, now), sessionId, 0, now, true, true);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
this.deps.logger.error("Cross-origin session cookie reset failed", error);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Store (or with `undefined` clear) the integration context of `sessionId`; this explicit call may take
|
|
283
|
+
* the cookie over from another session. `seq` is the page's best known next seq. Returns the next seq the
|
|
284
|
+
* page must continue at (a renewed reservation can move it up), or `undefined` when nothing was written.
|
|
285
|
+
*/
|
|
286
|
+
setContext(sessionId, seq, context, now = Date.now()) {
|
|
287
|
+
if (!this.enabled || !sessionId) {
|
|
288
|
+
return void 0;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const cookie = this.read(this.contextJar, now);
|
|
292
|
+
this.follow(sessionId);
|
|
293
|
+
this.context = context;
|
|
294
|
+
return this.sync(this.contextJar, cookie, sessionId, seq, now, seq >= this.reservedMark, true).seq;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
this.deps.logger.error("Cross-origin session context update failed", error);
|
|
297
|
+
return void 0;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** The integration context of `sessionId` (the cookie's when it holds that session, else this page's). */
|
|
301
|
+
getContext(sessionId, now = Date.now()) {
|
|
302
|
+
if (!this.enabled || !sessionId) {
|
|
303
|
+
return void 0;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
const cookie = this.read(this.contextJar, now);
|
|
307
|
+
if (cookie?.sessionId === sessionId) {
|
|
308
|
+
if (this.reservedFor === sessionId) {
|
|
309
|
+
this.context = cookie.context;
|
|
310
|
+
}
|
|
311
|
+
return cookie.context;
|
|
312
|
+
}
|
|
313
|
+
} catch (error) {
|
|
314
|
+
this.deps.logger.error("Cross-origin session context read failed", error);
|
|
315
|
+
}
|
|
316
|
+
return this.reservedFor === sessionId ? this.context : void 0;
|
|
317
|
+
}
|
|
318
|
+
/** Switch the in-memory state to `sessionId` (fresh reservation, nothing seen, no context) when it changed. */
|
|
319
|
+
follow(sessionId) {
|
|
320
|
+
if (this.reservedFor !== sessionId) {
|
|
321
|
+
this.reservedFor = sessionId;
|
|
322
|
+
this.reservedMark = 0;
|
|
323
|
+
this.highestSeen = 0;
|
|
324
|
+
this.context = void 0;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** `mark` when it is plausible next to the page's own counter `seq`; a jump beyond the cap is reported and ignored. */
|
|
328
|
+
plausibleMark(mark, seq) {
|
|
329
|
+
if (mark - seq > CROSS_ORIGIN_MAX_SEQ_JUMP) {
|
|
330
|
+
this.deps.report("info", "cross-origin session: implausible seq mark ignored");
|
|
331
|
+
return void 0;
|
|
332
|
+
}
|
|
333
|
+
return mark;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Merge the cookie (already read) with this page's state and write it back for `sessionId`.
|
|
337
|
+
*
|
|
338
|
+
* - `renew`: allocate a new reservation from `seq` (jumping above the cookie's plausible mark of the same
|
|
339
|
+
* session and anything seen before). Otherwise the mark only grows.
|
|
340
|
+
* - `ownContext`: this page's context wins (it was just set); otherwise the cookie's context of the same
|
|
341
|
+
* session is taken over.
|
|
342
|
+
*/
|
|
343
|
+
sync(jar, cookie, sessionId, seq, now, renew, ownContext) {
|
|
344
|
+
this.follow(sessionId);
|
|
345
|
+
const same = cookie?.sessionId === sessionId;
|
|
346
|
+
const cookieMark = same ? this.plausibleMark(cookie.seqMark, seq) : void 0;
|
|
347
|
+
if (cookieMark !== void 0) {
|
|
348
|
+
this.highestSeen = Math.max(this.highestSeen, cookieMark);
|
|
349
|
+
}
|
|
350
|
+
if (same && !ownContext) {
|
|
351
|
+
this.context = cookie.context;
|
|
352
|
+
}
|
|
353
|
+
let next = seq;
|
|
354
|
+
let mark;
|
|
355
|
+
if (renew) {
|
|
356
|
+
next = Math.max(seq, cookieMark ?? 0, this.highestSeen);
|
|
357
|
+
mark = next + CROSS_ORIGIN_SEQ_RESERVE;
|
|
358
|
+
} else {
|
|
359
|
+
mark = Math.max(this.reservedMark, this.highestSeen, seq);
|
|
360
|
+
}
|
|
361
|
+
const value = formatCrossOriginCookie({
|
|
362
|
+
sessionId,
|
|
363
|
+
lastActiveAt: now,
|
|
364
|
+
seqMark: mark,
|
|
365
|
+
...this.context !== void 0 && { context: this.context }
|
|
366
|
+
});
|
|
367
|
+
this.write(jar, value);
|
|
368
|
+
if (renew) {
|
|
369
|
+
this.reservedMark = mark;
|
|
370
|
+
}
|
|
371
|
+
this.highestSeen = Math.max(this.highestSeen, mark);
|
|
372
|
+
this.lastWriteAt = now;
|
|
373
|
+
return { seq: next, value };
|
|
374
|
+
}
|
|
375
|
+
maxAgeSeconds() {
|
|
376
|
+
return Math.max(60, Math.ceil(this.deps.inactivityMs() / 1e3));
|
|
377
|
+
}
|
|
378
|
+
write(jar, value) {
|
|
379
|
+
jar.write(
|
|
380
|
+
`${this.name}=${value}; Domain=${this.domain}; Path=/; Max-Age=${this.maxAgeSeconds()}; SameSite=Lax; Secure`
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
/** Raw values of every cookie with this name (another path or a host-only copy can shadow it). */
|
|
384
|
+
rawValues(jar) {
|
|
385
|
+
const prefix = `${this.name}=`;
|
|
386
|
+
return jar.read().split(";").map((part) => part.trim()).filter((part) => part.startsWith(prefix)).map((part) => part.substring(prefix.length));
|
|
387
|
+
}
|
|
388
|
+
isStored(value) {
|
|
389
|
+
try {
|
|
390
|
+
return this.rawValues(this.jar).includes(value);
|
|
391
|
+
} catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** The valid, unexpired record with the latest activity, or `undefined`. Throws when the cookie read throws. */
|
|
396
|
+
read(jar, now) {
|
|
397
|
+
const inactivityMs = this.deps.inactivityMs();
|
|
398
|
+
let best;
|
|
399
|
+
for (const raw of this.rawValues(jar)) {
|
|
400
|
+
const record = parseCrossOriginCookie(raw);
|
|
401
|
+
if (record && now - record.lastActiveAt < inactivityMs && record.lastActiveAt <= now + FUTURE_TOLERANCE_MS && (!best || record.lastActiveAt > best.lastActiveAt)) {
|
|
402
|
+
best = record;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return best;
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
export {
|
|
409
|
+
CROSS_ORIGIN_ACTIVITY_WRITE_INTERVAL_MS,
|
|
410
|
+
CROSS_ORIGIN_CONTEXT_MAX,
|
|
411
|
+
CROSS_ORIGIN_COOKIE_PREFIX,
|
|
412
|
+
CROSS_ORIGIN_COOKIE_VERSION,
|
|
413
|
+
CROSS_ORIGIN_FOREIGN_GAP,
|
|
414
|
+
CROSS_ORIGIN_MAX_SEQ_JUMP,
|
|
415
|
+
CROSS_ORIGIN_MAX_SEQ_MARK,
|
|
416
|
+
CROSS_ORIGIN_SEQ_RESERVE,
|
|
417
|
+
CrossOriginSession,
|
|
418
|
+
formatCrossOriginCookie,
|
|
419
|
+
isValidCrossOriginContext,
|
|
420
|
+
parseCrossOriginCookie,
|
|
421
|
+
resolveCrossOriginDomain
|
|
422
|
+
};
|