@sendoracloud/sdk-react-native 1.33.0 → 1.35.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/index.cjs +206 -24
- package/dist/index.d.cts +160 -4
- package/dist/index.d.ts +160 -4
- package/dist/index.js +206 -24
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -48,7 +48,7 @@ var import_react_native_get_random_values = require("react-native-get-random-val
|
|
|
48
48
|
var import_react_native5 = require("react-native");
|
|
49
49
|
|
|
50
50
|
// src/storage.ts
|
|
51
|
-
var
|
|
51
|
+
var DEFAULT_PREFIX = "sendora_";
|
|
52
52
|
var asyncStoragePromise = null;
|
|
53
53
|
async function getAsyncStorage() {
|
|
54
54
|
if (asyncStoragePromise) return asyncStoragePromise;
|
|
@@ -71,6 +71,12 @@ var Storage = class {
|
|
|
71
71
|
this.hydrated = false;
|
|
72
72
|
this.secure = opts.secureAdapter ?? null;
|
|
73
73
|
this.secureKeys = new Set(opts.secureKeys ?? []);
|
|
74
|
+
this.prefix = opts.prefix ?? DEFAULT_PREFIX;
|
|
75
|
+
this.securePrefix = opts.prefix ?? "";
|
|
76
|
+
}
|
|
77
|
+
/** The adapter-facing name for a key. Bare by default; see `prefix`. */
|
|
78
|
+
secureName(key) {
|
|
79
|
+
return this.securePrefix + key;
|
|
74
80
|
}
|
|
75
81
|
isSecure(key) {
|
|
76
82
|
return this.secure !== null && this.secureKeys.has(key);
|
|
@@ -91,7 +97,7 @@ var Storage = class {
|
|
|
91
97
|
await this.hydrateSecureKey(k, store);
|
|
92
98
|
} else if (store) {
|
|
93
99
|
try {
|
|
94
|
-
const v = await store.getItem(
|
|
100
|
+
const v = await store.getItem(this.prefix + k);
|
|
95
101
|
if (v !== null) this.cache.set(k, v);
|
|
96
102
|
} catch {
|
|
97
103
|
}
|
|
@@ -102,18 +108,18 @@ var Storage = class {
|
|
|
102
108
|
/** Read a secure key: adapter first, else migrate legacy plaintext. Never throws. */
|
|
103
109
|
async hydrateSecureKey(k, store) {
|
|
104
110
|
try {
|
|
105
|
-
const secureVal = await this.secure.getItem(k);
|
|
111
|
+
const secureVal = await this.secure.getItem(this.secureName(k));
|
|
106
112
|
if (secureVal !== null) {
|
|
107
113
|
this.cache.set(k, secureVal);
|
|
108
114
|
return;
|
|
109
115
|
}
|
|
110
116
|
if (store) {
|
|
111
|
-
const legacy = await store.getItem(
|
|
117
|
+
const legacy = await store.getItem(this.prefix + k).catch(() => null);
|
|
112
118
|
if (legacy !== null) {
|
|
113
119
|
this.cache.set(k, legacy);
|
|
114
120
|
try {
|
|
115
|
-
await this.secure.setItem(k, legacy);
|
|
116
|
-
await store.removeItem(
|
|
121
|
+
await this.secure.setItem(this.secureName(k), legacy);
|
|
122
|
+
await store.removeItem(this.prefix + k).catch(() => {
|
|
117
123
|
});
|
|
118
124
|
} catch {
|
|
119
125
|
}
|
|
@@ -122,7 +128,7 @@ var Storage = class {
|
|
|
122
128
|
} catch {
|
|
123
129
|
if (store) {
|
|
124
130
|
try {
|
|
125
|
-
const legacy = await store.getItem(
|
|
131
|
+
const legacy = await store.getItem(this.prefix + k);
|
|
126
132
|
if (legacy !== null) this.cache.set(k, legacy);
|
|
127
133
|
} catch {
|
|
128
134
|
}
|
|
@@ -135,27 +141,27 @@ var Storage = class {
|
|
|
135
141
|
set(key, value) {
|
|
136
142
|
this.cache.set(key, value);
|
|
137
143
|
if (this.isSecure(key)) {
|
|
138
|
-
void this.secure.setItem(key, value).catch(() => {
|
|
144
|
+
void this.secure.setItem(this.secureName(key), value).catch(() => {
|
|
139
145
|
void getAsyncStorage().then((store) => {
|
|
140
|
-
if (store) store.setItem(
|
|
146
|
+
if (store) store.setItem(this.prefix + key, value).catch(() => {
|
|
141
147
|
});
|
|
142
148
|
});
|
|
143
149
|
});
|
|
144
150
|
return;
|
|
145
151
|
}
|
|
146
152
|
void getAsyncStorage().then((store) => {
|
|
147
|
-
if (store) store.setItem(
|
|
153
|
+
if (store) store.setItem(this.prefix + key, value).catch(() => {
|
|
148
154
|
});
|
|
149
155
|
});
|
|
150
156
|
}
|
|
151
157
|
remove(key) {
|
|
152
158
|
this.cache.delete(key);
|
|
153
159
|
if (this.isSecure(key)) {
|
|
154
|
-
void this.secure.removeItem(key).catch(() => {
|
|
160
|
+
void this.secure.removeItem(this.secureName(key)).catch(() => {
|
|
155
161
|
});
|
|
156
162
|
}
|
|
157
163
|
void getAsyncStorage().then((store) => {
|
|
158
|
-
if (store) store.removeItem(
|
|
164
|
+
if (store) store.removeItem(this.prefix + key).catch(() => {
|
|
159
165
|
});
|
|
160
166
|
});
|
|
161
167
|
}
|
|
@@ -171,7 +177,7 @@ var Storage = class {
|
|
|
171
177
|
if (this.secure) {
|
|
172
178
|
for (const k of this.secureKeys) {
|
|
173
179
|
try {
|
|
174
|
-
await this.secure.removeItem(k);
|
|
180
|
+
await this.secure.removeItem(this.secureName(k));
|
|
175
181
|
} catch {
|
|
176
182
|
}
|
|
177
183
|
}
|
|
@@ -182,13 +188,13 @@ var Storage = class {
|
|
|
182
188
|
if (typeof store.getAllKeys === "function") {
|
|
183
189
|
try {
|
|
184
190
|
const all = await store.getAllKeys();
|
|
185
|
-
keys = all.filter((k) => k.startsWith(
|
|
191
|
+
keys = all.filter((k) => k.startsWith(this.prefix));
|
|
186
192
|
} catch {
|
|
187
193
|
keys = [];
|
|
188
194
|
}
|
|
189
195
|
}
|
|
190
196
|
if (keys.length === 0) {
|
|
191
|
-
keys = Array.from(this.secureKeys).map((k) =>
|
|
197
|
+
keys = Array.from(this.secureKeys).map((k) => this.prefix + k);
|
|
192
198
|
}
|
|
193
199
|
for (const k of keys) {
|
|
194
200
|
try {
|
|
@@ -202,7 +208,7 @@ var Storage = class {
|
|
|
202
208
|
// package.json
|
|
203
209
|
var package_default = {
|
|
204
210
|
name: "@sendoracloud/sdk-react-native",
|
|
205
|
-
version: "1.
|
|
211
|
+
version: "1.35.0",
|
|
206
212
|
description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
|
|
207
213
|
type: "module",
|
|
208
214
|
main: "./dist/index.cjs",
|
|
@@ -258,7 +264,7 @@ var package_default = {
|
|
|
258
264
|
scripts: {
|
|
259
265
|
build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
260
266
|
typecheck: "tsc --noEmit",
|
|
261
|
-
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts"
|
|
267
|
+
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts"
|
|
262
268
|
},
|
|
263
269
|
devDependencies: {
|
|
264
270
|
"@types/react": "^19.2.16",
|
|
@@ -488,6 +494,8 @@ function classifyAuthErrorKind(code, status) {
|
|
|
488
494
|
return "account_locked";
|
|
489
495
|
case "CREDENTIAL_IN_USE":
|
|
490
496
|
return "credential_in_use";
|
|
497
|
+
case "LAST_CREDENTIAL":
|
|
498
|
+
return "last_credential";
|
|
491
499
|
case "NOT_ANONYMOUS":
|
|
492
500
|
case "FORBIDDEN_NON_ANONYMOUS":
|
|
493
501
|
return "already_identified";
|
|
@@ -595,6 +603,27 @@ var Auth = class {
|
|
|
595
603
|
this.user = null;
|
|
596
604
|
this.accessToken = null;
|
|
597
605
|
this.accessExpiresAt = 0;
|
|
606
|
+
/**
|
|
607
|
+
* Set once we have PROOF that this device's clock runs fast, which
|
|
608
|
+
* releases the `exp` guard in `canServeCachedToken`.
|
|
609
|
+
*
|
|
610
|
+
* The proof is specific and cannot be faked by a stale deadline: a token
|
|
611
|
+
* the server minted *seconds ago* reads as already past its own `exp`.
|
|
612
|
+
* A freshly-issued token is never genuinely expired, so the only
|
|
613
|
+
* explanation is the clock we measured it with.
|
|
614
|
+
*
|
|
615
|
+
* ⚠ NEVER PERSIST THIS. It is re-learned for the cost of one refresh per
|
|
616
|
+
* process, and a persisted "my clock is fast" belief would outlive the
|
|
617
|
+
* clock correction that made it false — which is precisely the stale-frame
|
|
618
|
+
* bug this whole guard exists to close, moved one level up.
|
|
619
|
+
*
|
|
620
|
+
* ⚠ Without this escape the `exp` guard alone is a REFRESH LOOP on any
|
|
621
|
+
* device whose clock is fast by more than the token TTL (default 60 min):
|
|
622
|
+
* every freshly-minted token would read as already expired, so every
|
|
623
|
+
* `getAccessToken()` would refresh, forever. That is why the fix is not
|
|
624
|
+
* simply "require `exp` too".
|
|
625
|
+
*/
|
|
626
|
+
this.clockSkewConfirmed = false;
|
|
598
627
|
this.inflight = Promise.resolve();
|
|
599
628
|
this.refreshInflight = null;
|
|
600
629
|
/**
|
|
@@ -686,6 +715,7 @@ var Auth = class {
|
|
|
686
715
|
}
|
|
687
716
|
}
|
|
688
717
|
this.hydrated = true;
|
|
718
|
+
if (this.user && this.hooks.storage.get(REFRESH_KEY)) this.startProactiveRefreshCron();
|
|
689
719
|
this.resolveReady();
|
|
690
720
|
}
|
|
691
721
|
getCurrentUser() {
|
|
@@ -847,7 +877,11 @@ var Auth = class {
|
|
|
847
877
|
*
|
|
848
878
|
* Resolution order:
|
|
849
879
|
* 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
|
|
850
|
-
*
|
|
880
|
+
* before BOTH the tracked deadline and the token's own `exp`) →
|
|
881
|
+
* return it. Fast path, no I/O. See `canServeCachedToken` for why
|
|
882
|
+
* both are required and when the second is released.
|
|
883
|
+
* Pass `{ forceRefresh: true }` to skip this step when the caller
|
|
884
|
+
* knows the tracked deadline is wrong.
|
|
851
885
|
* 2. No cached token AND no refresh token in storage → genuinely
|
|
852
886
|
* signed out → null.
|
|
853
887
|
* 3. Expired/near-expiry but a refresh chain exists:
|
|
@@ -865,14 +899,85 @@ var Auth = class {
|
|
|
865
899
|
* the cooldown for an immediate retry on resume.
|
|
866
900
|
* c. Otherwise → fire the refresh and await it.
|
|
867
901
|
*/
|
|
868
|
-
|
|
869
|
-
|
|
902
|
+
/**
|
|
903
|
+
* The cached-token guard, and the single place the two deadlines are
|
|
904
|
+
* combined. Both must agree before a cached token is served:
|
|
905
|
+
*
|
|
906
|
+
* 1. the locally-tracked deadline (`Date.now() + expiresIn` at mint) —
|
|
907
|
+
* immune to a constantly-wrong clock, blind to a clock that MOVES;
|
|
908
|
+
* 2. the token's own `exp` — immune to a clock that moves, wrong by the
|
|
909
|
+
* full offset on a constantly-wrong clock.
|
|
910
|
+
*
|
|
911
|
+
* Guard 2 is released once `clockSkewConfirmed` proves the second failure
|
|
912
|
+
* mode is the one we are actually in.
|
|
913
|
+
*
|
|
914
|
+
* The gap this closes: `accessExpiresAt` is written in the device's clock
|
|
915
|
+
* frame at mint and PERSISTED. Step the clock backwards between mint and
|
|
916
|
+
* read — an iPhone acquiring automatic time after a long power-off, a
|
|
917
|
+
* restore, or a manual set — and the deadline is stale by the size of the
|
|
918
|
+
* correction, surviving relaunches. The SDK then serves a provably dead
|
|
919
|
+
* token, every request 401s, and because the fast path returns before
|
|
920
|
+
* reaching `persist()` or `wipeLocalIdentity()` nothing emits
|
|
921
|
+
* `signed_out`. Reported by Word Hurdle with a token 1929s past its `exp`.
|
|
922
|
+
*/
|
|
923
|
+
canServeToken(token, expiresAt, nowMs) {
|
|
924
|
+
if (!token || !expiresAt) return false;
|
|
925
|
+
if (nowMs >= expiresAt - REFRESH_SAFETY_MS) return false;
|
|
926
|
+
if (this.clockSkewConfirmed) return true;
|
|
927
|
+
return accessTokenExpIsFuture(token, nowMs);
|
|
928
|
+
}
|
|
929
|
+
canServeCachedToken(nowMs) {
|
|
930
|
+
return this.canServeToken(this.accessToken, this.accessExpiresAt, nowMs);
|
|
931
|
+
}
|
|
932
|
+
/** Guard 1 alone: does the locally-tracked deadline still claim life? */
|
|
933
|
+
trackedDeadlineAlive(nowMs) {
|
|
934
|
+
return !!this.accessToken && !!this.accessExpiresAt && nowMs < this.accessExpiresAt - REFRESH_SAFETY_MS;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Refresh — and when the two deadlines disagree, let the token that comes
|
|
938
|
+
* back say which one was lying.
|
|
939
|
+
*
|
|
940
|
+
* ⚠ THE TIMING IS THE WHOLE MECHANISM, and the obvious placement is wrong.
|
|
941
|
+
* Learning at every mint site looks equivalent and is not: in the reported
|
|
942
|
+
* failure the token was MINTED while the clock was fast and only read after
|
|
943
|
+
* the correction, so a mint-time probe records "clock is fast", and the flag
|
|
944
|
+
* then waves through the very token the guard exists to refuse. Probing here
|
|
945
|
+
* — only on a refresh we performed BECAUSE `exp` contradicted a tracked
|
|
946
|
+
* deadline that still claimed life — separates the two cases, because by now
|
|
947
|
+
* the clock has either been corrected (fresh token reads live → stale
|
|
948
|
+
* deadline, guard holds) or is genuinely fast (fresh token still reads dead
|
|
949
|
+
* → release the guard, one probe, no loop).
|
|
950
|
+
*/
|
|
951
|
+
async refreshAndReconcile(nowMs) {
|
|
952
|
+
const reconciling = this.deadlinesDisagree(nowMs);
|
|
953
|
+
const token = await this.refreshAccessToken();
|
|
954
|
+
if (reconciling && token) this.learnClockSkewFrom(token, Date.now());
|
|
955
|
+
return token;
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Record whether a token the server issued MOMENTS AGO already reads as
|
|
959
|
+
* expired. It cannot genuinely be — so a `false` here is proof the device
|
|
960
|
+
* clock runs fast, and releases the `exp` guard for this process.
|
|
961
|
+
*
|
|
962
|
+
* Call this at every site that installs a server-issued token, and only
|
|
963
|
+
* with a token that just arrived. Handing it a cached token would let a
|
|
964
|
+
* stale deadline confirm itself.
|
|
965
|
+
*/
|
|
966
|
+
learnClockSkewFrom(freshToken, nowMs) {
|
|
967
|
+
if (!accessTokenExpIsFuture(freshToken, nowMs)) this.clockSkewConfirmed = true;
|
|
968
|
+
}
|
|
969
|
+
/** True when a cached token is unusable ONLY because its `exp` contradicts a live tracked deadline. */
|
|
970
|
+
deadlinesDisagree(nowMs) {
|
|
971
|
+
return this.trackedDeadlineAlive(nowMs) && !this.canServeCachedToken(nowMs);
|
|
972
|
+
}
|
|
973
|
+
async getAccessToken(options) {
|
|
974
|
+
if (!options?.forceRefresh && this.canServeCachedToken(Date.now())) {
|
|
870
975
|
return this.accessToken;
|
|
871
976
|
}
|
|
872
977
|
if (!this.hooks.storage.get(REFRESH_KEY)) return null;
|
|
873
978
|
if (this.refreshInflight) return this.refreshInflight;
|
|
874
979
|
if (Date.now() < this.refreshCooldownUntil) return null;
|
|
875
|
-
return this.
|
|
980
|
+
return this.refreshAndReconcile(Date.now());
|
|
876
981
|
}
|
|
877
982
|
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
878
983
|
getAccessTokenSync() {
|
|
@@ -1230,6 +1335,56 @@ var Auth = class {
|
|
|
1230
1335
|
}
|
|
1231
1336
|
return { identities: parsed.data.identities ?? [], hasPassword: Boolean(parsed.data.hasPassword) };
|
|
1232
1337
|
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Remove one linked provider from the signed-in account.
|
|
1340
|
+
*
|
|
1341
|
+
* The write half of `listLinkedIdentities()` — what a "Disconnect" button
|
|
1342
|
+
* next to each entry on a Connected Accounts screen calls.
|
|
1343
|
+
*
|
|
1344
|
+
* ⚠ The server REFUSES to remove the last way into the account, with
|
|
1345
|
+
* `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
|
|
1346
|
+
* account with no credentials still exists and still holds the user's data,
|
|
1347
|
+
* but nothing can ever authenticate into it again — so this is a permanent
|
|
1348
|
+
* lockout, not an inconvenience. A password counts as a credential.
|
|
1349
|
+
*
|
|
1350
|
+
* Prefer to prevent the tap rather than explain the refusal: read
|
|
1351
|
+
* `listLinkedIdentities()` and disable Disconnect when
|
|
1352
|
+
* `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
|
|
1353
|
+
* backstop, not the UX.
|
|
1354
|
+
*
|
|
1355
|
+
* Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
|
|
1356
|
+
* provider is not linked to this account. Removing an already-absent
|
|
1357
|
+
* provider is NOT treated as success — a silent no-op would let a buggy
|
|
1358
|
+
* screen report "disconnected" for a credential that is still attached.
|
|
1359
|
+
*/
|
|
1360
|
+
async unlink(provider) {
|
|
1361
|
+
const token = await this.getAccessToken();
|
|
1362
|
+
if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before unlinking an identity");
|
|
1363
|
+
const res = await del(
|
|
1364
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1365
|
+
`/api/v1/auth-service/me/identities/${encodeURIComponent(provider)}`,
|
|
1366
|
+
{ Authorization: `Bearer ${token}` }
|
|
1367
|
+
);
|
|
1368
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Failed to unlink identity");
|
|
1369
|
+
let parsed;
|
|
1370
|
+
try {
|
|
1371
|
+
parsed = await res.json();
|
|
1372
|
+
} catch {
|
|
1373
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
|
|
1374
|
+
}
|
|
1375
|
+
if (!parsed.success || !parsed.data) {
|
|
1376
|
+
throw new AuthError(
|
|
1377
|
+
parsed.error?.code ?? "UNLINK_FAILED",
|
|
1378
|
+
parsed.error?.message ?? "Failed to unlink identity",
|
|
1379
|
+
{ status: res.status }
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
return {
|
|
1383
|
+
provider: parsed.data.provider ?? provider,
|
|
1384
|
+
removed: Number(parsed.data.removed ?? 0),
|
|
1385
|
+
remaining: Number(parsed.data.remaining ?? 0)
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1233
1388
|
/**
|
|
1234
1389
|
* Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
|
|
1235
1390
|
* ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
|
|
@@ -1689,7 +1844,7 @@ var Auth = class {
|
|
|
1689
1844
|
const freshExpiresStr = this.hooks.storage.get(TOKEN_EXPIRES_KEY);
|
|
1690
1845
|
if (freshAccess && freshExpiresStr) {
|
|
1691
1846
|
const freshExpires = Number(freshExpiresStr);
|
|
1692
|
-
if (Number.isFinite(freshExpires) && freshExpires
|
|
1847
|
+
if (Number.isFinite(freshExpires) && this.canServeToken(freshAccess, freshExpires, Date.now())) {
|
|
1693
1848
|
this.accessToken = freshAccess;
|
|
1694
1849
|
this.accessExpiresAt = freshExpires;
|
|
1695
1850
|
this.refreshFailureCount = 0;
|
|
@@ -1756,6 +1911,10 @@ var Auth = class {
|
|
|
1756
1911
|
const tick = async () => {
|
|
1757
1912
|
if (!this.accessToken || !this.accessExpiresAt) return;
|
|
1758
1913
|
const now = Date.now();
|
|
1914
|
+
if (!this.canServeCachedToken(now)) {
|
|
1915
|
+
await this.refreshAndReconcile(now);
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1759
1918
|
const lifetimeMs = this.accessExpiresAt - now;
|
|
1760
1919
|
if (lifetimeMs <= 0) return;
|
|
1761
1920
|
const guessOriginalMs = Math.max(lifetimeMs, 5 * 6e4);
|
|
@@ -1938,6 +2097,7 @@ var Auth = class {
|
|
|
1938
2097
|
this.user = null;
|
|
1939
2098
|
this.accessToken = null;
|
|
1940
2099
|
this.accessExpiresAt = 0;
|
|
2100
|
+
this.clockSkewConfirmed = false;
|
|
1941
2101
|
this.lastTakeover = null;
|
|
1942
2102
|
this.lastDeletionCancelled = null;
|
|
1943
2103
|
this.lastAnonRetirement = null;
|
|
@@ -1966,6 +2126,13 @@ function readRotatedTokens(data) {
|
|
|
1966
2126
|
if (typeof t.expiresIn !== "number" || t.expiresIn <= 0) return null;
|
|
1967
2127
|
return { accessToken: t.accessToken, refreshToken: t.refreshToken, expiresIn: t.expiresIn };
|
|
1968
2128
|
}
|
|
2129
|
+
function accessTokenExpIsFuture(token, nowMs) {
|
|
2130
|
+
const claims = decodeJwtPayload(token);
|
|
2131
|
+
if (!claims) return true;
|
|
2132
|
+
const exp = claims.exp;
|
|
2133
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) return true;
|
|
2134
|
+
return nowMs < exp * 1e3;
|
|
2135
|
+
}
|
|
1969
2136
|
function decodeJwtPayload(token) {
|
|
1970
2137
|
try {
|
|
1971
2138
|
const parts = token.split(".");
|
|
@@ -2655,6 +2822,16 @@ var AUTH_HYDRATE_KEYS = [
|
|
|
2655
2822
|
];
|
|
2656
2823
|
var SECURE_KEYS = ["auth_refresh_token", "auth_access_token", "auth_user"];
|
|
2657
2824
|
var PUBLIC_KEY_RE = /^pk_(prod|staging|dev|live|test)_[A-Za-z0-9]{16,}$/;
|
|
2825
|
+
var STORAGE_PREFIX_RE = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
2826
|
+
function normalizeStoragePrefix(prefix) {
|
|
2827
|
+
if (prefix === void 0) return void 0;
|
|
2828
|
+
if (typeof prefix !== "string" || !STORAGE_PREFIX_RE.test(prefix)) {
|
|
2829
|
+
throw new Error(
|
|
2830
|
+
`[sendora] init: storagePrefix must be 1-64 chars of A-Z a-z 0-9 _ . - (got ${JSON.stringify(prefix)}). Changing it signs the device out, so it is validated rather than ignored.`
|
|
2831
|
+
);
|
|
2832
|
+
}
|
|
2833
|
+
return prefix;
|
|
2834
|
+
}
|
|
2658
2835
|
var MAX_PUSH_TOKEN_BYTES = 4096;
|
|
2659
2836
|
var MAX_PUSH_METADATA_BYTES = 4096;
|
|
2660
2837
|
var MAX_USER_ID_LEN = 256;
|
|
@@ -2828,8 +3005,13 @@ var SendoraSDK = class {
|
|
|
2828
3005
|
`[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
|
|
2829
3006
|
);
|
|
2830
3007
|
}
|
|
2831
|
-
if (config.secureStorage) {
|
|
2832
|
-
|
|
3008
|
+
if (config.secureStorage || config.storagePrefix !== void 0) {
|
|
3009
|
+
const prefix = normalizeStoragePrefix(config.storagePrefix);
|
|
3010
|
+
this.storage = new Storage({
|
|
3011
|
+
secureAdapter: config.secureStorage,
|
|
3012
|
+
secureKeys: SECURE_KEYS,
|
|
3013
|
+
prefix
|
|
3014
|
+
});
|
|
2833
3015
|
}
|
|
2834
3016
|
await this.storage.hydrate([
|
|
2835
3017
|
ANON_KEY,
|
package/dist/index.d.cts
CHANGED
|
@@ -104,6 +104,29 @@ interface SendoraConfig {
|
|
|
104
104
|
* threat model. It is opt-in: adding it never changes an app that doesn't pass it.
|
|
105
105
|
*/
|
|
106
106
|
secureStorage?: SecureStorageAdapter;
|
|
107
|
+
/**
|
|
108
|
+
* Namespace every on-device key this SDK owns. Omit for the historical
|
|
109
|
+
* `sendora_` prefix — the default is byte-identical to prior releases.
|
|
110
|
+
*
|
|
111
|
+
* For apps that point one codebase at more than one Sendora project
|
|
112
|
+
* (staging vs production, a debug build beside a release build). Without
|
|
113
|
+
* it, both environments share `sendora_*` on the same device: whichever
|
|
114
|
+
* launched last owns the session, and a debug sign-in can evict the
|
|
115
|
+
* release build's. A custom `secureStorage` adapter only fixes the half
|
|
116
|
+
* that goes through the adapter — `auth_access_expires` and the anon id
|
|
117
|
+
* stay in AsyncStorage and still collide.
|
|
118
|
+
*
|
|
119
|
+
* ⚠ CHANGING THIS SIGNS THE DEVICE OUT. Keys are the session's only
|
|
120
|
+
* durable handle, and the SDK does not migrate between prefixes — a new
|
|
121
|
+
* prefix reads an empty store and mints a fresh anonymous user. Pick one
|
|
122
|
+
* per environment before shipping and never change it afterwards. This is
|
|
123
|
+
* the same frozen-key rule as ADR-023, delegated to you.
|
|
124
|
+
*
|
|
125
|
+
* ⚠ Applies to the `secureStorage` adapter too when set (adapter keys are
|
|
126
|
+
* bare by default). If you already namespace inside your own adapter,
|
|
127
|
+
* either drop that and use this, or expect `<yours><ours>key`.
|
|
128
|
+
*/
|
|
129
|
+
storagePrefix?: string;
|
|
107
130
|
}
|
|
108
131
|
/**
|
|
109
132
|
* Async key/value adapter for encrypted-at-rest auth storage. Compatible with a
|
|
@@ -178,13 +201,30 @@ type StorageOptions = {
|
|
|
178
201
|
secureAdapter?: SecureStorageAdapter | null;
|
|
179
202
|
/** Keys routed through `secureAdapter` when it is present. Everything else stays in AsyncStorage. */
|
|
180
203
|
secureKeys?: Iterable<string>;
|
|
204
|
+
/**
|
|
205
|
+
* Namespace for every key this SDK owns. Omit for the default `sendora_`.
|
|
206
|
+
*
|
|
207
|
+
* ⚠ Asymmetric by necessity, and the asymmetry is what keeps upgrades
|
|
208
|
+
* safe. When omitted, AsyncStorage keys keep the historical `sendora_`
|
|
209
|
+
* prefix and secure-adapter keys stay BARE — exactly what shipped before
|
|
210
|
+
* this option existed, so no installed app loses its session on upgrade.
|
|
211
|
+
* When supplied, the prefix applies to BOTH stores, because the whole
|
|
212
|
+
* point is that two environments sharing one device share nothing.
|
|
213
|
+
*/
|
|
214
|
+
prefix?: string;
|
|
181
215
|
};
|
|
182
216
|
declare class Storage {
|
|
183
217
|
private cache;
|
|
184
218
|
private hydrated;
|
|
185
219
|
private readonly secure;
|
|
186
220
|
private readonly secureKeys;
|
|
221
|
+
/** Applied to AsyncStorage keys. */
|
|
222
|
+
private readonly prefix;
|
|
223
|
+
/** Applied to secure-adapter keys — empty unless a custom prefix was set. */
|
|
224
|
+
private readonly securePrefix;
|
|
187
225
|
constructor(opts?: StorageOptions);
|
|
226
|
+
/** The adapter-facing name for a key. Bare by default; see `prefix`. */
|
|
227
|
+
private secureName;
|
|
188
228
|
private isSecure;
|
|
189
229
|
/**
|
|
190
230
|
* Populate the memory cache from persistent storage. Must be awaited
|
|
@@ -402,6 +442,19 @@ type LinkedIdentitiesResponse = {
|
|
|
402
442
|
identities: LinkedIdentity[];
|
|
403
443
|
hasPassword: boolean;
|
|
404
444
|
};
|
|
445
|
+
/**
|
|
446
|
+
* Result of removing one provider from the signed-in account.
|
|
447
|
+
*
|
|
448
|
+
* `removed` can exceed 1: an account may hold more than one row for a provider
|
|
449
|
+
* (distinct provider user ids across projects), and unlinking takes all of them
|
|
450
|
+
* — the floor counts them all as leaving, so reporting only one would be a lie
|
|
451
|
+
* a caller could act on.
|
|
452
|
+
*/
|
|
453
|
+
type UnlinkResult = {
|
|
454
|
+
provider: string;
|
|
455
|
+
removed: number;
|
|
456
|
+
remaining: number;
|
|
457
|
+
};
|
|
405
458
|
/**
|
|
406
459
|
* Closed set of auth failure categories (1.28.0). Branch on `kind` instead of
|
|
407
460
|
* string-matching the ~20 raw `code`s the backend and transport can produce.
|
|
@@ -411,7 +464,7 @@ type LinkedIdentitiesResponse = {
|
|
|
411
464
|
* OTP, a dismissed Game Center sheet and a 429 all leave `getUserSync()` and
|
|
412
465
|
* the stored refresh token exactly as they were.
|
|
413
466
|
*/
|
|
414
|
-
type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "already_identified" | "cancelled" | "config" | "unknown";
|
|
467
|
+
type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "already_identified" | "cancelled" | "config" | "unknown";
|
|
415
468
|
declare class AuthError extends Error {
|
|
416
469
|
code: string;
|
|
417
470
|
/**
|
|
@@ -517,6 +570,27 @@ declare class Auth {
|
|
|
517
570
|
private user;
|
|
518
571
|
private accessToken;
|
|
519
572
|
private accessExpiresAt;
|
|
573
|
+
/**
|
|
574
|
+
* Set once we have PROOF that this device's clock runs fast, which
|
|
575
|
+
* releases the `exp` guard in `canServeCachedToken`.
|
|
576
|
+
*
|
|
577
|
+
* The proof is specific and cannot be faked by a stale deadline: a token
|
|
578
|
+
* the server minted *seconds ago* reads as already past its own `exp`.
|
|
579
|
+
* A freshly-issued token is never genuinely expired, so the only
|
|
580
|
+
* explanation is the clock we measured it with.
|
|
581
|
+
*
|
|
582
|
+
* ⚠ NEVER PERSIST THIS. It is re-learned for the cost of one refresh per
|
|
583
|
+
* process, and a persisted "my clock is fast" belief would outlive the
|
|
584
|
+
* clock correction that made it false — which is precisely the stale-frame
|
|
585
|
+
* bug this whole guard exists to close, moved one level up.
|
|
586
|
+
*
|
|
587
|
+
* ⚠ Without this escape the `exp` guard alone is a REFRESH LOOP on any
|
|
588
|
+
* device whose clock is fast by more than the token TTL (default 60 min):
|
|
589
|
+
* every freshly-minted token would read as already expired, so every
|
|
590
|
+
* `getAccessToken()` would refresh, forever. That is why the fix is not
|
|
591
|
+
* simply "require `exp` too".
|
|
592
|
+
*/
|
|
593
|
+
private clockSkewConfirmed;
|
|
520
594
|
private inflight;
|
|
521
595
|
private refreshInflight;
|
|
522
596
|
/**
|
|
@@ -686,7 +760,11 @@ declare class Auth {
|
|
|
686
760
|
*
|
|
687
761
|
* Resolution order:
|
|
688
762
|
* 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
|
|
689
|
-
*
|
|
763
|
+
* before BOTH the tracked deadline and the token's own `exp`) →
|
|
764
|
+
* return it. Fast path, no I/O. See `canServeCachedToken` for why
|
|
765
|
+
* both are required and when the second is released.
|
|
766
|
+
* Pass `{ forceRefresh: true }` to skip this step when the caller
|
|
767
|
+
* knows the tracked deadline is wrong.
|
|
690
768
|
* 2. No cached token AND no refresh token in storage → genuinely
|
|
691
769
|
* signed out → null.
|
|
692
770
|
* 3. Expired/near-expiry but a refresh chain exists:
|
|
@@ -704,7 +782,62 @@ declare class Auth {
|
|
|
704
782
|
* the cooldown for an immediate retry on resume.
|
|
705
783
|
* c. Otherwise → fire the refresh and await it.
|
|
706
784
|
*/
|
|
707
|
-
|
|
785
|
+
/**
|
|
786
|
+
* The cached-token guard, and the single place the two deadlines are
|
|
787
|
+
* combined. Both must agree before a cached token is served:
|
|
788
|
+
*
|
|
789
|
+
* 1. the locally-tracked deadline (`Date.now() + expiresIn` at mint) —
|
|
790
|
+
* immune to a constantly-wrong clock, blind to a clock that MOVES;
|
|
791
|
+
* 2. the token's own `exp` — immune to a clock that moves, wrong by the
|
|
792
|
+
* full offset on a constantly-wrong clock.
|
|
793
|
+
*
|
|
794
|
+
* Guard 2 is released once `clockSkewConfirmed` proves the second failure
|
|
795
|
+
* mode is the one we are actually in.
|
|
796
|
+
*
|
|
797
|
+
* The gap this closes: `accessExpiresAt` is written in the device's clock
|
|
798
|
+
* frame at mint and PERSISTED. Step the clock backwards between mint and
|
|
799
|
+
* read — an iPhone acquiring automatic time after a long power-off, a
|
|
800
|
+
* restore, or a manual set — and the deadline is stale by the size of the
|
|
801
|
+
* correction, surviving relaunches. The SDK then serves a provably dead
|
|
802
|
+
* token, every request 401s, and because the fast path returns before
|
|
803
|
+
* reaching `persist()` or `wipeLocalIdentity()` nothing emits
|
|
804
|
+
* `signed_out`. Reported by Word Hurdle with a token 1929s past its `exp`.
|
|
805
|
+
*/
|
|
806
|
+
private canServeToken;
|
|
807
|
+
private canServeCachedToken;
|
|
808
|
+
/** Guard 1 alone: does the locally-tracked deadline still claim life? */
|
|
809
|
+
private trackedDeadlineAlive;
|
|
810
|
+
/**
|
|
811
|
+
* Refresh — and when the two deadlines disagree, let the token that comes
|
|
812
|
+
* back say which one was lying.
|
|
813
|
+
*
|
|
814
|
+
* ⚠ THE TIMING IS THE WHOLE MECHANISM, and the obvious placement is wrong.
|
|
815
|
+
* Learning at every mint site looks equivalent and is not: in the reported
|
|
816
|
+
* failure the token was MINTED while the clock was fast and only read after
|
|
817
|
+
* the correction, so a mint-time probe records "clock is fast", and the flag
|
|
818
|
+
* then waves through the very token the guard exists to refuse. Probing here
|
|
819
|
+
* — only on a refresh we performed BECAUSE `exp` contradicted a tracked
|
|
820
|
+
* deadline that still claimed life — separates the two cases, because by now
|
|
821
|
+
* the clock has either been corrected (fresh token reads live → stale
|
|
822
|
+
* deadline, guard holds) or is genuinely fast (fresh token still reads dead
|
|
823
|
+
* → release the guard, one probe, no loop).
|
|
824
|
+
*/
|
|
825
|
+
private refreshAndReconcile;
|
|
826
|
+
/**
|
|
827
|
+
* Record whether a token the server issued MOMENTS AGO already reads as
|
|
828
|
+
* expired. It cannot genuinely be — so a `false` here is proof the device
|
|
829
|
+
* clock runs fast, and releases the `exp` guard for this process.
|
|
830
|
+
*
|
|
831
|
+
* Call this at every site that installs a server-issued token, and only
|
|
832
|
+
* with a token that just arrived. Handing it a cached token would let a
|
|
833
|
+
* stale deadline confirm itself.
|
|
834
|
+
*/
|
|
835
|
+
private learnClockSkewFrom;
|
|
836
|
+
/** True when a cached token is unusable ONLY because its `exp` contradicts a live tracked deadline. */
|
|
837
|
+
private deadlinesDisagree;
|
|
838
|
+
getAccessToken(options?: {
|
|
839
|
+
forceRefresh?: boolean;
|
|
840
|
+
}): Promise<string | null>;
|
|
708
841
|
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
709
842
|
getAccessTokenSync(): string | null;
|
|
710
843
|
/**
|
|
@@ -914,6 +1047,29 @@ declare class Auth {
|
|
|
914
1047
|
* `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
|
|
915
1048
|
*/
|
|
916
1049
|
listLinkedIdentities(): Promise<LinkedIdentitiesResponse>;
|
|
1050
|
+
/**
|
|
1051
|
+
* Remove one linked provider from the signed-in account.
|
|
1052
|
+
*
|
|
1053
|
+
* The write half of `listLinkedIdentities()` — what a "Disconnect" button
|
|
1054
|
+
* next to each entry on a Connected Accounts screen calls.
|
|
1055
|
+
*
|
|
1056
|
+
* ⚠ The server REFUSES to remove the last way into the account, with
|
|
1057
|
+
* `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
|
|
1058
|
+
* account with no credentials still exists and still holds the user's data,
|
|
1059
|
+
* but nothing can ever authenticate into it again — so this is a permanent
|
|
1060
|
+
* lockout, not an inconvenience. A password counts as a credential.
|
|
1061
|
+
*
|
|
1062
|
+
* Prefer to prevent the tap rather than explain the refusal: read
|
|
1063
|
+
* `listLinkedIdentities()` and disable Disconnect when
|
|
1064
|
+
* `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
|
|
1065
|
+
* backstop, not the UX.
|
|
1066
|
+
*
|
|
1067
|
+
* Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
|
|
1068
|
+
* provider is not linked to this account. Removing an already-absent
|
|
1069
|
+
* provider is NOT treated as success — a silent no-op would let a buggy
|
|
1070
|
+
* screen report "disconnected" for a credential that is still attached.
|
|
1071
|
+
*/
|
|
1072
|
+
unlink(provider: string): Promise<UnlinkResult>;
|
|
917
1073
|
/**
|
|
918
1074
|
* Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
|
|
919
1075
|
* ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
|
|
@@ -1918,4 +2074,4 @@ declare class SendoraSDK {
|
|
|
1918
2074
|
}
|
|
1919
2075
|
declare const SendoraCloud: SendoraSDK;
|
|
1920
2076
|
|
|
1921
|
-
export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
|
|
2077
|
+
export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, type UnlinkResult, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
|
package/dist/index.d.ts
CHANGED
|
@@ -104,6 +104,29 @@ interface SendoraConfig {
|
|
|
104
104
|
* threat model. It is opt-in: adding it never changes an app that doesn't pass it.
|
|
105
105
|
*/
|
|
106
106
|
secureStorage?: SecureStorageAdapter;
|
|
107
|
+
/**
|
|
108
|
+
* Namespace every on-device key this SDK owns. Omit for the historical
|
|
109
|
+
* `sendora_` prefix — the default is byte-identical to prior releases.
|
|
110
|
+
*
|
|
111
|
+
* For apps that point one codebase at more than one Sendora project
|
|
112
|
+
* (staging vs production, a debug build beside a release build). Without
|
|
113
|
+
* it, both environments share `sendora_*` on the same device: whichever
|
|
114
|
+
* launched last owns the session, and a debug sign-in can evict the
|
|
115
|
+
* release build's. A custom `secureStorage` adapter only fixes the half
|
|
116
|
+
* that goes through the adapter — `auth_access_expires` and the anon id
|
|
117
|
+
* stay in AsyncStorage and still collide.
|
|
118
|
+
*
|
|
119
|
+
* ⚠ CHANGING THIS SIGNS THE DEVICE OUT. Keys are the session's only
|
|
120
|
+
* durable handle, and the SDK does not migrate between prefixes — a new
|
|
121
|
+
* prefix reads an empty store and mints a fresh anonymous user. Pick one
|
|
122
|
+
* per environment before shipping and never change it afterwards. This is
|
|
123
|
+
* the same frozen-key rule as ADR-023, delegated to you.
|
|
124
|
+
*
|
|
125
|
+
* ⚠ Applies to the `secureStorage` adapter too when set (adapter keys are
|
|
126
|
+
* bare by default). If you already namespace inside your own adapter,
|
|
127
|
+
* either drop that and use this, or expect `<yours><ours>key`.
|
|
128
|
+
*/
|
|
129
|
+
storagePrefix?: string;
|
|
107
130
|
}
|
|
108
131
|
/**
|
|
109
132
|
* Async key/value adapter for encrypted-at-rest auth storage. Compatible with a
|
|
@@ -178,13 +201,30 @@ type StorageOptions = {
|
|
|
178
201
|
secureAdapter?: SecureStorageAdapter | null;
|
|
179
202
|
/** Keys routed through `secureAdapter` when it is present. Everything else stays in AsyncStorage. */
|
|
180
203
|
secureKeys?: Iterable<string>;
|
|
204
|
+
/**
|
|
205
|
+
* Namespace for every key this SDK owns. Omit for the default `sendora_`.
|
|
206
|
+
*
|
|
207
|
+
* ⚠ Asymmetric by necessity, and the asymmetry is what keeps upgrades
|
|
208
|
+
* safe. When omitted, AsyncStorage keys keep the historical `sendora_`
|
|
209
|
+
* prefix and secure-adapter keys stay BARE — exactly what shipped before
|
|
210
|
+
* this option existed, so no installed app loses its session on upgrade.
|
|
211
|
+
* When supplied, the prefix applies to BOTH stores, because the whole
|
|
212
|
+
* point is that two environments sharing one device share nothing.
|
|
213
|
+
*/
|
|
214
|
+
prefix?: string;
|
|
181
215
|
};
|
|
182
216
|
declare class Storage {
|
|
183
217
|
private cache;
|
|
184
218
|
private hydrated;
|
|
185
219
|
private readonly secure;
|
|
186
220
|
private readonly secureKeys;
|
|
221
|
+
/** Applied to AsyncStorage keys. */
|
|
222
|
+
private readonly prefix;
|
|
223
|
+
/** Applied to secure-adapter keys — empty unless a custom prefix was set. */
|
|
224
|
+
private readonly securePrefix;
|
|
187
225
|
constructor(opts?: StorageOptions);
|
|
226
|
+
/** The adapter-facing name for a key. Bare by default; see `prefix`. */
|
|
227
|
+
private secureName;
|
|
188
228
|
private isSecure;
|
|
189
229
|
/**
|
|
190
230
|
* Populate the memory cache from persistent storage. Must be awaited
|
|
@@ -402,6 +442,19 @@ type LinkedIdentitiesResponse = {
|
|
|
402
442
|
identities: LinkedIdentity[];
|
|
403
443
|
hasPassword: boolean;
|
|
404
444
|
};
|
|
445
|
+
/**
|
|
446
|
+
* Result of removing one provider from the signed-in account.
|
|
447
|
+
*
|
|
448
|
+
* `removed` can exceed 1: an account may hold more than one row for a provider
|
|
449
|
+
* (distinct provider user ids across projects), and unlinking takes all of them
|
|
450
|
+
* — the floor counts them all as leaving, so reporting only one would be a lie
|
|
451
|
+
* a caller could act on.
|
|
452
|
+
*/
|
|
453
|
+
type UnlinkResult = {
|
|
454
|
+
provider: string;
|
|
455
|
+
removed: number;
|
|
456
|
+
remaining: number;
|
|
457
|
+
};
|
|
405
458
|
/**
|
|
406
459
|
* Closed set of auth failure categories (1.28.0). Branch on `kind` instead of
|
|
407
460
|
* string-matching the ~20 raw `code`s the backend and transport can produce.
|
|
@@ -411,7 +464,7 @@ type LinkedIdentitiesResponse = {
|
|
|
411
464
|
* OTP, a dismissed Game Center sheet and a 429 all leave `getUserSync()` and
|
|
412
465
|
* the stored refresh token exactly as they were.
|
|
413
466
|
*/
|
|
414
|
-
type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "already_identified" | "cancelled" | "config" | "unknown";
|
|
467
|
+
type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "already_identified" | "cancelled" | "config" | "unknown";
|
|
415
468
|
declare class AuthError extends Error {
|
|
416
469
|
code: string;
|
|
417
470
|
/**
|
|
@@ -517,6 +570,27 @@ declare class Auth {
|
|
|
517
570
|
private user;
|
|
518
571
|
private accessToken;
|
|
519
572
|
private accessExpiresAt;
|
|
573
|
+
/**
|
|
574
|
+
* Set once we have PROOF that this device's clock runs fast, which
|
|
575
|
+
* releases the `exp` guard in `canServeCachedToken`.
|
|
576
|
+
*
|
|
577
|
+
* The proof is specific and cannot be faked by a stale deadline: a token
|
|
578
|
+
* the server minted *seconds ago* reads as already past its own `exp`.
|
|
579
|
+
* A freshly-issued token is never genuinely expired, so the only
|
|
580
|
+
* explanation is the clock we measured it with.
|
|
581
|
+
*
|
|
582
|
+
* ⚠ NEVER PERSIST THIS. It is re-learned for the cost of one refresh per
|
|
583
|
+
* process, and a persisted "my clock is fast" belief would outlive the
|
|
584
|
+
* clock correction that made it false — which is precisely the stale-frame
|
|
585
|
+
* bug this whole guard exists to close, moved one level up.
|
|
586
|
+
*
|
|
587
|
+
* ⚠ Without this escape the `exp` guard alone is a REFRESH LOOP on any
|
|
588
|
+
* device whose clock is fast by more than the token TTL (default 60 min):
|
|
589
|
+
* every freshly-minted token would read as already expired, so every
|
|
590
|
+
* `getAccessToken()` would refresh, forever. That is why the fix is not
|
|
591
|
+
* simply "require `exp` too".
|
|
592
|
+
*/
|
|
593
|
+
private clockSkewConfirmed;
|
|
520
594
|
private inflight;
|
|
521
595
|
private refreshInflight;
|
|
522
596
|
/**
|
|
@@ -686,7 +760,11 @@ declare class Auth {
|
|
|
686
760
|
*
|
|
687
761
|
* Resolution order:
|
|
688
762
|
* 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
|
|
689
|
-
*
|
|
763
|
+
* before BOTH the tracked deadline and the token's own `exp`) →
|
|
764
|
+
* return it. Fast path, no I/O. See `canServeCachedToken` for why
|
|
765
|
+
* both are required and when the second is released.
|
|
766
|
+
* Pass `{ forceRefresh: true }` to skip this step when the caller
|
|
767
|
+
* knows the tracked deadline is wrong.
|
|
690
768
|
* 2. No cached token AND no refresh token in storage → genuinely
|
|
691
769
|
* signed out → null.
|
|
692
770
|
* 3. Expired/near-expiry but a refresh chain exists:
|
|
@@ -704,7 +782,62 @@ declare class Auth {
|
|
|
704
782
|
* the cooldown for an immediate retry on resume.
|
|
705
783
|
* c. Otherwise → fire the refresh and await it.
|
|
706
784
|
*/
|
|
707
|
-
|
|
785
|
+
/**
|
|
786
|
+
* The cached-token guard, and the single place the two deadlines are
|
|
787
|
+
* combined. Both must agree before a cached token is served:
|
|
788
|
+
*
|
|
789
|
+
* 1. the locally-tracked deadline (`Date.now() + expiresIn` at mint) —
|
|
790
|
+
* immune to a constantly-wrong clock, blind to a clock that MOVES;
|
|
791
|
+
* 2. the token's own `exp` — immune to a clock that moves, wrong by the
|
|
792
|
+
* full offset on a constantly-wrong clock.
|
|
793
|
+
*
|
|
794
|
+
* Guard 2 is released once `clockSkewConfirmed` proves the second failure
|
|
795
|
+
* mode is the one we are actually in.
|
|
796
|
+
*
|
|
797
|
+
* The gap this closes: `accessExpiresAt` is written in the device's clock
|
|
798
|
+
* frame at mint and PERSISTED. Step the clock backwards between mint and
|
|
799
|
+
* read — an iPhone acquiring automatic time after a long power-off, a
|
|
800
|
+
* restore, or a manual set — and the deadline is stale by the size of the
|
|
801
|
+
* correction, surviving relaunches. The SDK then serves a provably dead
|
|
802
|
+
* token, every request 401s, and because the fast path returns before
|
|
803
|
+
* reaching `persist()` or `wipeLocalIdentity()` nothing emits
|
|
804
|
+
* `signed_out`. Reported by Word Hurdle with a token 1929s past its `exp`.
|
|
805
|
+
*/
|
|
806
|
+
private canServeToken;
|
|
807
|
+
private canServeCachedToken;
|
|
808
|
+
/** Guard 1 alone: does the locally-tracked deadline still claim life? */
|
|
809
|
+
private trackedDeadlineAlive;
|
|
810
|
+
/**
|
|
811
|
+
* Refresh — and when the two deadlines disagree, let the token that comes
|
|
812
|
+
* back say which one was lying.
|
|
813
|
+
*
|
|
814
|
+
* ⚠ THE TIMING IS THE WHOLE MECHANISM, and the obvious placement is wrong.
|
|
815
|
+
* Learning at every mint site looks equivalent and is not: in the reported
|
|
816
|
+
* failure the token was MINTED while the clock was fast and only read after
|
|
817
|
+
* the correction, so a mint-time probe records "clock is fast", and the flag
|
|
818
|
+
* then waves through the very token the guard exists to refuse. Probing here
|
|
819
|
+
* — only on a refresh we performed BECAUSE `exp` contradicted a tracked
|
|
820
|
+
* deadline that still claimed life — separates the two cases, because by now
|
|
821
|
+
* the clock has either been corrected (fresh token reads live → stale
|
|
822
|
+
* deadline, guard holds) or is genuinely fast (fresh token still reads dead
|
|
823
|
+
* → release the guard, one probe, no loop).
|
|
824
|
+
*/
|
|
825
|
+
private refreshAndReconcile;
|
|
826
|
+
/**
|
|
827
|
+
* Record whether a token the server issued MOMENTS AGO already reads as
|
|
828
|
+
* expired. It cannot genuinely be — so a `false` here is proof the device
|
|
829
|
+
* clock runs fast, and releases the `exp` guard for this process.
|
|
830
|
+
*
|
|
831
|
+
* Call this at every site that installs a server-issued token, and only
|
|
832
|
+
* with a token that just arrived. Handing it a cached token would let a
|
|
833
|
+
* stale deadline confirm itself.
|
|
834
|
+
*/
|
|
835
|
+
private learnClockSkewFrom;
|
|
836
|
+
/** True when a cached token is unusable ONLY because its `exp` contradicts a live tracked deadline. */
|
|
837
|
+
private deadlinesDisagree;
|
|
838
|
+
getAccessToken(options?: {
|
|
839
|
+
forceRefresh?: boolean;
|
|
840
|
+
}): Promise<string | null>;
|
|
708
841
|
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
709
842
|
getAccessTokenSync(): string | null;
|
|
710
843
|
/**
|
|
@@ -914,6 +1047,29 @@ declare class Auth {
|
|
|
914
1047
|
* `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
|
|
915
1048
|
*/
|
|
916
1049
|
listLinkedIdentities(): Promise<LinkedIdentitiesResponse>;
|
|
1050
|
+
/**
|
|
1051
|
+
* Remove one linked provider from the signed-in account.
|
|
1052
|
+
*
|
|
1053
|
+
* The write half of `listLinkedIdentities()` — what a "Disconnect" button
|
|
1054
|
+
* next to each entry on a Connected Accounts screen calls.
|
|
1055
|
+
*
|
|
1056
|
+
* ⚠ The server REFUSES to remove the last way into the account, with
|
|
1057
|
+
* `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
|
|
1058
|
+
* account with no credentials still exists and still holds the user's data,
|
|
1059
|
+
* but nothing can ever authenticate into it again — so this is a permanent
|
|
1060
|
+
* lockout, not an inconvenience. A password counts as a credential.
|
|
1061
|
+
*
|
|
1062
|
+
* Prefer to prevent the tap rather than explain the refusal: read
|
|
1063
|
+
* `listLinkedIdentities()` and disable Disconnect when
|
|
1064
|
+
* `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
|
|
1065
|
+
* backstop, not the UX.
|
|
1066
|
+
*
|
|
1067
|
+
* Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
|
|
1068
|
+
* provider is not linked to this account. Removing an already-absent
|
|
1069
|
+
* provider is NOT treated as success — a silent no-op would let a buggy
|
|
1070
|
+
* screen report "disconnected" for a credential that is still attached.
|
|
1071
|
+
*/
|
|
1072
|
+
unlink(provider: string): Promise<UnlinkResult>;
|
|
917
1073
|
/**
|
|
918
1074
|
* Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
|
|
919
1075
|
* ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
|
|
@@ -1918,4 +2074,4 @@ declare class SendoraSDK {
|
|
|
1918
2074
|
}
|
|
1919
2075
|
declare const SendoraCloud: SendoraSDK;
|
|
1920
2076
|
|
|
1921
|
-
export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
|
|
2077
|
+
export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, type UnlinkResult, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import "react-native-get-random-values";
|
|
|
3
3
|
import { Platform } from "react-native";
|
|
4
4
|
|
|
5
5
|
// src/storage.ts
|
|
6
|
-
var
|
|
6
|
+
var DEFAULT_PREFIX = "sendora_";
|
|
7
7
|
var asyncStoragePromise = null;
|
|
8
8
|
async function getAsyncStorage() {
|
|
9
9
|
if (asyncStoragePromise) return asyncStoragePromise;
|
|
@@ -26,6 +26,12 @@ var Storage = class {
|
|
|
26
26
|
this.hydrated = false;
|
|
27
27
|
this.secure = opts.secureAdapter ?? null;
|
|
28
28
|
this.secureKeys = new Set(opts.secureKeys ?? []);
|
|
29
|
+
this.prefix = opts.prefix ?? DEFAULT_PREFIX;
|
|
30
|
+
this.securePrefix = opts.prefix ?? "";
|
|
31
|
+
}
|
|
32
|
+
/** The adapter-facing name for a key. Bare by default; see `prefix`. */
|
|
33
|
+
secureName(key) {
|
|
34
|
+
return this.securePrefix + key;
|
|
29
35
|
}
|
|
30
36
|
isSecure(key) {
|
|
31
37
|
return this.secure !== null && this.secureKeys.has(key);
|
|
@@ -46,7 +52,7 @@ var Storage = class {
|
|
|
46
52
|
await this.hydrateSecureKey(k, store);
|
|
47
53
|
} else if (store) {
|
|
48
54
|
try {
|
|
49
|
-
const v = await store.getItem(
|
|
55
|
+
const v = await store.getItem(this.prefix + k);
|
|
50
56
|
if (v !== null) this.cache.set(k, v);
|
|
51
57
|
} catch {
|
|
52
58
|
}
|
|
@@ -57,18 +63,18 @@ var Storage = class {
|
|
|
57
63
|
/** Read a secure key: adapter first, else migrate legacy plaintext. Never throws. */
|
|
58
64
|
async hydrateSecureKey(k, store) {
|
|
59
65
|
try {
|
|
60
|
-
const secureVal = await this.secure.getItem(k);
|
|
66
|
+
const secureVal = await this.secure.getItem(this.secureName(k));
|
|
61
67
|
if (secureVal !== null) {
|
|
62
68
|
this.cache.set(k, secureVal);
|
|
63
69
|
return;
|
|
64
70
|
}
|
|
65
71
|
if (store) {
|
|
66
|
-
const legacy = await store.getItem(
|
|
72
|
+
const legacy = await store.getItem(this.prefix + k).catch(() => null);
|
|
67
73
|
if (legacy !== null) {
|
|
68
74
|
this.cache.set(k, legacy);
|
|
69
75
|
try {
|
|
70
|
-
await this.secure.setItem(k, legacy);
|
|
71
|
-
await store.removeItem(
|
|
76
|
+
await this.secure.setItem(this.secureName(k), legacy);
|
|
77
|
+
await store.removeItem(this.prefix + k).catch(() => {
|
|
72
78
|
});
|
|
73
79
|
} catch {
|
|
74
80
|
}
|
|
@@ -77,7 +83,7 @@ var Storage = class {
|
|
|
77
83
|
} catch {
|
|
78
84
|
if (store) {
|
|
79
85
|
try {
|
|
80
|
-
const legacy = await store.getItem(
|
|
86
|
+
const legacy = await store.getItem(this.prefix + k);
|
|
81
87
|
if (legacy !== null) this.cache.set(k, legacy);
|
|
82
88
|
} catch {
|
|
83
89
|
}
|
|
@@ -90,27 +96,27 @@ var Storage = class {
|
|
|
90
96
|
set(key, value) {
|
|
91
97
|
this.cache.set(key, value);
|
|
92
98
|
if (this.isSecure(key)) {
|
|
93
|
-
void this.secure.setItem(key, value).catch(() => {
|
|
99
|
+
void this.secure.setItem(this.secureName(key), value).catch(() => {
|
|
94
100
|
void getAsyncStorage().then((store) => {
|
|
95
|
-
if (store) store.setItem(
|
|
101
|
+
if (store) store.setItem(this.prefix + key, value).catch(() => {
|
|
96
102
|
});
|
|
97
103
|
});
|
|
98
104
|
});
|
|
99
105
|
return;
|
|
100
106
|
}
|
|
101
107
|
void getAsyncStorage().then((store) => {
|
|
102
|
-
if (store) store.setItem(
|
|
108
|
+
if (store) store.setItem(this.prefix + key, value).catch(() => {
|
|
103
109
|
});
|
|
104
110
|
});
|
|
105
111
|
}
|
|
106
112
|
remove(key) {
|
|
107
113
|
this.cache.delete(key);
|
|
108
114
|
if (this.isSecure(key)) {
|
|
109
|
-
void this.secure.removeItem(key).catch(() => {
|
|
115
|
+
void this.secure.removeItem(this.secureName(key)).catch(() => {
|
|
110
116
|
});
|
|
111
117
|
}
|
|
112
118
|
void getAsyncStorage().then((store) => {
|
|
113
|
-
if (store) store.removeItem(
|
|
119
|
+
if (store) store.removeItem(this.prefix + key).catch(() => {
|
|
114
120
|
});
|
|
115
121
|
});
|
|
116
122
|
}
|
|
@@ -126,7 +132,7 @@ var Storage = class {
|
|
|
126
132
|
if (this.secure) {
|
|
127
133
|
for (const k of this.secureKeys) {
|
|
128
134
|
try {
|
|
129
|
-
await this.secure.removeItem(k);
|
|
135
|
+
await this.secure.removeItem(this.secureName(k));
|
|
130
136
|
} catch {
|
|
131
137
|
}
|
|
132
138
|
}
|
|
@@ -137,13 +143,13 @@ var Storage = class {
|
|
|
137
143
|
if (typeof store.getAllKeys === "function") {
|
|
138
144
|
try {
|
|
139
145
|
const all = await store.getAllKeys();
|
|
140
|
-
keys = all.filter((k) => k.startsWith(
|
|
146
|
+
keys = all.filter((k) => k.startsWith(this.prefix));
|
|
141
147
|
} catch {
|
|
142
148
|
keys = [];
|
|
143
149
|
}
|
|
144
150
|
}
|
|
145
151
|
if (keys.length === 0) {
|
|
146
|
-
keys = Array.from(this.secureKeys).map((k) =>
|
|
152
|
+
keys = Array.from(this.secureKeys).map((k) => this.prefix + k);
|
|
147
153
|
}
|
|
148
154
|
for (const k of keys) {
|
|
149
155
|
try {
|
|
@@ -157,7 +163,7 @@ var Storage = class {
|
|
|
157
163
|
// package.json
|
|
158
164
|
var package_default = {
|
|
159
165
|
name: "@sendoracloud/sdk-react-native",
|
|
160
|
-
version: "1.
|
|
166
|
+
version: "1.35.0",
|
|
161
167
|
description: "Sendora Cloud React Native + Expo SDK \u2014 analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
|
|
162
168
|
type: "module",
|
|
163
169
|
main: "./dist/index.cjs",
|
|
@@ -213,7 +219,7 @@ var package_default = {
|
|
|
213
219
|
scripts: {
|
|
214
220
|
build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
215
221
|
typecheck: "tsc --noEmit",
|
|
216
|
-
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts"
|
|
222
|
+
test: "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts"
|
|
217
223
|
},
|
|
218
224
|
devDependencies: {
|
|
219
225
|
"@types/react": "^19.2.16",
|
|
@@ -443,6 +449,8 @@ function classifyAuthErrorKind(code, status) {
|
|
|
443
449
|
return "account_locked";
|
|
444
450
|
case "CREDENTIAL_IN_USE":
|
|
445
451
|
return "credential_in_use";
|
|
452
|
+
case "LAST_CREDENTIAL":
|
|
453
|
+
return "last_credential";
|
|
446
454
|
case "NOT_ANONYMOUS":
|
|
447
455
|
case "FORBIDDEN_NON_ANONYMOUS":
|
|
448
456
|
return "already_identified";
|
|
@@ -550,6 +558,27 @@ var Auth = class {
|
|
|
550
558
|
this.user = null;
|
|
551
559
|
this.accessToken = null;
|
|
552
560
|
this.accessExpiresAt = 0;
|
|
561
|
+
/**
|
|
562
|
+
* Set once we have PROOF that this device's clock runs fast, which
|
|
563
|
+
* releases the `exp` guard in `canServeCachedToken`.
|
|
564
|
+
*
|
|
565
|
+
* The proof is specific and cannot be faked by a stale deadline: a token
|
|
566
|
+
* the server minted *seconds ago* reads as already past its own `exp`.
|
|
567
|
+
* A freshly-issued token is never genuinely expired, so the only
|
|
568
|
+
* explanation is the clock we measured it with.
|
|
569
|
+
*
|
|
570
|
+
* ⚠ NEVER PERSIST THIS. It is re-learned for the cost of one refresh per
|
|
571
|
+
* process, and a persisted "my clock is fast" belief would outlive the
|
|
572
|
+
* clock correction that made it false — which is precisely the stale-frame
|
|
573
|
+
* bug this whole guard exists to close, moved one level up.
|
|
574
|
+
*
|
|
575
|
+
* ⚠ Without this escape the `exp` guard alone is a REFRESH LOOP on any
|
|
576
|
+
* device whose clock is fast by more than the token TTL (default 60 min):
|
|
577
|
+
* every freshly-minted token would read as already expired, so every
|
|
578
|
+
* `getAccessToken()` would refresh, forever. That is why the fix is not
|
|
579
|
+
* simply "require `exp` too".
|
|
580
|
+
*/
|
|
581
|
+
this.clockSkewConfirmed = false;
|
|
553
582
|
this.inflight = Promise.resolve();
|
|
554
583
|
this.refreshInflight = null;
|
|
555
584
|
/**
|
|
@@ -641,6 +670,7 @@ var Auth = class {
|
|
|
641
670
|
}
|
|
642
671
|
}
|
|
643
672
|
this.hydrated = true;
|
|
673
|
+
if (this.user && this.hooks.storage.get(REFRESH_KEY)) this.startProactiveRefreshCron();
|
|
644
674
|
this.resolveReady();
|
|
645
675
|
}
|
|
646
676
|
getCurrentUser() {
|
|
@@ -802,7 +832,11 @@ var Auth = class {
|
|
|
802
832
|
*
|
|
803
833
|
* Resolution order:
|
|
804
834
|
* 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
|
|
805
|
-
*
|
|
835
|
+
* before BOTH the tracked deadline and the token's own `exp`) →
|
|
836
|
+
* return it. Fast path, no I/O. See `canServeCachedToken` for why
|
|
837
|
+
* both are required and when the second is released.
|
|
838
|
+
* Pass `{ forceRefresh: true }` to skip this step when the caller
|
|
839
|
+
* knows the tracked deadline is wrong.
|
|
806
840
|
* 2. No cached token AND no refresh token in storage → genuinely
|
|
807
841
|
* signed out → null.
|
|
808
842
|
* 3. Expired/near-expiry but a refresh chain exists:
|
|
@@ -820,14 +854,85 @@ var Auth = class {
|
|
|
820
854
|
* the cooldown for an immediate retry on resume.
|
|
821
855
|
* c. Otherwise → fire the refresh and await it.
|
|
822
856
|
*/
|
|
823
|
-
|
|
824
|
-
|
|
857
|
+
/**
|
|
858
|
+
* The cached-token guard, and the single place the two deadlines are
|
|
859
|
+
* combined. Both must agree before a cached token is served:
|
|
860
|
+
*
|
|
861
|
+
* 1. the locally-tracked deadline (`Date.now() + expiresIn` at mint) —
|
|
862
|
+
* immune to a constantly-wrong clock, blind to a clock that MOVES;
|
|
863
|
+
* 2. the token's own `exp` — immune to a clock that moves, wrong by the
|
|
864
|
+
* full offset on a constantly-wrong clock.
|
|
865
|
+
*
|
|
866
|
+
* Guard 2 is released once `clockSkewConfirmed` proves the second failure
|
|
867
|
+
* mode is the one we are actually in.
|
|
868
|
+
*
|
|
869
|
+
* The gap this closes: `accessExpiresAt` is written in the device's clock
|
|
870
|
+
* frame at mint and PERSISTED. Step the clock backwards between mint and
|
|
871
|
+
* read — an iPhone acquiring automatic time after a long power-off, a
|
|
872
|
+
* restore, or a manual set — and the deadline is stale by the size of the
|
|
873
|
+
* correction, surviving relaunches. The SDK then serves a provably dead
|
|
874
|
+
* token, every request 401s, and because the fast path returns before
|
|
875
|
+
* reaching `persist()` or `wipeLocalIdentity()` nothing emits
|
|
876
|
+
* `signed_out`. Reported by Word Hurdle with a token 1929s past its `exp`.
|
|
877
|
+
*/
|
|
878
|
+
canServeToken(token, expiresAt, nowMs) {
|
|
879
|
+
if (!token || !expiresAt) return false;
|
|
880
|
+
if (nowMs >= expiresAt - REFRESH_SAFETY_MS) return false;
|
|
881
|
+
if (this.clockSkewConfirmed) return true;
|
|
882
|
+
return accessTokenExpIsFuture(token, nowMs);
|
|
883
|
+
}
|
|
884
|
+
canServeCachedToken(nowMs) {
|
|
885
|
+
return this.canServeToken(this.accessToken, this.accessExpiresAt, nowMs);
|
|
886
|
+
}
|
|
887
|
+
/** Guard 1 alone: does the locally-tracked deadline still claim life? */
|
|
888
|
+
trackedDeadlineAlive(nowMs) {
|
|
889
|
+
return !!this.accessToken && !!this.accessExpiresAt && nowMs < this.accessExpiresAt - REFRESH_SAFETY_MS;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Refresh — and when the two deadlines disagree, let the token that comes
|
|
893
|
+
* back say which one was lying.
|
|
894
|
+
*
|
|
895
|
+
* ⚠ THE TIMING IS THE WHOLE MECHANISM, and the obvious placement is wrong.
|
|
896
|
+
* Learning at every mint site looks equivalent and is not: in the reported
|
|
897
|
+
* failure the token was MINTED while the clock was fast and only read after
|
|
898
|
+
* the correction, so a mint-time probe records "clock is fast", and the flag
|
|
899
|
+
* then waves through the very token the guard exists to refuse. Probing here
|
|
900
|
+
* — only on a refresh we performed BECAUSE `exp` contradicted a tracked
|
|
901
|
+
* deadline that still claimed life — separates the two cases, because by now
|
|
902
|
+
* the clock has either been corrected (fresh token reads live → stale
|
|
903
|
+
* deadline, guard holds) or is genuinely fast (fresh token still reads dead
|
|
904
|
+
* → release the guard, one probe, no loop).
|
|
905
|
+
*/
|
|
906
|
+
async refreshAndReconcile(nowMs) {
|
|
907
|
+
const reconciling = this.deadlinesDisagree(nowMs);
|
|
908
|
+
const token = await this.refreshAccessToken();
|
|
909
|
+
if (reconciling && token) this.learnClockSkewFrom(token, Date.now());
|
|
910
|
+
return token;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Record whether a token the server issued MOMENTS AGO already reads as
|
|
914
|
+
* expired. It cannot genuinely be — so a `false` here is proof the device
|
|
915
|
+
* clock runs fast, and releases the `exp` guard for this process.
|
|
916
|
+
*
|
|
917
|
+
* Call this at every site that installs a server-issued token, and only
|
|
918
|
+
* with a token that just arrived. Handing it a cached token would let a
|
|
919
|
+
* stale deadline confirm itself.
|
|
920
|
+
*/
|
|
921
|
+
learnClockSkewFrom(freshToken, nowMs) {
|
|
922
|
+
if (!accessTokenExpIsFuture(freshToken, nowMs)) this.clockSkewConfirmed = true;
|
|
923
|
+
}
|
|
924
|
+
/** True when a cached token is unusable ONLY because its `exp` contradicts a live tracked deadline. */
|
|
925
|
+
deadlinesDisagree(nowMs) {
|
|
926
|
+
return this.trackedDeadlineAlive(nowMs) && !this.canServeCachedToken(nowMs);
|
|
927
|
+
}
|
|
928
|
+
async getAccessToken(options) {
|
|
929
|
+
if (!options?.forceRefresh && this.canServeCachedToken(Date.now())) {
|
|
825
930
|
return this.accessToken;
|
|
826
931
|
}
|
|
827
932
|
if (!this.hooks.storage.get(REFRESH_KEY)) return null;
|
|
828
933
|
if (this.refreshInflight) return this.refreshInflight;
|
|
829
934
|
if (Date.now() < this.refreshCooldownUntil) return null;
|
|
830
|
-
return this.
|
|
935
|
+
return this.refreshAndReconcile(Date.now());
|
|
831
936
|
}
|
|
832
937
|
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
833
938
|
getAccessTokenSync() {
|
|
@@ -1185,6 +1290,56 @@ var Auth = class {
|
|
|
1185
1290
|
}
|
|
1186
1291
|
return { identities: parsed.data.identities ?? [], hasPassword: Boolean(parsed.data.hasPassword) };
|
|
1187
1292
|
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Remove one linked provider from the signed-in account.
|
|
1295
|
+
*
|
|
1296
|
+
* The write half of `listLinkedIdentities()` — what a "Disconnect" button
|
|
1297
|
+
* next to each entry on a Connected Accounts screen calls.
|
|
1298
|
+
*
|
|
1299
|
+
* ⚠ The server REFUSES to remove the last way into the account, with
|
|
1300
|
+
* `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"`. An
|
|
1301
|
+
* account with no credentials still exists and still holds the user's data,
|
|
1302
|
+
* but nothing can ever authenticate into it again — so this is a permanent
|
|
1303
|
+
* lockout, not an inconvenience. A password counts as a credential.
|
|
1304
|
+
*
|
|
1305
|
+
* Prefer to prevent the tap rather than explain the refusal: read
|
|
1306
|
+
* `listLinkedIdentities()` and disable Disconnect when
|
|
1307
|
+
* `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
|
|
1308
|
+
* backstop, not the UX.
|
|
1309
|
+
*
|
|
1310
|
+
* Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
|
|
1311
|
+
* provider is not linked to this account. Removing an already-absent
|
|
1312
|
+
* provider is NOT treated as success — a silent no-op would let a buggy
|
|
1313
|
+
* screen report "disconnected" for a credential that is still attached.
|
|
1314
|
+
*/
|
|
1315
|
+
async unlink(provider) {
|
|
1316
|
+
const token = await this.getAccessToken();
|
|
1317
|
+
if (!token) throw new AuthError("NOT_SIGNED_IN", "Sign in before unlinking an identity");
|
|
1318
|
+
const res = await del(
|
|
1319
|
+
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
1320
|
+
`/api/v1/auth-service/me/identities/${encodeURIComponent(provider)}`,
|
|
1321
|
+
{ Authorization: `Bearer ${token}` }
|
|
1322
|
+
);
|
|
1323
|
+
if (!res) throw new AuthError("NETWORK_ERROR", "Failed to unlink identity");
|
|
1324
|
+
let parsed;
|
|
1325
|
+
try {
|
|
1326
|
+
parsed = await res.json();
|
|
1327
|
+
} catch {
|
|
1328
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`);
|
|
1329
|
+
}
|
|
1330
|
+
if (!parsed.success || !parsed.data) {
|
|
1331
|
+
throw new AuthError(
|
|
1332
|
+
parsed.error?.code ?? "UNLINK_FAILED",
|
|
1333
|
+
parsed.error?.message ?? "Failed to unlink identity",
|
|
1334
|
+
{ status: res.status }
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
return {
|
|
1338
|
+
provider: parsed.data.provider ?? provider,
|
|
1339
|
+
removed: Number(parsed.data.removed ?? 0),
|
|
1340
|
+
remaining: Number(parsed.data.remaining ?? 0)
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1188
1343
|
/**
|
|
1189
1344
|
* Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
|
|
1190
1345
|
* ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
|
|
@@ -1644,7 +1799,7 @@ var Auth = class {
|
|
|
1644
1799
|
const freshExpiresStr = this.hooks.storage.get(TOKEN_EXPIRES_KEY);
|
|
1645
1800
|
if (freshAccess && freshExpiresStr) {
|
|
1646
1801
|
const freshExpires = Number(freshExpiresStr);
|
|
1647
|
-
if (Number.isFinite(freshExpires) && freshExpires
|
|
1802
|
+
if (Number.isFinite(freshExpires) && this.canServeToken(freshAccess, freshExpires, Date.now())) {
|
|
1648
1803
|
this.accessToken = freshAccess;
|
|
1649
1804
|
this.accessExpiresAt = freshExpires;
|
|
1650
1805
|
this.refreshFailureCount = 0;
|
|
@@ -1711,6 +1866,10 @@ var Auth = class {
|
|
|
1711
1866
|
const tick = async () => {
|
|
1712
1867
|
if (!this.accessToken || !this.accessExpiresAt) return;
|
|
1713
1868
|
const now = Date.now();
|
|
1869
|
+
if (!this.canServeCachedToken(now)) {
|
|
1870
|
+
await this.refreshAndReconcile(now);
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1714
1873
|
const lifetimeMs = this.accessExpiresAt - now;
|
|
1715
1874
|
if (lifetimeMs <= 0) return;
|
|
1716
1875
|
const guessOriginalMs = Math.max(lifetimeMs, 5 * 6e4);
|
|
@@ -1893,6 +2052,7 @@ var Auth = class {
|
|
|
1893
2052
|
this.user = null;
|
|
1894
2053
|
this.accessToken = null;
|
|
1895
2054
|
this.accessExpiresAt = 0;
|
|
2055
|
+
this.clockSkewConfirmed = false;
|
|
1896
2056
|
this.lastTakeover = null;
|
|
1897
2057
|
this.lastDeletionCancelled = null;
|
|
1898
2058
|
this.lastAnonRetirement = null;
|
|
@@ -1921,6 +2081,13 @@ function readRotatedTokens(data) {
|
|
|
1921
2081
|
if (typeof t.expiresIn !== "number" || t.expiresIn <= 0) return null;
|
|
1922
2082
|
return { accessToken: t.accessToken, refreshToken: t.refreshToken, expiresIn: t.expiresIn };
|
|
1923
2083
|
}
|
|
2084
|
+
function accessTokenExpIsFuture(token, nowMs) {
|
|
2085
|
+
const claims = decodeJwtPayload(token);
|
|
2086
|
+
if (!claims) return true;
|
|
2087
|
+
const exp = claims.exp;
|
|
2088
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) return true;
|
|
2089
|
+
return nowMs < exp * 1e3;
|
|
2090
|
+
}
|
|
1924
2091
|
function decodeJwtPayload(token) {
|
|
1925
2092
|
try {
|
|
1926
2093
|
const parts = token.split(".");
|
|
@@ -2615,6 +2782,16 @@ var AUTH_HYDRATE_KEYS = [
|
|
|
2615
2782
|
];
|
|
2616
2783
|
var SECURE_KEYS = ["auth_refresh_token", "auth_access_token", "auth_user"];
|
|
2617
2784
|
var PUBLIC_KEY_RE = /^pk_(prod|staging|dev|live|test)_[A-Za-z0-9]{16,}$/;
|
|
2785
|
+
var STORAGE_PREFIX_RE = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
2786
|
+
function normalizeStoragePrefix(prefix) {
|
|
2787
|
+
if (prefix === void 0) return void 0;
|
|
2788
|
+
if (typeof prefix !== "string" || !STORAGE_PREFIX_RE.test(prefix)) {
|
|
2789
|
+
throw new Error(
|
|
2790
|
+
`[sendora] init: storagePrefix must be 1-64 chars of A-Z a-z 0-9 _ . - (got ${JSON.stringify(prefix)}). Changing it signs the device out, so it is validated rather than ignored.`
|
|
2791
|
+
);
|
|
2792
|
+
}
|
|
2793
|
+
return prefix;
|
|
2794
|
+
}
|
|
2618
2795
|
var MAX_PUSH_TOKEN_BYTES = 4096;
|
|
2619
2796
|
var MAX_PUSH_METADATA_BYTES = 4096;
|
|
2620
2797
|
var MAX_USER_ID_LEN = 256;
|
|
@@ -2788,8 +2965,13 @@ var SendoraSDK = class {
|
|
|
2788
2965
|
`[sendoracloud] apiUrl override active: ${apiUrl} \u2014 confirm this is intentional. Tokens + events will be sent here.`
|
|
2789
2966
|
);
|
|
2790
2967
|
}
|
|
2791
|
-
if (config.secureStorage) {
|
|
2792
|
-
|
|
2968
|
+
if (config.secureStorage || config.storagePrefix !== void 0) {
|
|
2969
|
+
const prefix = normalizeStoragePrefix(config.storagePrefix);
|
|
2970
|
+
this.storage = new Storage({
|
|
2971
|
+
secureAdapter: config.secureStorage,
|
|
2972
|
+
secureKeys: SECURE_KEYS,
|
|
2973
|
+
prefix
|
|
2974
|
+
});
|
|
2793
2975
|
}
|
|
2794
2976
|
await this.storage.hydrate([
|
|
2795
2977
|
ANON_KEY,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendoracloud/sdk-react-native",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.35.0",
|
|
4
4
|
"description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth (incl. Game Center / Play Games), deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push-token registration and the no-arg Game Center / Play Games sign-in require a Dev Client (EAS Build or `npx expo prebuild`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
58
58
|
"typecheck": "tsc --noEmit",
|
|
59
|
-
"test": "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts"
|
|
59
|
+
"test": "tsx test/secure-storage.test.ts && tsx test/integration-storage.test.ts && tsx test/plugin.test.ts && tsx test/game-auth.test.ts && tsx test/link.test.ts && tsx test/session-preservation.test.ts && tsx test/credential-collision.test.ts && tsx test/token-expiry.test.ts && tsx test/unlink.test.ts"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/react": "^19.2.16",
|