@sendoracloud/sdk-react-native 1.27.0 → 1.29.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 +258 -47
- package/dist/index.d.cts +156 -6
- package/dist/index.d.ts +156 -6
- package/dist/index.js +258 -47
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -202,7 +202,7 @@ var Storage = class {
|
|
|
202
202
|
// package.json
|
|
203
203
|
var package_default = {
|
|
204
204
|
name: "@sendoracloud/sdk-react-native",
|
|
205
|
-
version: "1.
|
|
205
|
+
version: "1.29.0",
|
|
206
206
|
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
207
|
type: "module",
|
|
208
208
|
main: "./dist/index.cjs",
|
|
@@ -258,7 +258,7 @@ var package_default = {
|
|
|
258
258
|
scripts: {
|
|
259
259
|
build: "tsup src/index.ts src/contact-widget.tsx --format esm,cjs --dts --clean --external react --external react-native --external react-native-webview",
|
|
260
260
|
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"
|
|
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"
|
|
262
262
|
},
|
|
263
263
|
devDependencies: {
|
|
264
264
|
"@types/react": "^19.2.16",
|
|
@@ -440,13 +440,88 @@ var CredentialInUseError = class extends Error {
|
|
|
440
440
|
this.name = "CredentialInUseError";
|
|
441
441
|
}
|
|
442
442
|
};
|
|
443
|
+
var RETRYABLE_KINDS = /* @__PURE__ */ new Set([
|
|
444
|
+
"network",
|
|
445
|
+
"server",
|
|
446
|
+
"rate_limited",
|
|
447
|
+
"account_locked"
|
|
448
|
+
]);
|
|
449
|
+
function classifyAuthErrorKind(code, status) {
|
|
450
|
+
switch (code) {
|
|
451
|
+
case "NETWORK_ERROR":
|
|
452
|
+
case "NETWORK_TIMEOUT":
|
|
453
|
+
return "network";
|
|
454
|
+
case "PARSE_ERROR":
|
|
455
|
+
return "server";
|
|
456
|
+
case "RATE_LIMITED":
|
|
457
|
+
case "RATE_LIMIT":
|
|
458
|
+
case "RATE_LIMIT_EXCEEDED":
|
|
459
|
+
case "QUOTA_EXCEEDED":
|
|
460
|
+
return "rate_limited";
|
|
461
|
+
case "ACCOUNT_LOCKED":
|
|
462
|
+
return "account_locked";
|
|
463
|
+
case "CREDENTIAL_IN_USE":
|
|
464
|
+
return "credential_in_use";
|
|
465
|
+
case "NOT_ANONYMOUS":
|
|
466
|
+
case "FORBIDDEN_NON_ANONYMOUS":
|
|
467
|
+
return "already_identified";
|
|
468
|
+
case "PASSKEY_USER_CANCELLED":
|
|
469
|
+
case "GAME_CENTER_CANCELLED":
|
|
470
|
+
case "PLAY_GAMES_CANCELLED":
|
|
471
|
+
case "SSO_CANCELLED":
|
|
472
|
+
return "cancelled";
|
|
473
|
+
case "GAME_CENTER_UNAVAILABLE":
|
|
474
|
+
case "PLAY_GAMES_UNAVAILABLE":
|
|
475
|
+
case "ENTITLEMENT_ERROR":
|
|
476
|
+
case "tier_required":
|
|
477
|
+
return "config";
|
|
478
|
+
case "UNAUTHORIZED":
|
|
479
|
+
case "INVALID_CREDENTIALS":
|
|
480
|
+
case "INVALID_REFRESH_TOKEN":
|
|
481
|
+
case "EMAIL_OTP_INVALID":
|
|
482
|
+
case "MAGIC_LINK_INVALID":
|
|
483
|
+
case "PASSKEY_RP_MISMATCH":
|
|
484
|
+
case "NOT_SIGNED_IN":
|
|
485
|
+
return "invalid_credential";
|
|
486
|
+
default:
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
if (typeof status === "number") {
|
|
490
|
+
if (status === 429) return "rate_limited";
|
|
491
|
+
if (status >= 500) return "server";
|
|
492
|
+
if (status >= 400) return "invalid_credential";
|
|
493
|
+
}
|
|
494
|
+
return "unknown";
|
|
495
|
+
}
|
|
443
496
|
var AuthError = class extends Error {
|
|
444
|
-
constructor(code, message) {
|
|
497
|
+
constructor(code, message, opts) {
|
|
445
498
|
super(message);
|
|
446
499
|
this.name = "AuthError";
|
|
447
500
|
this.code = code;
|
|
501
|
+
this.kind = opts?.kind ?? classifyAuthErrorKind(code, opts?.status);
|
|
502
|
+
this.retryable = RETRYABLE_KINDS.has(this.kind);
|
|
503
|
+
this.retryAfterSeconds = opts?.retryAfterSeconds;
|
|
504
|
+
this.status = opts?.status;
|
|
448
505
|
}
|
|
449
506
|
};
|
|
507
|
+
function asAuthError(err) {
|
|
508
|
+
if (err instanceof AuthError) return err;
|
|
509
|
+
if (err instanceof EmailAlreadyTakenError || err instanceof AlreadyIdentifiedError || err instanceof CredentialInUseError) {
|
|
510
|
+
return err;
|
|
511
|
+
}
|
|
512
|
+
const name = err?.name;
|
|
513
|
+
if (name === "NetworkTimeoutError") {
|
|
514
|
+
return new AuthError("NETWORK_TIMEOUT", err.message, { kind: "network" });
|
|
515
|
+
}
|
|
516
|
+
return new AuthError("UNKNOWN_ERROR", err instanceof Error ? err.message : String(err), {
|
|
517
|
+
kind: "unknown"
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
function readRetryAfterSeconds(parsed) {
|
|
521
|
+
const details = parsed?.error?.details;
|
|
522
|
+
const v = details?.retryAfterSeconds;
|
|
523
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
|
|
524
|
+
}
|
|
450
525
|
var GAME_AUTH_NATIVE_MODULE = "SendoraGameAuth";
|
|
451
526
|
async function fetchGameCenterPayloadNative() {
|
|
452
527
|
const mod = requireOptionalNativeModule(GAME_AUTH_NATIVE_MODULE);
|
|
@@ -511,6 +586,7 @@ var Auth = class {
|
|
|
511
586
|
this.refreshCooldownUntil = 0;
|
|
512
587
|
this.takeoverListeners = /* @__PURE__ */ new Set();
|
|
513
588
|
this.deletionCancelledListeners = /* @__PURE__ */ new Set();
|
|
589
|
+
this.authStateListeners = /* @__PURE__ */ new Set();
|
|
514
590
|
this.lastDeletionCancelled = null;
|
|
515
591
|
this.lastTakeover = null;
|
|
516
592
|
/**
|
|
@@ -521,6 +597,11 @@ var Auth = class {
|
|
|
521
597
|
* device ended with two user_ids + duplicate pushes (audit s58.203 fix).
|
|
522
598
|
*/
|
|
523
599
|
this.pendingAnonTakeover = null;
|
|
600
|
+
/**
|
|
601
|
+
* True once `hydrate()` has run. Distinguishes "no session restored YET"
|
|
602
|
+
* from "genuinely signed out" for `onAuthStateChanged`'s replay-on-subscribe.
|
|
603
|
+
*/
|
|
604
|
+
this.hydrated = false;
|
|
524
605
|
/**
|
|
525
606
|
* Proactive refresh (s58.72). Fires when access-token age crosses
|
|
526
607
|
* ~80% of TTL. Pairs with the AppState foreground listener so a
|
|
@@ -575,9 +656,9 @@ var Auth = class {
|
|
|
575
656
|
this.hooks.storage.remove(USER_KEY);
|
|
576
657
|
this.hooks.storage.remove(TOKEN_KEY);
|
|
577
658
|
this.hooks.storage.remove(TOKEN_EXPIRES_KEY);
|
|
578
|
-
this.hooks.storage.remove(REFRESH_KEY);
|
|
579
659
|
}
|
|
580
660
|
}
|
|
661
|
+
this.hydrated = true;
|
|
581
662
|
this.resolveReady();
|
|
582
663
|
}
|
|
583
664
|
getCurrentUser() {
|
|
@@ -603,6 +684,52 @@ var Auth = class {
|
|
|
603
684
|
this.takeoverListeners.delete(listener);
|
|
604
685
|
};
|
|
605
686
|
}
|
|
687
|
+
/**
|
|
688
|
+
* Subscribe to every auth-state transition (1.28.0) — the single stream that
|
|
689
|
+
* answers "what happened to my session?". Firebase `onAuthStateChanged` /
|
|
690
|
+
* Supabase `onAuthStateChange` parity.
|
|
691
|
+
*
|
|
692
|
+
* The reason it exists: a session that dies in the BACKGROUND (the server
|
|
693
|
+
* rejected the stored refresh token) used to emit no signal whatsoever, so
|
|
694
|
+
* an app could not distinguish it from a deliberate sign-out and only
|
|
695
|
+
* noticed when `getAccessToken()` began returning null. That transition is
|
|
696
|
+
* now `{ event: "signed_out", reason: "session_expired" }`.
|
|
697
|
+
*
|
|
698
|
+
* Fires the CURRENT state immediately on subscribe once `init()` has
|
|
699
|
+
* hydrated (a `signed_in` when a session was restored from disk — including
|
|
700
|
+
* offline, since hydrate is disk-only), so a subscriber never misses the
|
|
701
|
+
* state it started in. Nothing is emitted for a signed-out cold start.
|
|
702
|
+
*
|
|
703
|
+
* A failed sign-in emits NOTHING: the session is unchanged, so there is no
|
|
704
|
+
* transition to report — the rejected promise is the whole story.
|
|
705
|
+
*
|
|
706
|
+
* Listeners are best-effort; a throwing listener is swallowed (dev-logged)
|
|
707
|
+
* and never affects auth state. Returns an unsubscribe function.
|
|
708
|
+
*/
|
|
709
|
+
onAuthStateChanged(listener) {
|
|
710
|
+
this.authStateListeners.add(listener);
|
|
711
|
+
if (this.hydrated && this.user) {
|
|
712
|
+
const user = this.user;
|
|
713
|
+
try {
|
|
714
|
+
listener({ event: "signed_in", user });
|
|
715
|
+
} catch (err) {
|
|
716
|
+
if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return () => {
|
|
720
|
+
this.authStateListeners.delete(listener);
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
/** Fan a state change out to subscribers. Never throws. */
|
|
724
|
+
emitAuthState(change) {
|
|
725
|
+
for (const listener of this.authStateListeners) {
|
|
726
|
+
try {
|
|
727
|
+
listener(change);
|
|
728
|
+
} catch (err) {
|
|
729
|
+
if (this.hooks.debug) console.warn("[Sendora] onAuthStateChanged listener threw", err);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
606
733
|
/**
|
|
607
734
|
* Inspect the most recent device-takeover event observed by the
|
|
608
735
|
* SDK. Cleared on signOut. Returns null when no takeover has
|
|
@@ -699,8 +826,27 @@ var Auth = class {
|
|
|
699
826
|
getAccessTokenSync() {
|
|
700
827
|
return this.accessToken;
|
|
701
828
|
}
|
|
829
|
+
/**
|
|
830
|
+
* Mint (or return) an anonymous session.
|
|
831
|
+
*
|
|
832
|
+
* Reuses the existing anonymous user when there already is one — the same
|
|
833
|
+
* short-circuit Firebase's `signInAnonymously` performs. Without it, an app
|
|
834
|
+
* that calls this defensively on every cold launch (the most natural thing
|
|
835
|
+
* to write) mints a fresh `user_id` each time, and `persist()` overwrites
|
|
836
|
+
* the stored refresh token — the previous anonymous account's only durable
|
|
837
|
+
* handle — with no takeover, no webhook and no state event. That is the same
|
|
838
|
+
* silent progress loss as a failed sign-in wiping the session, except it
|
|
839
|
+
* happens on a *healthy* network.
|
|
840
|
+
*
|
|
841
|
+
* Pass `forceNew: true` to deliberately abandon the current anonymous
|
|
842
|
+
* session and mint a separate one.
|
|
843
|
+
*/
|
|
702
844
|
signInAnonymously(opts) {
|
|
703
845
|
return this.serialize(async () => {
|
|
846
|
+
const existing = this.user;
|
|
847
|
+
if (!opts?.forceNew && existing?.isAnonymous && this.hooks.storage.get(REFRESH_KEY)) {
|
|
848
|
+
return existing;
|
|
849
|
+
}
|
|
704
850
|
const data = await this.callAuth("/api/v1/auth-service/anonymous", {
|
|
705
851
|
name: opts?.name,
|
|
706
852
|
metadata: opts?.metadata
|
|
@@ -749,7 +895,7 @@ var Auth = class {
|
|
|
749
895
|
throw new EmailAlreadyTakenError(err.message);
|
|
750
896
|
}
|
|
751
897
|
if (err instanceof AuthError && (err.code === "INVALID_REFRESH" || err.code === "UNAUTHORIZED" || err.code === "REFRESH_EXPIRED")) {
|
|
752
|
-
await this.wipeLocalIdentity();
|
|
898
|
+
await this.wipeLocalIdentity("session_expired");
|
|
753
899
|
} else {
|
|
754
900
|
throw err;
|
|
755
901
|
}
|
|
@@ -790,10 +936,14 @@ var Auth = class {
|
|
|
790
936
|
try {
|
|
791
937
|
parsed = await res.json();
|
|
792
938
|
} catch {
|
|
793
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
939
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
794
940
|
}
|
|
795
941
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
796
|
-
throw new AuthError(
|
|
942
|
+
throw new AuthError(
|
|
943
|
+
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
944
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
945
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
946
|
+
);
|
|
797
947
|
}
|
|
798
948
|
const r = parsed.data;
|
|
799
949
|
if (r.mfaRequired === true && typeof r.mfaChallengeToken === "string") {
|
|
@@ -820,12 +970,7 @@ var Auth = class {
|
|
|
820
970
|
*/
|
|
821
971
|
loginSocial(input) {
|
|
822
972
|
return this.serialize(async () => {
|
|
823
|
-
|
|
824
|
-
if (this.user?.isAnonymous) {
|
|
825
|
-
const stashed = this.hooks.storage.get(REFRESH_KEY);
|
|
826
|
-
if (stashed) prevAnonRefreshToken = stashed;
|
|
827
|
-
}
|
|
828
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
973
|
+
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
829
974
|
const { link, ...socialInput } = input;
|
|
830
975
|
const payload = { ...socialInput };
|
|
831
976
|
if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
@@ -840,14 +985,16 @@ var Auth = class {
|
|
|
840
985
|
try {
|
|
841
986
|
parsed = await res.json();
|
|
842
987
|
} catch {
|
|
843
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
988
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
844
989
|
}
|
|
845
990
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
846
991
|
throw new AuthError(
|
|
847
992
|
parsed.error?.code ?? "SOCIAL_LOGIN_FAILED",
|
|
848
|
-
parsed.error?.message ?? "Social login failed"
|
|
993
|
+
parsed.error?.message ?? "Social login failed",
|
|
994
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
849
995
|
);
|
|
850
996
|
}
|
|
997
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
851
998
|
this.persist(parsed.data);
|
|
852
999
|
return parsed.data.user;
|
|
853
1000
|
});
|
|
@@ -966,12 +1113,15 @@ var Auth = class {
|
|
|
966
1113
|
try {
|
|
967
1114
|
parsed = await res.json();
|
|
968
1115
|
} catch {
|
|
969
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1116
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
970
1117
|
}
|
|
971
1118
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
972
1119
|
const code = parsed.error?.code ?? `HTTP_${res.status}`;
|
|
973
1120
|
if (code === "CREDENTIAL_IN_USE") throw new CredentialInUseError(parsed.error?.message);
|
|
974
|
-
throw new AuthError(code, parsed.error?.message ?? "Failed to link credential"
|
|
1121
|
+
throw new AuthError(code, parsed.error?.message ?? "Failed to link credential", {
|
|
1122
|
+
status: res.status,
|
|
1123
|
+
retryAfterSeconds: readRetryAfterSeconds(parsed)
|
|
1124
|
+
});
|
|
975
1125
|
}
|
|
976
1126
|
if (parsed.data.user) this.updateLocalUser(parsed.data.user);
|
|
977
1127
|
return parsed.data.user ?? this.user;
|
|
@@ -1024,12 +1174,7 @@ var Auth = class {
|
|
|
1024
1174
|
gameIdentitySignIn(path, identityBody, link) {
|
|
1025
1175
|
return this.serialize(async () => {
|
|
1026
1176
|
const body = typeof identityBody === "function" ? await identityBody() : identityBody;
|
|
1027
|
-
|
|
1028
|
-
if (this.user?.isAnonymous) {
|
|
1029
|
-
const stashed = this.hooks.storage.get(REFRESH_KEY);
|
|
1030
|
-
if (stashed) prevAnonRefreshToken = stashed;
|
|
1031
|
-
}
|
|
1032
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1177
|
+
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1033
1178
|
const payload = { ...body };
|
|
1034
1179
|
if (prevAnonRefreshToken) payload.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1035
1180
|
if (link) payload.linkAnonymous = true;
|
|
@@ -1043,14 +1188,16 @@ var Auth = class {
|
|
|
1043
1188
|
try {
|
|
1044
1189
|
parsed = await res.json();
|
|
1045
1190
|
} catch {
|
|
1046
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1191
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1047
1192
|
}
|
|
1048
1193
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1049
1194
|
throw new AuthError(
|
|
1050
1195
|
parsed.error?.code ?? "GAME_LOGIN_FAILED",
|
|
1051
|
-
parsed.error?.message ?? "Game sign-in failed"
|
|
1196
|
+
parsed.error?.message ?? "Game sign-in failed",
|
|
1197
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1052
1198
|
);
|
|
1053
1199
|
}
|
|
1200
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1054
1201
|
this.persist(parsed.data);
|
|
1055
1202
|
return parsed.data.user;
|
|
1056
1203
|
});
|
|
@@ -1101,10 +1248,10 @@ var Auth = class {
|
|
|
1101
1248
|
verifyMagicLink(token) {
|
|
1102
1249
|
return this.serialize(async () => {
|
|
1103
1250
|
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1104
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1105
1251
|
const body = { token };
|
|
1106
1252
|
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1107
1253
|
const data = await this.callAuth("/api/v1/auth-service/magic-link/verify", body);
|
|
1254
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1108
1255
|
this.persist(data);
|
|
1109
1256
|
return data.user;
|
|
1110
1257
|
});
|
|
@@ -1125,10 +1272,10 @@ var Auth = class {
|
|
|
1125
1272
|
verifyEmailOtp(email, code) {
|
|
1126
1273
|
return this.serialize(async () => {
|
|
1127
1274
|
const prevAnonRefreshToken = this.readPrevAnonRefreshToken();
|
|
1128
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1129
1275
|
const body = { email, code };
|
|
1130
1276
|
if (prevAnonRefreshToken) body.prevAnonRefreshToken = prevAnonRefreshToken;
|
|
1131
1277
|
const data = await this.callAuth("/api/v1/auth-service/email-otp/verify", body);
|
|
1278
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1132
1279
|
this.persist(data);
|
|
1133
1280
|
return data.user;
|
|
1134
1281
|
});
|
|
@@ -1284,7 +1431,7 @@ var Auth = class {
|
|
|
1284
1431
|
}
|
|
1285
1432
|
} catch {
|
|
1286
1433
|
}
|
|
1287
|
-
await this.wipeLocalIdentity();
|
|
1434
|
+
await this.wipeLocalIdentity("account_deleted");
|
|
1288
1435
|
return out;
|
|
1289
1436
|
}
|
|
1290
1437
|
// ============================================================
|
|
@@ -1352,7 +1499,6 @@ var Auth = class {
|
|
|
1352
1499
|
*/
|
|
1353
1500
|
consumeSsoFragment(callbackUrl) {
|
|
1354
1501
|
return this.serialize(async () => {
|
|
1355
|
-
if (this.user !== null) await this.wipeLocalIdentity();
|
|
1356
1502
|
const hashIdx = callbackUrl.indexOf("#");
|
|
1357
1503
|
const fragment = hashIdx >= 0 ? callbackUrl.slice(hashIdx + 1) : "";
|
|
1358
1504
|
const params = new URLSearchParams(fragment);
|
|
@@ -1366,14 +1512,13 @@ var Auth = class {
|
|
|
1366
1512
|
"/api/v1/auth-service/token/refresh",
|
|
1367
1513
|
{ refreshToken: refresh }
|
|
1368
1514
|
);
|
|
1369
|
-
if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}
|
|
1515
|
+
if (!res || !res.ok) throw new AuthError("SSO_EXCHANGE_FAILED", `HTTP ${res?.status ?? "network"}`, { status: res?.status });
|
|
1370
1516
|
const parsed = await res.json();
|
|
1371
|
-
const
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
if (!parsed.success || !access || !newRefresh || !expiresIn || expiresIn <= 0) {
|
|
1375
|
-
throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token");
|
|
1517
|
+
const rotated = parsed.success ? readRotatedTokens(parsed.data) : null;
|
|
1518
|
+
if (!rotated) {
|
|
1519
|
+
throw new AuthError("SSO_EXCHANGE_FAILED", "Could not exchange OIDC token", { status: res.status });
|
|
1376
1520
|
}
|
|
1521
|
+
const { accessToken: access, refreshToken: newRefresh, expiresIn } = rotated;
|
|
1377
1522
|
const claims = decodeJwtPayload(access);
|
|
1378
1523
|
const user = {
|
|
1379
1524
|
id: claims?.sub ?? "",
|
|
@@ -1382,6 +1527,7 @@ var Auth = class {
|
|
|
1382
1527
|
name: claims?.name ?? null,
|
|
1383
1528
|
isAnonymous: false
|
|
1384
1529
|
};
|
|
1530
|
+
if (this.user !== null) await this.wipeLocalIdentity("replaced");
|
|
1385
1531
|
this.persist({
|
|
1386
1532
|
user,
|
|
1387
1533
|
tokens: {
|
|
@@ -1417,7 +1563,7 @@ var Auth = class {
|
|
|
1417
1563
|
signOut() {
|
|
1418
1564
|
return this.serialize(async () => {
|
|
1419
1565
|
const refresh = this.hooks.storage.get(REFRESH_KEY);
|
|
1420
|
-
await this.wipeLocalIdentity();
|
|
1566
|
+
await this.wipeLocalIdentity("user");
|
|
1421
1567
|
if (refresh) {
|
|
1422
1568
|
void post(
|
|
1423
1569
|
{ apiUrl: this.hooks.apiUrl, publicKey: this.hooks.publicKey, debug: this.hooks.debug },
|
|
@@ -1431,7 +1577,11 @@ var Auth = class {
|
|
|
1431
1577
|
serialize(op) {
|
|
1432
1578
|
const guarded = async () => {
|
|
1433
1579
|
await this.readyPromise;
|
|
1434
|
-
|
|
1580
|
+
try {
|
|
1581
|
+
return await op();
|
|
1582
|
+
} catch (err) {
|
|
1583
|
+
throw asAuthError(err);
|
|
1584
|
+
}
|
|
1435
1585
|
};
|
|
1436
1586
|
const next = this.inflight.then(guarded, guarded);
|
|
1437
1587
|
this.inflight = next.catch(() => void 0);
|
|
@@ -1456,6 +1606,7 @@ var Auth = class {
|
|
|
1456
1606
|
const refreshSnapshot = this.hooks.storage.get(REFRESH_KEY);
|
|
1457
1607
|
if (!refreshSnapshot) return null;
|
|
1458
1608
|
this.refreshInflight = (async () => {
|
|
1609
|
+
let sent = refreshSnapshot;
|
|
1459
1610
|
try {
|
|
1460
1611
|
const current = this.hooks.storage.get(REFRESH_KEY);
|
|
1461
1612
|
if (current && current !== refreshSnapshot) {
|
|
@@ -1472,20 +1623,28 @@ var Auth = class {
|
|
|
1472
1623
|
}
|
|
1473
1624
|
}
|
|
1474
1625
|
}
|
|
1626
|
+
sent = current ?? refreshSnapshot;
|
|
1475
1627
|
const data = await this.callAuth("/api/v1/auth-service/token/refresh", {
|
|
1476
|
-
refreshToken:
|
|
1628
|
+
refreshToken: sent
|
|
1477
1629
|
});
|
|
1630
|
+
if (!this.tokenStillCurrent(sent)) return null;
|
|
1478
1631
|
this.accessToken = data.tokens.accessToken;
|
|
1479
1632
|
this.accessExpiresAt = Date.now() + data.tokens.expiresIn * 1e3;
|
|
1480
1633
|
this.hooks.storage.set(TOKEN_KEY, data.tokens.accessToken);
|
|
1481
1634
|
this.hooks.storage.set(TOKEN_EXPIRES_KEY, String(this.accessExpiresAt));
|
|
1482
1635
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
1636
|
+
if (this.user === null && isAuthApiResponse(data)) {
|
|
1637
|
+
this.user = data.user;
|
|
1638
|
+
this.hooks.storage.set(USER_KEY, JSON.stringify(data.user));
|
|
1639
|
+
this.hooks.onIdentityChange(data.user.id);
|
|
1640
|
+
this.emitAuthState({ event: "signed_in", user: data.user });
|
|
1641
|
+
}
|
|
1483
1642
|
this.refreshFailureCount = 0;
|
|
1484
1643
|
this.refreshCooldownUntil = 0;
|
|
1485
1644
|
return data.tokens.accessToken;
|
|
1486
1645
|
} catch (err) {
|
|
1487
|
-
if (err instanceof AuthError && isDeadRefreshError(err.code)) {
|
|
1488
|
-
await this.wipeLocalIdentity().catch(() => void 0);
|
|
1646
|
+
if (err instanceof AuthError && isDeadRefreshError(err.code) && this.tokenStillCurrent(sent)) {
|
|
1647
|
+
await this.wipeLocalIdentity("session_expired").catch(() => void 0);
|
|
1489
1648
|
this.refreshFailureCount = 0;
|
|
1490
1649
|
this.refreshCooldownUntil = 0;
|
|
1491
1650
|
return null;
|
|
@@ -1599,12 +1758,13 @@ var Auth = class {
|
|
|
1599
1758
|
try {
|
|
1600
1759
|
parsed = await res.json();
|
|
1601
1760
|
} catch {
|
|
1602
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1761
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1603
1762
|
}
|
|
1604
1763
|
if (!res.ok || !parsed.success || !parsed.data) {
|
|
1605
1764
|
throw new AuthError(
|
|
1606
1765
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
1607
|
-
parsed.error?.message ?? `HTTP ${res.status}
|
|
1766
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
1767
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1608
1768
|
);
|
|
1609
1769
|
}
|
|
1610
1770
|
const t = parsed.data.tokens;
|
|
@@ -1638,12 +1798,13 @@ var Auth = class {
|
|
|
1638
1798
|
try {
|
|
1639
1799
|
parsed = await res.json();
|
|
1640
1800
|
} catch {
|
|
1641
|
-
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}
|
|
1801
|
+
throw new AuthError("PARSE_ERROR", `HTTP ${res.status}`, { status: res.status });
|
|
1642
1802
|
}
|
|
1643
1803
|
if (!res.ok || !parsed.success || !isAuthApiResponse(parsed.data)) {
|
|
1644
1804
|
throw new AuthError(
|
|
1645
1805
|
parsed.error?.code ?? `HTTP_${res.status}`,
|
|
1646
|
-
parsed.error?.message ?? `HTTP ${res.status}
|
|
1806
|
+
parsed.error?.message ?? `HTTP ${res.status}`,
|
|
1807
|
+
{ status: res.status, retryAfterSeconds: readRetryAfterSeconds(parsed) }
|
|
1647
1808
|
);
|
|
1648
1809
|
}
|
|
1649
1810
|
return parsed.data;
|
|
@@ -1658,14 +1819,46 @@ var Auth = class {
|
|
|
1658
1819
|
this.hooks.storage.set(REFRESH_KEY, data.tokens.refreshToken);
|
|
1659
1820
|
this.hooks.onIdentityChange(data.user.id);
|
|
1660
1821
|
this.startProactiveRefreshCron();
|
|
1822
|
+
this.emitAuthState({ event: "signed_in", user: data.user });
|
|
1661
1823
|
if (data.retiredAnonUserId) {
|
|
1662
1824
|
this.fireDeviceTakeover(data.retiredAnonUserId, data.user.id);
|
|
1825
|
+
this.emitAuthState({
|
|
1826
|
+
event: "device_takeover",
|
|
1827
|
+
user: data.user,
|
|
1828
|
+
retiredAnonUserId: data.retiredAnonUserId
|
|
1829
|
+
});
|
|
1663
1830
|
}
|
|
1664
1831
|
if (data.reactivatedFromDeletion) {
|
|
1665
1832
|
this.fireDeletionCancelled(data.user.id);
|
|
1833
|
+
this.emitAuthState({ event: "deletion_cancelled", user: data.user });
|
|
1666
1834
|
}
|
|
1667
1835
|
}
|
|
1668
|
-
|
|
1836
|
+
/**
|
|
1837
|
+
* Drop the local session.
|
|
1838
|
+
*
|
|
1839
|
+
* `reason` drives the `onAuthStateChanged` emission: a real sign-out reason
|
|
1840
|
+
* emits `signed_out`, while `"replaced"` (the internal pre-`persist()` clear
|
|
1841
|
+
* on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
|
|
1842
|
+
* not a spurious logout/login pair.
|
|
1843
|
+
*
|
|
1844
|
+
* ⚠ Never call this before a fallible network request. A failed sign-in must
|
|
1845
|
+
* leave the caller exactly as it found them; for an anonymous session the
|
|
1846
|
+
* refresh token dropped here is the ONLY durable handle on the account, so a
|
|
1847
|
+
* pre-call wipe that is not followed by a successful `persist()` orphans the
|
|
1848
|
+
* account permanently (offline: deterministically). Capture the anon refresh
|
|
1849
|
+
* into a local, make the call, and wipe only after the response validates.
|
|
1850
|
+
*/
|
|
1851
|
+
/**
|
|
1852
|
+
* True when `sent` is still the refresh token on disk — i.e. nothing
|
|
1853
|
+
* replaced or cleared the session while a request carrying it was in
|
|
1854
|
+
* flight. Guards both directions of the refresh race: installing a rotated
|
|
1855
|
+
* token over a session that has since been signed out or replaced, and
|
|
1856
|
+
* wiping a fresh session because an OLD token was rejected.
|
|
1857
|
+
*/
|
|
1858
|
+
tokenStillCurrent(sent) {
|
|
1859
|
+
return this.hooks.storage.get(REFRESH_KEY) === sent;
|
|
1860
|
+
}
|
|
1861
|
+
async wipeLocalIdentity(reason = "replaced") {
|
|
1669
1862
|
this.user = null;
|
|
1670
1863
|
this.accessToken = null;
|
|
1671
1864
|
this.accessExpiresAt = 0;
|
|
@@ -1678,11 +1871,23 @@ var Auth = class {
|
|
|
1678
1871
|
this.hooks.storage.remove(REFRESH_KEY);
|
|
1679
1872
|
this.stopProactiveRefreshCron();
|
|
1680
1873
|
await this.hooks.onAnonymousWipe();
|
|
1874
|
+
if (reason !== "replaced") {
|
|
1875
|
+
this.emitAuthState({ event: "signed_out", user: null, reason });
|
|
1876
|
+
}
|
|
1681
1877
|
}
|
|
1682
1878
|
};
|
|
1683
1879
|
function isDeadRefreshError(code) {
|
|
1684
1880
|
if (!code) return false;
|
|
1685
|
-
return code === "INVALID_REFRESH_TOKEN"
|
|
1881
|
+
return code === "INVALID_REFRESH_TOKEN";
|
|
1882
|
+
}
|
|
1883
|
+
function readRotatedTokens(data) {
|
|
1884
|
+
if (!data || typeof data !== "object") return null;
|
|
1885
|
+
const container = data.tokens;
|
|
1886
|
+
const t = container && typeof container === "object" ? container : data;
|
|
1887
|
+
if (typeof t.accessToken !== "string" || !t.accessToken) return null;
|
|
1888
|
+
if (typeof t.refreshToken !== "string" || !t.refreshToken) return null;
|
|
1889
|
+
if (typeof t.expiresIn !== "number" || t.expiresIn <= 0) return null;
|
|
1890
|
+
return { accessToken: t.accessToken, refreshToken: t.refreshToken, expiresIn: t.expiresIn };
|
|
1686
1891
|
}
|
|
1687
1892
|
function decodeJwtPayload(token) {
|
|
1688
1893
|
try {
|
|
@@ -3326,10 +3531,16 @@ var SendoraSDK = class {
|
|
|
3326
3531
|
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
3327
3532
|
* the AsyncStorage writes so a caller who kills the app immediately
|
|
3328
3533
|
* after doesn't leave stale identity on disk.
|
|
3534
|
+
*
|
|
3535
|
+
* Signs out through `auth` rather than just clearing storage: `clearAll()`
|
|
3536
|
+
* removes the auth keys too, so doing it directly left `Auth` holding an
|
|
3537
|
+
* in-memory user and reporting itself signed in while its refresh token was
|
|
3538
|
+
* already gone — with the server-side session never revoked and no
|
|
3539
|
+
* `onAuthStateChanged` emission telling the app the session had ended.
|
|
3329
3540
|
*/
|
|
3330
3541
|
async reset() {
|
|
3542
|
+
await this.auth.signOut().catch(() => void 0);
|
|
3331
3543
|
this.userId = null;
|
|
3332
|
-
this.anonId = uuid();
|
|
3333
3544
|
this.sessionId = "";
|
|
3334
3545
|
await this.storage.clearAll();
|
|
3335
3546
|
this.storage.set(ANON_KEY, this.anonId);
|