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