react-native-nitro-auth 0.5.1 → 0.5.4

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.
Files changed (126) hide show
  1. package/README.md +362 -190
  2. package/android/build.gradle +2 -5
  3. package/android/src/main/cpp/PlatformAuth+Android.cpp +84 -18
  4. package/android/src/main/java/com/auth/AuthAdapter.kt +82 -182
  5. package/android/src/main/java/com/auth/NitroAuthPackage.kt +1 -1
  6. package/app.plugin.js +2 -9
  7. package/cpp/AuthCache.cpp +0 -134
  8. package/cpp/AuthCache.hpp +0 -7
  9. package/cpp/HybridAuth.cpp +57 -63
  10. package/cpp/HybridAuth.hpp +3 -4
  11. package/ios/AuthAdapter.swift +23 -25
  12. package/lib/commonjs/Auth.web.js +523 -201
  13. package/lib/commonjs/Auth.web.js.map +1 -1
  14. package/lib/commonjs/index.js +0 -12
  15. package/lib/commonjs/index.js.map +1 -1
  16. package/lib/commonjs/index.web.js +0 -12
  17. package/lib/commonjs/index.web.js.map +1 -1
  18. package/lib/commonjs/js-storage-adapter.js +2 -0
  19. package/lib/commonjs/js-storage-adapter.js.map +1 -0
  20. package/lib/commonjs/service.js +9 -86
  21. package/lib/commonjs/service.js.map +1 -1
  22. package/lib/commonjs/service.web.js +1 -5
  23. package/lib/commonjs/service.web.js.map +1 -1
  24. package/lib/commonjs/ui/social-button.js +44 -29
  25. package/lib/commonjs/ui/social-button.js.map +1 -1
  26. package/lib/commonjs/ui/social-button.web.js +44 -29
  27. package/lib/commonjs/ui/social-button.web.js.map +1 -1
  28. package/lib/commonjs/use-auth.js +56 -42
  29. package/lib/commonjs/use-auth.js.map +1 -1
  30. package/lib/commonjs/utils/logger.js +12 -4
  31. package/lib/commonjs/utils/logger.js.map +1 -1
  32. package/lib/module/Auth.web.js +523 -201
  33. package/lib/module/Auth.web.js.map +1 -1
  34. package/lib/module/index.js +0 -1
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/module/index.web.js +0 -1
  37. package/lib/module/index.web.js.map +1 -1
  38. package/lib/module/js-storage-adapter.js +2 -0
  39. package/lib/module/js-storage-adapter.js.map +1 -0
  40. package/lib/module/service.js +9 -86
  41. package/lib/module/service.js.map +1 -1
  42. package/lib/module/service.web.js +1 -5
  43. package/lib/module/service.web.js.map +1 -1
  44. package/lib/module/ui/social-button.js +44 -29
  45. package/lib/module/ui/social-button.js.map +1 -1
  46. package/lib/module/ui/social-button.web.js +44 -29
  47. package/lib/module/ui/social-button.web.js.map +1 -1
  48. package/lib/module/use-auth.js +56 -42
  49. package/lib/module/use-auth.js.map +1 -1
  50. package/lib/module/utils/logger.js +12 -4
  51. package/lib/module/utils/logger.js.map +1 -1
  52. package/lib/typescript/commonjs/Auth.nitro.d.ts +3 -3
  53. package/lib/typescript/commonjs/Auth.nitro.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/Auth.web.d.ts +25 -6
  55. package/lib/typescript/commonjs/Auth.web.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/index.d.ts +1 -2
  57. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/index.web.d.ts +0 -1
  59. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/js-storage-adapter.d.ts +6 -0
  61. package/lib/typescript/commonjs/js-storage-adapter.d.ts.map +1 -0
  62. package/lib/typescript/commonjs/service.d.ts +1 -8
  63. package/lib/typescript/commonjs/service.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/service.web.d.ts +1 -8
  65. package/lib/typescript/commonjs/service.web.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/social-button.d.ts +6 -6
  67. package/lib/typescript/commonjs/ui/social-button.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/ui/social-button.web.d.ts +6 -6
  69. package/lib/typescript/commonjs/ui/social-button.web.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/use-auth.d.ts +4 -4
  71. package/lib/typescript/commonjs/use-auth.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/utils/logger.d.ts +4 -4
  73. package/lib/typescript/commonjs/utils/logger.d.ts.map +1 -1
  74. package/lib/typescript/module/Auth.nitro.d.ts +3 -3
  75. package/lib/typescript/module/Auth.nitro.d.ts.map +1 -1
  76. package/lib/typescript/module/Auth.web.d.ts +25 -6
  77. package/lib/typescript/module/Auth.web.d.ts.map +1 -1
  78. package/lib/typescript/module/index.d.ts +1 -2
  79. package/lib/typescript/module/index.d.ts.map +1 -1
  80. package/lib/typescript/module/index.web.d.ts +0 -1
  81. package/lib/typescript/module/index.web.d.ts.map +1 -1
  82. package/lib/typescript/module/js-storage-adapter.d.ts +6 -0
  83. package/lib/typescript/module/js-storage-adapter.d.ts.map +1 -0
  84. package/lib/typescript/module/service.d.ts +1 -8
  85. package/lib/typescript/module/service.d.ts.map +1 -1
  86. package/lib/typescript/module/service.web.d.ts +1 -8
  87. package/lib/typescript/module/service.web.d.ts.map +1 -1
  88. package/lib/typescript/module/ui/social-button.d.ts +6 -6
  89. package/lib/typescript/module/ui/social-button.d.ts.map +1 -1
  90. package/lib/typescript/module/ui/social-button.web.d.ts +6 -6
  91. package/lib/typescript/module/ui/social-button.web.d.ts.map +1 -1
  92. package/lib/typescript/module/use-auth.d.ts +4 -4
  93. package/lib/typescript/module/use-auth.d.ts.map +1 -1
  94. package/lib/typescript/module/utils/logger.d.ts +4 -4
  95. package/lib/typescript/module/utils/logger.d.ts.map +1 -1
  96. package/nitrogen/generated/android/NitroAuth+autolinking.cmake +0 -1
  97. package/nitrogen/generated/shared/c++/AuthTokens.hpp +5 -1
  98. package/nitrogen/generated/shared/c++/AuthUser.hpp +5 -1
  99. package/nitrogen/generated/shared/c++/HybridAuthSpec.cpp +0 -1
  100. package/nitrogen/generated/shared/c++/HybridAuthSpec.hpp +0 -5
  101. package/package.json +11 -8
  102. package/react-native-nitro-auth.podspec +1 -1
  103. package/src/Auth.nitro.ts +4 -3
  104. package/src/Auth.web.ts +700 -246
  105. package/src/global.d.ts +0 -1
  106. package/src/index.ts +1 -2
  107. package/src/index.web.ts +0 -1
  108. package/src/js-storage-adapter.ts +5 -0
  109. package/src/service.ts +13 -106
  110. package/src/service.web.ts +0 -7
  111. package/src/ui/social-button.tsx +66 -43
  112. package/src/ui/social-button.web.tsx +67 -44
  113. package/src/use-auth.ts +116 -72
  114. package/src/utils/logger.ts +12 -4
  115. package/ios/KeychainStore.swift +0 -43
  116. package/lib/commonjs/AuthStorage.nitro.js +0 -6
  117. package/lib/commonjs/AuthStorage.nitro.js.map +0 -1
  118. package/lib/module/AuthStorage.nitro.js +0 -4
  119. package/lib/module/AuthStorage.nitro.js.map +0 -1
  120. package/lib/typescript/commonjs/AuthStorage.nitro.d.ts +0 -26
  121. package/lib/typescript/commonjs/AuthStorage.nitro.d.ts.map +0 -1
  122. package/lib/typescript/module/AuthStorage.nitro.d.ts +0 -26
  123. package/lib/typescript/module/AuthStorage.nitro.d.ts.map +0 -1
  124. package/nitrogen/generated/shared/c++/HybridAuthStorageAdapterSpec.cpp +0 -23
  125. package/nitrogen/generated/shared/c++/HybridAuthStorageAdapterSpec.hpp +0 -65
  126. package/src/AuthStorage.nitro.ts +0 -26
@@ -3,13 +3,83 @@
3
3
  import { logger } from "./utils/logger.js";
4
4
  const CACHE_KEY = "nitro_auth_user";
5
5
  const SCOPES_KEY = "nitro_auth_scopes";
6
+ const MS_REFRESH_TOKEN_KEY = "nitro_auth_microsoft_refresh_token";
6
7
  const DEFAULT_SCOPES = ["openid", "email", "profile"];
7
8
  const MS_DEFAULT_SCOPES = ["openid", "email", "profile", "User.Read"];
9
+ const STORAGE_MODE_SESSION = "session";
10
+ const STORAGE_MODE_LOCAL = "local";
11
+ const STORAGE_MODE_MEMORY = "memory";
12
+ const POPUP_POLL_INTERVAL_MS = 100;
13
+ const POPUP_TIMEOUT_MS = 120000;
14
+ const WEB_STORAGE_MODES = new Set([STORAGE_MODE_SESSION, STORAGE_MODE_LOCAL, STORAGE_MODE_MEMORY]);
15
+ const inMemoryWebStorage = new Map();
16
+ class AuthWebError extends Error {
17
+ constructor(message, underlyingError) {
18
+ super(message);
19
+ this.name = "AuthWebError";
20
+ this.underlyingError = underlyingError;
21
+ }
22
+ }
23
+ const isJsonObject = value => typeof value === "object" && value !== null;
24
+ const isAuthProvider = value => value === "google" || value === "apple" || value === "microsoft";
25
+ const getOptionalString = (source, key) => {
26
+ const candidate = source[key];
27
+ return typeof candidate === "string" ? candidate : undefined;
28
+ };
29
+ const getOptionalNumber = (source, key) => {
30
+ const candidate = source[key];
31
+ return typeof candidate === "number" ? candidate : undefined;
32
+ };
33
+ const parseAuthUser = value => {
34
+ if (!isJsonObject(value) || !isAuthProvider(value.provider)) {
35
+ return undefined;
36
+ }
37
+ const scopesCandidate = value.scopes;
38
+ const scopes = Array.isArray(scopesCandidate) ? scopesCandidate.filter(scope => typeof scope === "string") : undefined;
39
+ return {
40
+ provider: value.provider,
41
+ email: getOptionalString(value, "email"),
42
+ name: getOptionalString(value, "name"),
43
+ photo: getOptionalString(value, "photo"),
44
+ idToken: getOptionalString(value, "idToken"),
45
+ accessToken: getOptionalString(value, "accessToken"),
46
+ refreshToken: getOptionalString(value, "refreshToken"),
47
+ serverAuthCode: getOptionalString(value, "serverAuthCode"),
48
+ scopes,
49
+ expirationTime: getOptionalNumber(value, "expirationTime"),
50
+ underlyingError: getOptionalString(value, "underlyingError")
51
+ };
52
+ };
53
+ const parseScopes = value => {
54
+ if (!Array.isArray(value)) {
55
+ return undefined;
56
+ }
57
+ return value.filter(scope => typeof scope === "string");
58
+ };
59
+ const parseAuthWebExtraConfig = value => {
60
+ if (!isJsonObject(value)) {
61
+ return {};
62
+ }
63
+ const nitroAuthWebStorageCandidate = value.nitroAuthWebStorage;
64
+ const nitroAuthWebStorage = nitroAuthWebStorageCandidate === STORAGE_MODE_SESSION || nitroAuthWebStorageCandidate === STORAGE_MODE_LOCAL || nitroAuthWebStorageCandidate === STORAGE_MODE_MEMORY ? nitroAuthWebStorageCandidate : undefined;
65
+ return {
66
+ googleWebClientId: getOptionalString(value, "googleWebClientId"),
67
+ microsoftClientId: getOptionalString(value, "microsoftClientId"),
68
+ microsoftTenant: getOptionalString(value, "microsoftTenant"),
69
+ microsoftB2cDomain: getOptionalString(value, "microsoftB2cDomain"),
70
+ appleWebClientId: getOptionalString(value, "appleWebClientId"),
71
+ nitroAuthWebStorage,
72
+ nitroAuthPersistTokensOnWeb: typeof value.nitroAuthPersistTokensOnWeb === "boolean" ? value.nitroAuthPersistTokensOnWeb : undefined
73
+ };
74
+ };
8
75
  const getConfig = () => {
9
76
  try {
10
77
  const Constants = require("expo-constants").default;
11
- return Constants.expoConfig?.extra || {};
12
- } catch {
78
+ return parseAuthWebExtraConfig(Constants.expoConfig?.extra);
79
+ } catch (error) {
80
+ logger.debug("expo-constants unavailable on web, falling back to defaults", {
81
+ error: String(error)
82
+ });
13
83
  return {};
14
84
  }
15
85
  };
@@ -17,33 +87,204 @@ class AuthWeb {
17
87
  _grantedScopes = [];
18
88
  _listeners = [];
19
89
  _tokenListeners = [];
90
+ _browserStorageResolved = false;
20
91
  constructor() {
92
+ this._config = getConfig();
21
93
  this.loadFromCache();
22
94
  }
95
+ isPromiseLike(value) {
96
+ if (!isJsonObject(value)) {
97
+ return false;
98
+ }
99
+ return typeof value.then === "function";
100
+ }
101
+ createWebStorageDriver(adapter) {
102
+ return {
103
+ save: (key, value) => {
104
+ const result = adapter.save(key, value);
105
+ if (this.isPromiseLike(result)) {
106
+ throw new Error("On web, JSStorageAdapter.save must be synchronous.");
107
+ }
108
+ },
109
+ load: key => {
110
+ const result = adapter.load(key);
111
+ if (this.isPromiseLike(result)) {
112
+ throw new Error("On web, JSStorageAdapter.load must be synchronous.");
113
+ }
114
+ return result;
115
+ },
116
+ remove: key => {
117
+ const result = adapter.remove(key);
118
+ if (this.isPromiseLike(result)) {
119
+ throw new Error("On web, JSStorageAdapter.remove must be synchronous.");
120
+ }
121
+ }
122
+ };
123
+ }
124
+ shouldPersistTokensInStorage() {
125
+ if (this._storageAdapter) {
126
+ return true;
127
+ }
128
+ return this._config.nitroAuthPersistTokensOnWeb === true;
129
+ }
130
+ getWebStorageMode() {
131
+ const configuredMode = this._config.nitroAuthWebStorage;
132
+ if (configuredMode && WEB_STORAGE_MODES.has(configuredMode)) {
133
+ return configuredMode;
134
+ }
135
+ return STORAGE_MODE_SESSION;
136
+ }
137
+ getBrowserStorage() {
138
+ if (this._browserStorageResolved) {
139
+ return this._browserStorageCache;
140
+ }
141
+ this._browserStorageResolved = true;
142
+ if (typeof window === "undefined") {
143
+ this._browserStorageCache = undefined;
144
+ return undefined;
145
+ }
146
+ const mode = this.getWebStorageMode();
147
+ if (mode === STORAGE_MODE_MEMORY) {
148
+ this._browserStorageCache = undefined;
149
+ return undefined;
150
+ }
151
+ const storage = mode === STORAGE_MODE_LOCAL ? window.localStorage : window.sessionStorage;
152
+ try {
153
+ const testKey = "__nitro_auth_storage_probe__";
154
+ storage.setItem(testKey, "1");
155
+ storage.removeItem(testKey);
156
+ this._browserStorageCache = storage;
157
+ return storage;
158
+ } catch (error) {
159
+ logger.warn("Configured web storage is unavailable; using in-memory fallback", {
160
+ mode,
161
+ error: String(error)
162
+ });
163
+ this._browserStorageCache = undefined;
164
+ return undefined;
165
+ }
166
+ }
167
+ saveValue(key, value) {
168
+ if (this._storageAdapter) {
169
+ this._storageAdapter.save(key, value);
170
+ return;
171
+ }
172
+ const storage = this.getBrowserStorage();
173
+ if (storage) {
174
+ storage.setItem(key, value);
175
+ return;
176
+ }
177
+ inMemoryWebStorage.set(key, value);
178
+ }
179
+ loadValue(key) {
180
+ if (this._storageAdapter) {
181
+ return this._storageAdapter.load(key);
182
+ }
183
+ const storage = this.getBrowserStorage();
184
+ if (storage) {
185
+ return storage.getItem(key) ?? undefined;
186
+ }
187
+ return inMemoryWebStorage.get(key);
188
+ }
189
+ removeValue(key) {
190
+ if (this._storageAdapter) {
191
+ this._storageAdapter.remove(key);
192
+ return;
193
+ }
194
+ const storage = this.getBrowserStorage();
195
+ if (storage) {
196
+ storage.removeItem(key);
197
+ }
198
+ inMemoryWebStorage.delete(key);
199
+ }
200
+ removePersistedBrowserValue(key) {
201
+ if (typeof window === "undefined") {
202
+ return;
203
+ }
204
+ try {
205
+ window.localStorage.removeItem(key);
206
+ window.sessionStorage.removeItem(key);
207
+ } catch (error) {
208
+ logger.debug("Failed to clear persisted browser value", {
209
+ key,
210
+ error: String(error)
211
+ });
212
+ }
213
+ }
214
+ sanitizeUserForPersistence(user) {
215
+ if (this.shouldPersistTokensInStorage()) {
216
+ return user;
217
+ }
218
+ const safeUser = {
219
+ ...user
220
+ };
221
+ delete safeUser.accessToken;
222
+ delete safeUser.idToken;
223
+ delete safeUser.serverAuthCode;
224
+ return safeUser;
225
+ }
226
+ saveRefreshToken(refreshToken) {
227
+ if (this._storageAdapter || this.shouldPersistTokensInStorage()) {
228
+ this.saveValue(MS_REFRESH_TOKEN_KEY, refreshToken);
229
+ return;
230
+ }
231
+
232
+ // Security-first default: keep refresh tokens in-memory only on web.
233
+ inMemoryWebStorage.set(MS_REFRESH_TOKEN_KEY, refreshToken);
234
+ }
235
+ loadRefreshToken() {
236
+ if (this._storageAdapter || this.shouldPersistTokensInStorage()) {
237
+ return this.loadValue(MS_REFRESH_TOKEN_KEY);
238
+ }
239
+ return inMemoryWebStorage.get(MS_REFRESH_TOKEN_KEY);
240
+ }
23
241
  loadFromCache() {
24
- const cached = this._storageAdapter ? this._storageAdapter.load(CACHE_KEY) : localStorage.getItem(CACHE_KEY);
242
+ const cached = this.loadValue(CACHE_KEY);
25
243
  if (cached) {
26
244
  try {
27
- this._currentUser = JSON.parse(cached);
28
- } catch {
245
+ const parsedUser = parseAuthUser(JSON.parse(cached));
246
+ if (!parsedUser) {
247
+ throw new Error("Expected cached auth user to be a valid AuthUser");
248
+ }
249
+ if (this.shouldPersistTokensInStorage()) {
250
+ this._currentUser = parsedUser;
251
+ } else {
252
+ const safeUser = {
253
+ ...parsedUser
254
+ };
255
+ delete safeUser.accessToken;
256
+ delete safeUser.idToken;
257
+ delete safeUser.serverAuthCode;
258
+ this._currentUser = safeUser;
259
+ }
260
+ } catch (error) {
261
+ logger.warn("Failed to parse cached auth user; clearing cache", {
262
+ error: String(error)
263
+ });
29
264
  this.removeFromCache(CACHE_KEY);
30
265
  }
31
266
  }
32
- const scopes = this._storageAdapter ? this._storageAdapter.load(SCOPES_KEY) : localStorage.getItem(SCOPES_KEY);
267
+ const scopes = this.loadValue(SCOPES_KEY);
33
268
  if (scopes) {
34
269
  try {
35
- this._grantedScopes = JSON.parse(scopes);
36
- } catch {
270
+ const parsedScopes = parseScopes(JSON.parse(scopes));
271
+ if (!parsedScopes) {
272
+ throw new Error("Expected cached scopes to be an array");
273
+ }
274
+ this._grantedScopes = parsedScopes;
275
+ } catch (error) {
276
+ logger.warn("Failed to parse cached scopes; clearing cache", {
277
+ error: String(error)
278
+ });
37
279
  this.removeFromCache(SCOPES_KEY);
38
280
  }
39
281
  }
282
+ if (!this.shouldPersistTokensInStorage() && !this._storageAdapter) {
283
+ this.removePersistedBrowserValue(MS_REFRESH_TOKEN_KEY);
284
+ }
40
285
  }
41
286
  removeFromCache(key) {
42
- if (this._storageAdapter) {
43
- this._storageAdapter.remove(key);
44
- } else {
45
- localStorage.removeItem(key);
46
- }
287
+ this.removeValue(key);
47
288
  }
48
289
  get currentUser() {
49
290
  return this._currentUser;
@@ -68,7 +309,9 @@ class AuthWeb {
68
309
  };
69
310
  }
70
311
  notify() {
71
- this._listeners.forEach(l => l(this._currentUser));
312
+ this._listeners.forEach(l => {
313
+ l(this._currentUser);
314
+ });
72
315
  }
73
316
  async login(provider, options) {
74
317
  const loginHint = options?.loginHint;
@@ -117,11 +360,7 @@ class AuthWeb {
117
360
  async revokeScopes(scopes) {
118
361
  logger.log("Revoking scopes:", scopes);
119
362
  this._grantedScopes = this._grantedScopes.filter(s => !scopes.includes(s));
120
- if (this._storageAdapter) {
121
- this._storageAdapter.save(SCOPES_KEY, JSON.stringify(this._grantedScopes));
122
- } else {
123
- localStorage.setItem(SCOPES_KEY, JSON.stringify(this._grantedScopes));
124
- }
363
+ this.saveValue(SCOPES_KEY, JSON.stringify(this._grantedScopes));
125
364
  if (this._currentUser) {
126
365
  this._currentUser.scopes = this._grantedScopes;
127
366
  this.updateUser(this._currentUser);
@@ -138,19 +377,35 @@ class AuthWeb {
138
377
  return this._currentUser?.accessToken;
139
378
  }
140
379
  async refreshToken() {
380
+ if (this._refreshPromise) {
381
+ return this._refreshPromise;
382
+ }
383
+ const refreshPromise = this.performRefreshToken();
384
+ this._refreshPromise = refreshPromise;
385
+ try {
386
+ return await refreshPromise;
387
+ } finally {
388
+ if (this._refreshPromise === refreshPromise) {
389
+ this._refreshPromise = undefined;
390
+ }
391
+ }
392
+ }
393
+ async performRefreshToken() {
141
394
  if (!this._currentUser) {
142
395
  throw new Error("No user logged in");
143
396
  }
144
397
  if (this._currentUser.provider === "microsoft") {
145
398
  logger.log("Refreshing Microsoft tokens...");
146
- const refreshToken = this._storageAdapter ? this._storageAdapter.load("microsoft_refresh_token") : localStorage.getItem("nitro_auth_microsoft_refresh_token");
399
+ const refreshToken = this.loadRefreshToken();
147
400
  if (!refreshToken) {
148
401
  throw new Error("No refresh token available");
149
402
  }
150
- const config = getConfig();
151
- const clientId = config.microsoftClientId;
152
- const tenant = config.microsoftTenant ?? "common";
153
- const b2cDomain = config.microsoftB2cDomain;
403
+ const clientId = this._config.microsoftClientId;
404
+ if (!clientId) {
405
+ throw new Error("Microsoft Client ID not configured. Add 'microsoftClientId' to expo.extra in your app.config.js");
406
+ }
407
+ const tenant = this._config.microsoftTenant ?? "common";
408
+ const b2cDomain = this._config.microsoftB2cDomain;
154
409
  const authBaseUrl = this.getMicrosoftAuthBaseUrl(tenant, b2cDomain);
155
410
  const tokenUrl = `${authBaseUrl}oauth2/v2.0/token`;
156
411
  const body = new URLSearchParams({
@@ -165,35 +420,38 @@ class AuthWeb {
165
420
  },
166
421
  body: body.toString()
167
422
  });
168
- const json = await response.json();
423
+ const json = await this.parseResponseObject(response);
169
424
  if (!response.ok) {
170
- throw new Error(json.error_description ?? json.error ?? "Token refresh failed");
425
+ throw new Error(getOptionalString(json, "error_description") ?? getOptionalString(json, "error") ?? "Token refresh failed");
171
426
  }
172
- const idToken = json.id_token;
173
- const accessToken = json.access_token;
174
- const newRefreshToken = json.refresh_token;
175
- const expiresIn = json.expires_in;
427
+ const idToken = getOptionalString(json, "id_token");
428
+ const accessToken = getOptionalString(json, "access_token");
429
+ const newRefreshToken = getOptionalString(json, "refresh_token");
430
+ const expiresInSeconds = getOptionalNumber(json, "expires_in");
176
431
  if (newRefreshToken) {
177
- if (this._storageAdapter) {
178
- this._storageAdapter.save("microsoft_refresh_token", newRefreshToken);
179
- } else {
180
- localStorage.setItem("nitro_auth_microsoft_refresh_token", newRefreshToken);
181
- }
432
+ this.saveRefreshToken(newRefreshToken);
182
433
  }
183
- const claims = this.decodeMicrosoftJwt(idToken);
434
+ const expirationTime = typeof expiresInSeconds === "number" ? Date.now() + expiresInSeconds * 1000 : undefined;
435
+ const effectiveIdToken = idToken ?? this._currentUser.idToken;
436
+ const claims = effectiveIdToken ? this.decodeMicrosoftJwt(effectiveIdToken) : {};
184
437
  const user = {
185
438
  ...this._currentUser,
186
- idToken,
439
+ idToken: effectiveIdToken,
187
440
  accessToken: accessToken ?? undefined,
188
- expirationTime: expiresIn ? Date.now() + parseInt(expiresIn) * 1000 : undefined,
441
+ refreshToken: newRefreshToken ?? this._currentUser.refreshToken,
442
+ expirationTime,
189
443
  ...claims
190
444
  };
191
445
  this.updateUser(user);
192
446
  const tokens = {
193
- accessToken,
194
- idToken
447
+ accessToken: accessToken ?? undefined,
448
+ idToken: effectiveIdToken,
449
+ refreshToken: newRefreshToken ?? undefined,
450
+ expirationTime
195
451
  };
196
- this._tokenListeners.forEach(l => l(tokens));
452
+ this._tokenListeners.forEach(l => {
453
+ l(tokens);
454
+ });
197
455
  return tokens;
198
456
  }
199
457
  if (this._currentUser.provider !== "google") {
@@ -203,9 +461,13 @@ class AuthWeb {
203
461
  await this.loginGoogle(this._grantedScopes.length > 0 ? this._grantedScopes : DEFAULT_SCOPES);
204
462
  const tokens = {
205
463
  accessToken: this._currentUser.accessToken,
206
- idToken: this._currentUser.idToken
464
+ idToken: this._currentUser.idToken,
465
+ refreshToken: this._currentUser.refreshToken,
466
+ expirationTime: this._currentUser.expirationTime
207
467
  };
208
- this._tokenListeners.forEach(l => l(tokens));
468
+ this._tokenListeners.forEach(l => {
469
+ l(tokens);
470
+ });
209
471
  return tokens;
210
472
  }
211
473
  mapError(error) {
@@ -214,18 +476,83 @@ class AuthWeb {
214
476
  let mappedMsg = rawMessage;
215
477
  if (msg.includes("cancel") || msg.includes("popup_closed")) {
216
478
  mappedMsg = "cancelled";
479
+ } else if (msg.includes("timeout")) {
480
+ mappedMsg = "timeout";
481
+ } else if (msg.includes("popup blocked")) {
482
+ mappedMsg = "popup_blocked";
217
483
  } else if (msg.includes("network")) {
218
484
  mappedMsg = "network_error";
219
485
  } else if (msg.includes("client id") || msg.includes("config")) {
220
486
  mappedMsg = "configuration_error";
221
487
  }
222
- return Object.assign(new Error(mappedMsg), {
223
- underlyingError: rawMessage
488
+ return new AuthWebError(mappedMsg, rawMessage);
489
+ }
490
+ async parseResponseObject(response) {
491
+ const parsed = await response.json();
492
+ if (!isJsonObject(parsed)) {
493
+ throw new Error("Expected JSON object response from auth provider");
494
+ }
495
+ return parsed;
496
+ }
497
+ parseJwtPayload(token) {
498
+ const payload = token.split(".")[1];
499
+ if (!payload) {
500
+ throw new Error("Invalid JWT payload");
501
+ }
502
+ const normalizedPayload = payload.replace(/-/g, "+").replace(/_/g, "/");
503
+ const padding = "=".repeat((4 - normalizedPayload.length % 4) % 4);
504
+ const decoded = JSON.parse(atob(`${normalizedPayload}${padding}`));
505
+ if (!isJsonObject(decoded)) {
506
+ throw new Error("Expected JWT payload to be an object");
507
+ }
508
+ return decoded;
509
+ }
510
+ waitForPopupRedirect(popup, redirectUri, provider, onRedirect) {
511
+ return new Promise((resolve, reject) => {
512
+ let crossOriginLogShown = false;
513
+ const cleanup = (intervalId, timeoutId, shouldClosePopup) => {
514
+ window.clearInterval(intervalId);
515
+ window.clearTimeout(timeoutId);
516
+ if (shouldClosePopup && !popup.closed) {
517
+ popup.close();
518
+ }
519
+ };
520
+ const timeoutId = window.setTimeout(() => {
521
+ cleanup(intervalId, timeoutId, true);
522
+ reject(new Error(`${provider.toLowerCase()}_auth_timeout`));
523
+ }, POPUP_TIMEOUT_MS);
524
+ const intervalId = window.setInterval(() => {
525
+ if (popup.closed) {
526
+ cleanup(intervalId, timeoutId, false);
527
+ reject(new Error("cancelled"));
528
+ return;
529
+ }
530
+ let url;
531
+ try {
532
+ url = popup.location.href;
533
+ } catch (error) {
534
+ if (!crossOriginLogShown) {
535
+ logger.debug(`Waiting for ${provider} auth redirect`, {
536
+ error: String(error)
537
+ });
538
+ crossOriginLogShown = true;
539
+ }
540
+ return;
541
+ }
542
+ if (!url.startsWith(redirectUri)) {
543
+ return;
544
+ }
545
+ cleanup(intervalId, timeoutId, true);
546
+ void Promise.resolve(onRedirect(url)).then(() => {
547
+ resolve();
548
+ }).catch(error => {
549
+ reject(error);
550
+ });
551
+ }, POPUP_POLL_INTERVAL_MS);
224
552
  });
225
553
  }
226
554
  async loginGoogle(scopes, loginHint) {
227
- const config = getConfig();
228
- const clientId = config.googleWebClientId;
555
+ const clientId = this._config.googleWebClientId;
229
556
  if (!clientId) {
230
557
  throw new Error("Google Web Client ID not configured. Add 'GOOGLE_WEB_CLIENT_ID' to your .env file.");
231
558
  }
@@ -236,7 +563,7 @@ class AuthWeb {
236
563
  authUrl.searchParams.set("redirect_uri", redirectUri);
237
564
  authUrl.searchParams.set("response_type", "id_token token code");
238
565
  authUrl.searchParams.set("scope", scopes.join(" "));
239
- authUrl.searchParams.set("nonce", Math.random().toString(36).slice(2));
566
+ authUrl.searchParams.set("nonce", crypto.randomUUID());
240
567
  authUrl.searchParams.set("access_type", "offline");
241
568
  authUrl.searchParams.set("prompt", "consent");
242
569
  if (loginHint) {
@@ -251,70 +578,57 @@ class AuthWeb {
251
578
  reject(new Error("Popup blocked. Please allow popups for this site."));
252
579
  return;
253
580
  }
254
- const checkInterval = setInterval(() => {
255
- try {
256
- if (popup.closed) {
257
- clearInterval(checkInterval);
258
- reject(new Error("cancelled"));
259
- return;
260
- }
261
- const url = popup.location.href;
262
- if (url.startsWith(redirectUri)) {
263
- clearInterval(checkInterval);
264
- popup.close();
265
- const hash = new URL(url).hash.slice(1);
266
- const params = new URLSearchParams(hash);
267
- const idToken = params.get("id_token");
268
- const accessToken = params.get("access_token");
269
- const expiresIn = params.get("expires_in");
270
- const code = params.get("code");
271
- if (idToken) {
272
- this._grantedScopes = scopes;
273
- if (this._storageAdapter) {
274
- this._storageAdapter.save(SCOPES_KEY, JSON.stringify(scopes));
275
- } else {
276
- localStorage.setItem(SCOPES_KEY, JSON.stringify(scopes));
277
- }
278
- const user = {
279
- provider: "google",
280
- idToken,
281
- accessToken: accessToken ?? undefined,
282
- serverAuthCode: code ?? undefined,
283
- scopes,
284
- expirationTime: expiresIn ? Date.now() + parseInt(expiresIn) * 1000 : undefined,
285
- ...this.decodeGoogleJwt(idToken)
286
- };
287
- this.updateUser(user);
288
- resolve();
289
- } else {
290
- reject(new Error("No id_token in response"));
291
- }
292
- }
293
- } catch {}
294
- }, 100);
581
+ void this.waitForPopupRedirect(popup, redirectUri, "Google", url => {
582
+ const hash = new URL(url).hash.slice(1);
583
+ const params = new URLSearchParams(hash);
584
+ const idToken = params.get("id_token");
585
+ const accessToken = params.get("access_token");
586
+ const expiresIn = params.get("expires_in");
587
+ const code = params.get("code");
588
+ if (!idToken) {
589
+ throw new Error("No id_token in response");
590
+ }
591
+ this._grantedScopes = scopes;
592
+ this.saveValue(SCOPES_KEY, JSON.stringify(scopes));
593
+ const user = {
594
+ provider: "google",
595
+ idToken,
596
+ accessToken: accessToken ?? undefined,
597
+ serverAuthCode: code ?? undefined,
598
+ scopes,
599
+ expirationTime: expiresIn ? Date.now() + parseInt(expiresIn, 10) * 1000 : undefined,
600
+ ...this.decodeGoogleJwt(idToken)
601
+ };
602
+ this.updateUser(user);
603
+ }).then(() => {
604
+ resolve();
605
+ }).catch(error => {
606
+ reject(error);
607
+ });
295
608
  });
296
609
  }
297
610
  decodeGoogleJwt(token) {
298
611
  try {
299
- const payload = token.split(".")[1];
300
- const decoded = JSON.parse(atob(payload));
612
+ const decoded = this.parseJwtPayload(token);
301
613
  return {
302
- email: decoded.email,
303
- name: decoded.name,
304
- photo: decoded.picture
614
+ email: getOptionalString(decoded, "email"),
615
+ name: getOptionalString(decoded, "name"),
616
+ photo: getOptionalString(decoded, "picture")
305
617
  };
306
- } catch {
618
+ } catch (error) {
619
+ logger.warn("Failed to decode Google ID token", {
620
+ error: String(error)
621
+ });
307
622
  return {};
308
623
  }
309
624
  }
310
625
  async loginMicrosoft(scopes, loginHint, tenant, prompt) {
311
- const config = getConfig();
312
- const clientId = config.microsoftClientId;
626
+ const clientId = this._config.microsoftClientId;
313
627
  if (!clientId) {
314
628
  throw new Error("Microsoft Client ID not configured. Add 'microsoftClientId' to expo.extra in your app.config.js");
315
629
  }
316
- const effectiveTenant = tenant ?? config.microsoftTenant ?? "common";
317
- const b2cDomain = config.microsoftB2cDomain;
630
+ const effectiveTenant = tenant ?? this._config.microsoftTenant ?? "common";
631
+ const b2cDomain = this._config.microsoftB2cDomain;
318
632
  const authBaseUrl = this.getMicrosoftAuthBaseUrl(effectiveTenant, b2cDomain);
319
633
  const effectiveScopes = scopes.length ? scopes : ["openid", "email", "profile", "offline_access", "User.Read"];
320
634
  const codeVerifier = this.generateCodeVerifier();
@@ -346,43 +660,27 @@ class AuthWeb {
346
660
  reject(new Error("Popup blocked. Please allow popups for this site."));
347
661
  return;
348
662
  }
349
- const checkInterval = setInterval(async () => {
350
- try {
351
- if (popup.closed) {
352
- clearInterval(checkInterval);
353
- reject(new Error("cancelled"));
354
- return;
355
- }
356
- const url = popup.location.href;
357
- if (url.startsWith(redirectUri)) {
358
- clearInterval(checkInterval);
359
- popup.close();
360
- const urlObj = new URL(url);
361
- const code = urlObj.searchParams.get("code");
362
- const returnedState = urlObj.searchParams.get("state");
363
- const error = urlObj.searchParams.get("error");
364
- const errorDescription = urlObj.searchParams.get("error_description");
365
- if (error) {
366
- reject(new Error(errorDescription ?? error));
367
- return;
368
- }
369
- if (returnedState !== state) {
370
- reject(new Error("State mismatch - possible CSRF attack"));
371
- return;
372
- }
373
- if (!code) {
374
- reject(new Error("No authorization code in response"));
375
- return;
376
- }
377
- try {
378
- await this.exchangeMicrosoftCodeForTokens(code, codeVerifier, clientId, redirectUri, effectiveTenant, nonce, effectiveScopes);
379
- resolve();
380
- } catch (e) {
381
- reject(e);
382
- }
383
- }
384
- } catch {}
385
- }, 100);
663
+ void this.waitForPopupRedirect(popup, redirectUri, "Microsoft", async url => {
664
+ const urlObj = new URL(url);
665
+ const code = urlObj.searchParams.get("code");
666
+ const returnedState = urlObj.searchParams.get("state");
667
+ const error = urlObj.searchParams.get("error");
668
+ const errorDescription = urlObj.searchParams.get("error_description");
669
+ if (error) {
670
+ throw new Error(errorDescription ?? error);
671
+ }
672
+ if (returnedState !== state) {
673
+ throw new Error("State mismatch - possible CSRF attack");
674
+ }
675
+ if (!code) {
676
+ throw new Error("No authorization code in response");
677
+ }
678
+ await this.exchangeMicrosoftCodeForTokens(code, codeVerifier, clientId, redirectUri, effectiveTenant, nonce, effectiveScopes);
679
+ }).then(() => {
680
+ resolve();
681
+ }).catch(error => {
682
+ reject(error);
683
+ });
386
684
  });
387
685
  }
388
686
  generateCodeVerifier() {
@@ -401,8 +699,7 @@ class AuthWeb {
401
699
  return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
402
700
  }
403
701
  async exchangeMicrosoftCodeForTokens(code, codeVerifier, clientId, redirectUri, tenant, expectedNonce, scopes) {
404
- const config = getConfig();
405
- const authBaseUrl = this.getMicrosoftAuthBaseUrl(tenant, config.microsoftB2cDomain);
702
+ const authBaseUrl = this.getMicrosoftAuthBaseUrl(tenant, this._config.microsoftB2cDomain);
406
703
  const tokenUrl = `${authBaseUrl}oauth2/v2.0/token`;
407
704
  const body = new URLSearchParams({
408
705
  client_id: clientId,
@@ -418,41 +715,34 @@ class AuthWeb {
418
715
  },
419
716
  body: body.toString()
420
717
  });
421
- const json = await response.json();
718
+ const json = await this.parseResponseObject(response);
422
719
  if (!response.ok) {
423
- throw new Error(json.error_description ?? json.error ?? "Token exchange failed");
720
+ throw new Error(getOptionalString(json, "error_description") ?? getOptionalString(json, "error") ?? "Token exchange failed");
424
721
  }
425
- const idToken = json.id_token;
722
+ const idToken = getOptionalString(json, "id_token");
426
723
  if (!idToken) {
427
724
  throw new Error("No id_token in token response");
428
725
  }
429
726
  const claims = this.decodeMicrosoftJwt(idToken);
430
- const payload = JSON.parse(atob(idToken.split(".")[1]));
431
- if (payload.nonce !== expectedNonce) {
727
+ const payload = this.parseJwtPayload(idToken);
728
+ if (getOptionalString(payload, "nonce") !== expectedNonce) {
432
729
  throw new Error("Nonce mismatch - token may be replayed");
433
730
  }
434
- const accessToken = json.access_token;
435
- const refreshToken = json.refresh_token;
436
- const expiresIn = json.expires_in;
731
+ const accessToken = getOptionalString(json, "access_token");
732
+ const refreshToken = getOptionalString(json, "refresh_token");
733
+ const expiresInSeconds = getOptionalNumber(json, "expires_in");
437
734
  if (refreshToken) {
438
- if (this._storageAdapter) {
439
- this._storageAdapter.save("microsoft_refresh_token", refreshToken);
440
- } else {
441
- localStorage.setItem("nitro_auth_microsoft_refresh_token", refreshToken);
442
- }
735
+ this.saveRefreshToken(refreshToken);
443
736
  }
444
737
  this._grantedScopes = scopes;
445
- if (this._storageAdapter) {
446
- this._storageAdapter.save(SCOPES_KEY, JSON.stringify(scopes));
447
- } else {
448
- localStorage.setItem(SCOPES_KEY, JSON.stringify(scopes));
449
- }
738
+ this.saveValue(SCOPES_KEY, JSON.stringify(scopes));
450
739
  const user = {
451
740
  provider: "microsoft",
452
741
  idToken,
453
742
  accessToken: accessToken ?? undefined,
743
+ refreshToken: refreshToken ?? undefined,
454
744
  scopes,
455
- expirationTime: expiresIn ? Date.now() + parseInt(expiresIn) * 1000 : undefined,
745
+ expirationTime: typeof expiresInSeconds === "number" ? Date.now() + expiresInSeconds * 1000 : undefined,
456
746
  ...claims
457
747
  };
458
748
  this.updateUser(user);
@@ -469,51 +759,88 @@ class AuthWeb {
469
759
  }
470
760
  decodeMicrosoftJwt(token) {
471
761
  try {
472
- const payload = token.split(".")[1];
473
- const decoded = JSON.parse(atob(payload));
762
+ const decoded = this.parseJwtPayload(token);
474
763
  return {
475
- email: decoded.preferred_username ?? decoded.email,
476
- name: decoded.name
764
+ email: getOptionalString(decoded, "preferred_username") ?? getOptionalString(decoded, "email"),
765
+ name: getOptionalString(decoded, "name")
477
766
  };
478
- } catch {
767
+ } catch (error) {
768
+ logger.warn("Failed to decode Microsoft ID token", {
769
+ error: String(error)
770
+ });
479
771
  return {};
480
772
  }
481
773
  }
482
- async loginApple() {
483
- const config = getConfig();
484
- const clientId = config.appleWebClientId;
485
- if (!clientId) {
486
- throw new Error("Apple Web Client ID not configured. Add 'APPLE_WEB_CLIENT_ID' to your .env file.");
774
+ async ensureAppleSdkLoaded() {
775
+ if (window.AppleID) {
776
+ return;
487
777
  }
488
- return new Promise((resolve, reject) => {
778
+ if (this._appleSdkLoadPromise) {
779
+ return this._appleSdkLoadPromise;
780
+ }
781
+ this._appleSdkLoadPromise = new Promise((resolve, reject) => {
782
+ const scriptId = "nitro-auth-apple-sdk";
783
+ const existingScript = document.getElementById(scriptId);
784
+ if (existingScript) {
785
+ if (window.AppleID) {
786
+ resolve();
787
+ return;
788
+ }
789
+ existingScript.addEventListener("load", () => {
790
+ resolve();
791
+ }, {
792
+ once: true
793
+ });
794
+ existingScript.addEventListener("error", () => {
795
+ this._appleSdkLoadPromise = undefined;
796
+ reject(new Error("Failed to load Apple SDK"));
797
+ }, {
798
+ once: true
799
+ });
800
+ return;
801
+ }
489
802
  const script = document.createElement("script");
803
+ script.id = scriptId;
490
804
  script.src = "https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js";
491
805
  script.async = true;
492
806
  script.onload = () => {
493
- if (!window.AppleID) {
494
- reject(new Error("Apple SDK not loaded"));
495
- return;
496
- }
497
- window.AppleID.auth.init({
498
- clientId,
499
- scope: "name email",
500
- redirectURI: window.location.origin,
501
- usePopup: true
502
- });
503
- window.AppleID.auth.signIn().then(response => {
504
- const user = {
505
- provider: "apple",
506
- idToken: response.authorization.id_token,
507
- email: response.user?.email,
508
- name: response.user?.name ? `${response.user.name.firstName} ${response.user.name.lastName}`.trim() : undefined
509
- };
510
- this.updateUser(user);
511
- resolve();
512
- }).catch(err => reject(this.mapError(err)));
807
+ resolve();
808
+ };
809
+ script.onerror = () => {
810
+ this._appleSdkLoadPromise = undefined;
811
+ reject(new Error("Failed to load Apple SDK"));
513
812
  };
514
- script.onerror = () => reject(new Error("Failed to load Apple SDK"));
515
813
  document.head.appendChild(script);
516
814
  });
815
+ return this._appleSdkLoadPromise;
816
+ }
817
+ async loginApple() {
818
+ const clientId = this._config.appleWebClientId;
819
+ if (!clientId) {
820
+ throw new Error("Apple Web Client ID not configured. Add 'APPLE_WEB_CLIENT_ID' to your .env file.");
821
+ }
822
+ await this.ensureAppleSdkLoaded();
823
+ if (!window.AppleID) {
824
+ throw new Error("Apple SDK not loaded");
825
+ }
826
+ window.AppleID.auth.init({
827
+ clientId,
828
+ scope: "name email",
829
+ redirectURI: window.location.origin,
830
+ usePopup: true
831
+ });
832
+ try {
833
+ const response = await window.AppleID.auth.signIn();
834
+ const user = {
835
+ provider: "apple",
836
+ idToken: response.authorization.id_token,
837
+ email: response.user?.email,
838
+ name: response.user?.name ? `${response.user.name.firstName} ${response.user.name.lastName}`.trim() : undefined
839
+ };
840
+ this.updateUser(user);
841
+ } catch (error) {
842
+ throw this.mapError(error);
843
+ }
517
844
  }
518
845
  async silentRestore() {
519
846
  logger.log("Attempting silent restore...");
@@ -533,27 +860,22 @@ class AuthWeb {
533
860
  this._grantedScopes = [];
534
861
  this.removeFromCache(CACHE_KEY);
535
862
  this.removeFromCache(SCOPES_KEY);
536
- this.removeFromCache("microsoft_refresh_token");
863
+ this.removeFromCache(MS_REFRESH_TOKEN_KEY);
537
864
  this.notify();
538
865
  }
539
866
  updateUser(user) {
540
867
  this._currentUser = user;
541
- if (this._storageAdapter) {
542
- this._storageAdapter.save(CACHE_KEY, JSON.stringify(user));
543
- } else {
544
- localStorage.setItem(CACHE_KEY, JSON.stringify(user));
545
- }
868
+ const userToPersist = this.sanitizeUserForPersistence(user);
869
+ this.saveValue(CACHE_KEY, JSON.stringify(userToPersist));
546
870
  this.notify();
547
871
  }
548
872
  setLoggingEnabled(enabled) {
549
873
  logger.setEnabled(enabled);
550
874
  }
551
- setStorageAdapter(adapter) {
552
- this._storageAdapter = adapter;
553
- if (adapter) {
554
- this.loadFromCache();
555
- this.notify();
556
- }
875
+ setWebStorageAdapter(adapter) {
876
+ this._storageAdapter = adapter ? this.createWebStorageDriver(adapter) : undefined;
877
+ this.loadFromCache();
878
+ this.notify();
557
879
  }
558
880
  name = "Auth";
559
881
  dispose() {}