react-native-nitro-auth 0.5.1 → 0.5.3

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 (121) hide show
  1. package/README.md +362 -190
  2. package/android/src/main/java/com/auth/AuthAdapter.kt +55 -169
  3. package/android/src/main/java/com/auth/NitroAuthPackage.kt +1 -1
  4. package/app.plugin.js +2 -9
  5. package/cpp/AuthCache.cpp +12 -102
  6. package/cpp/HybridAuth.cpp +37 -61
  7. package/cpp/HybridAuth.hpp +2 -4
  8. package/ios/AuthAdapter.swift +21 -25
  9. package/lib/commonjs/Auth.web.js +433 -164
  10. package/lib/commonjs/Auth.web.js.map +1 -1
  11. package/lib/commonjs/index.js +0 -12
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/index.web.js +0 -12
  14. package/lib/commonjs/index.web.js.map +1 -1
  15. package/lib/commonjs/js-storage-adapter.js +2 -0
  16. package/lib/commonjs/js-storage-adapter.js.map +1 -0
  17. package/lib/commonjs/service.js +7 -84
  18. package/lib/commonjs/service.js.map +1 -1
  19. package/lib/commonjs/service.web.js +1 -5
  20. package/lib/commonjs/service.web.js.map +1 -1
  21. package/lib/commonjs/ui/social-button.js +44 -29
  22. package/lib/commonjs/ui/social-button.js.map +1 -1
  23. package/lib/commonjs/ui/social-button.web.js +44 -29
  24. package/lib/commonjs/ui/social-button.web.js.map +1 -1
  25. package/lib/commonjs/use-auth.js +8 -2
  26. package/lib/commonjs/use-auth.js.map +1 -1
  27. package/lib/commonjs/utils/logger.js +12 -4
  28. package/lib/commonjs/utils/logger.js.map +1 -1
  29. package/lib/module/Auth.web.js +433 -164
  30. package/lib/module/Auth.web.js.map +1 -1
  31. package/lib/module/index.js +0 -1
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/module/index.web.js +0 -1
  34. package/lib/module/index.web.js.map +1 -1
  35. package/lib/module/js-storage-adapter.js +2 -0
  36. package/lib/module/js-storage-adapter.js.map +1 -0
  37. package/lib/module/service.js +7 -84
  38. package/lib/module/service.js.map +1 -1
  39. package/lib/module/service.web.js +1 -5
  40. package/lib/module/service.web.js.map +1 -1
  41. package/lib/module/ui/social-button.js +44 -29
  42. package/lib/module/ui/social-button.js.map +1 -1
  43. package/lib/module/ui/social-button.web.js +44 -29
  44. package/lib/module/ui/social-button.web.js.map +1 -1
  45. package/lib/module/use-auth.js +8 -2
  46. package/lib/module/use-auth.js.map +1 -1
  47. package/lib/module/utils/logger.js +12 -4
  48. package/lib/module/utils/logger.js.map +1 -1
  49. package/lib/typescript/commonjs/Auth.nitro.d.ts +3 -3
  50. package/lib/typescript/commonjs/Auth.nitro.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/Auth.web.d.ts +18 -6
  52. package/lib/typescript/commonjs/Auth.web.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/index.d.ts +1 -2
  54. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/index.web.d.ts +0 -1
  56. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/js-storage-adapter.d.ts +6 -0
  58. package/lib/typescript/commonjs/js-storage-adapter.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/service.d.ts +1 -8
  60. package/lib/typescript/commonjs/service.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/service.web.d.ts +1 -8
  62. package/lib/typescript/commonjs/service.web.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/ui/social-button.d.ts +6 -6
  64. package/lib/typescript/commonjs/ui/social-button.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ui/social-button.web.d.ts +6 -6
  66. package/lib/typescript/commonjs/ui/social-button.web.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/use-auth.d.ts +4 -4
  68. package/lib/typescript/commonjs/use-auth.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/utils/logger.d.ts +4 -4
  70. package/lib/typescript/commonjs/utils/logger.d.ts.map +1 -1
  71. package/lib/typescript/module/Auth.nitro.d.ts +3 -3
  72. package/lib/typescript/module/Auth.nitro.d.ts.map +1 -1
  73. package/lib/typescript/module/Auth.web.d.ts +18 -6
  74. package/lib/typescript/module/Auth.web.d.ts.map +1 -1
  75. package/lib/typescript/module/index.d.ts +1 -2
  76. package/lib/typescript/module/index.d.ts.map +1 -1
  77. package/lib/typescript/module/index.web.d.ts +0 -1
  78. package/lib/typescript/module/index.web.d.ts.map +1 -1
  79. package/lib/typescript/module/js-storage-adapter.d.ts +6 -0
  80. package/lib/typescript/module/js-storage-adapter.d.ts.map +1 -0
  81. package/lib/typescript/module/service.d.ts +1 -8
  82. package/lib/typescript/module/service.d.ts.map +1 -1
  83. package/lib/typescript/module/service.web.d.ts +1 -8
  84. package/lib/typescript/module/service.web.d.ts.map +1 -1
  85. package/lib/typescript/module/ui/social-button.d.ts +6 -6
  86. package/lib/typescript/module/ui/social-button.d.ts.map +1 -1
  87. package/lib/typescript/module/ui/social-button.web.d.ts +6 -6
  88. package/lib/typescript/module/ui/social-button.web.d.ts.map +1 -1
  89. package/lib/typescript/module/use-auth.d.ts +4 -4
  90. package/lib/typescript/module/use-auth.d.ts.map +1 -1
  91. package/lib/typescript/module/utils/logger.d.ts +4 -4
  92. package/lib/typescript/module/utils/logger.d.ts.map +1 -1
  93. package/nitrogen/generated/android/NitroAuth+autolinking.cmake +0 -1
  94. package/nitrogen/generated/shared/c++/AuthTokens.hpp +5 -1
  95. package/nitrogen/generated/shared/c++/AuthUser.hpp +5 -1
  96. package/nitrogen/generated/shared/c++/HybridAuthSpec.cpp +0 -1
  97. package/nitrogen/generated/shared/c++/HybridAuthSpec.hpp +0 -5
  98. package/package.json +11 -8
  99. package/src/Auth.nitro.ts +4 -3
  100. package/src/Auth.web.ts +582 -202
  101. package/src/global.d.ts +0 -1
  102. package/src/index.ts +1 -2
  103. package/src/index.web.ts +0 -1
  104. package/src/js-storage-adapter.ts +5 -0
  105. package/src/service.ts +11 -104
  106. package/src/service.web.ts +0 -7
  107. package/src/ui/social-button.tsx +66 -43
  108. package/src/ui/social-button.web.tsx +67 -44
  109. package/src/use-auth.ts +18 -6
  110. package/src/utils/logger.ts +12 -4
  111. package/lib/commonjs/AuthStorage.nitro.js +0 -6
  112. package/lib/commonjs/AuthStorage.nitro.js.map +0 -1
  113. package/lib/module/AuthStorage.nitro.js +0 -4
  114. package/lib/module/AuthStorage.nitro.js.map +0 -1
  115. package/lib/typescript/commonjs/AuthStorage.nitro.d.ts +0 -26
  116. package/lib/typescript/commonjs/AuthStorage.nitro.d.ts.map +0 -1
  117. package/lib/typescript/module/AuthStorage.nitro.d.ts +0 -26
  118. package/lib/typescript/module/AuthStorage.nitro.d.ts.map +0 -1
  119. package/nitrogen/generated/shared/c++/HybridAuthStorageAdapterSpec.cpp +0 -23
  120. package/nitrogen/generated/shared/c++/HybridAuthStorageAdapterSpec.hpp +0 -65
  121. package/src/AuthStorage.nitro.ts +0 -26
package/src/Auth.web.ts CHANGED
@@ -5,13 +5,31 @@ import type {
5
5
  LoginOptions,
6
6
  AuthTokens,
7
7
  } from "./Auth.nitro";
8
- import type { AuthStorageAdapter } from "./AuthStorage.nitro";
8
+ import type { JSStorageAdapter } from "./js-storage-adapter";
9
9
  import { logger } from "./utils/logger";
10
10
 
11
11
  const CACHE_KEY = "nitro_auth_user";
12
12
  const SCOPES_KEY = "nitro_auth_scopes";
13
+ const MS_REFRESH_TOKEN_KEY = "nitro_auth_microsoft_refresh_token";
13
14
  const DEFAULT_SCOPES = ["openid", "email", "profile"];
14
15
  const MS_DEFAULT_SCOPES = ["openid", "email", "profile", "User.Read"];
16
+ const STORAGE_MODE_SESSION = "session";
17
+ const STORAGE_MODE_LOCAL = "local";
18
+ const STORAGE_MODE_MEMORY = "memory";
19
+ const POPUP_POLL_INTERVAL_MS = 100;
20
+ const POPUP_TIMEOUT_MS = 120000;
21
+ const WEB_STORAGE_MODES = new Set([
22
+ STORAGE_MODE_SESSION,
23
+ STORAGE_MODE_LOCAL,
24
+ STORAGE_MODE_MEMORY,
25
+ ] as const);
26
+ const inMemoryWebStorage = new Map<string, string>();
27
+
28
+ type WebStorageDriver = {
29
+ save(key: string, value: string): void;
30
+ load(key: string): string | undefined;
31
+ remove(key: string): void;
32
+ };
15
33
 
16
34
  type AppleAuthResponse = {
17
35
  authorization: {
@@ -26,11 +44,123 @@ type AppleAuthResponse = {
26
44
  };
27
45
  };
28
46
 
29
- const getConfig = () => {
47
+ type AuthWebExtraConfig = {
48
+ googleWebClientId?: string;
49
+ microsoftClientId?: string;
50
+ microsoftTenant?: string;
51
+ microsoftB2cDomain?: string;
52
+ appleWebClientId?: string;
53
+ nitroAuthWebStorage?: "session" | "local" | "memory";
54
+ nitroAuthPersistTokensOnWeb?: boolean;
55
+ };
56
+
57
+ type JsonObject = Record<string, unknown>;
58
+
59
+ class AuthWebError extends Error {
60
+ public readonly underlyingError?: string;
61
+
62
+ constructor(message: string, underlyingError?: string) {
63
+ super(message);
64
+ this.name = "AuthWebError";
65
+ this.underlyingError = underlyingError;
66
+ }
67
+ }
68
+
69
+ const isJsonObject = (value: unknown): value is JsonObject =>
70
+ typeof value === "object" && value !== null;
71
+
72
+ const isAuthProvider = (value: unknown): value is AuthProvider =>
73
+ value === "google" || value === "apple" || value === "microsoft";
74
+
75
+ const getOptionalString = (
76
+ source: JsonObject,
77
+ key: string,
78
+ ): string | undefined => {
79
+ const candidate = source[key];
80
+ return typeof candidate === "string" ? candidate : undefined;
81
+ };
82
+
83
+ const getOptionalNumber = (
84
+ source: JsonObject,
85
+ key: string,
86
+ ): number | undefined => {
87
+ const candidate = source[key];
88
+ return typeof candidate === "number" ? candidate : undefined;
89
+ };
90
+
91
+ const parseAuthUser = (value: unknown): AuthUser | undefined => {
92
+ if (!isJsonObject(value) || !isAuthProvider(value.provider)) {
93
+ return undefined;
94
+ }
95
+
96
+ const scopesCandidate = value.scopes;
97
+ const scopes = Array.isArray(scopesCandidate)
98
+ ? scopesCandidate.filter(
99
+ (scope): scope is string => typeof scope === "string",
100
+ )
101
+ : undefined;
102
+
103
+ return {
104
+ provider: value.provider,
105
+ email: getOptionalString(value, "email"),
106
+ name: getOptionalString(value, "name"),
107
+ photo: getOptionalString(value, "photo"),
108
+ idToken: getOptionalString(value, "idToken"),
109
+ accessToken: getOptionalString(value, "accessToken"),
110
+ refreshToken: getOptionalString(value, "refreshToken"),
111
+ serverAuthCode: getOptionalString(value, "serverAuthCode"),
112
+ scopes,
113
+ expirationTime: getOptionalNumber(value, "expirationTime"),
114
+ underlyingError: getOptionalString(value, "underlyingError"),
115
+ };
116
+ };
117
+
118
+ const parseScopes = (value: unknown): string[] | undefined => {
119
+ if (!Array.isArray(value)) {
120
+ return undefined;
121
+ }
122
+
123
+ return value.filter((scope): scope is string => typeof scope === "string");
124
+ };
125
+
126
+ const parseAuthWebExtraConfig = (value: unknown): AuthWebExtraConfig => {
127
+ if (!isJsonObject(value)) {
128
+ return {};
129
+ }
130
+
131
+ const nitroAuthWebStorageCandidate = value.nitroAuthWebStorage;
132
+ const nitroAuthWebStorage =
133
+ nitroAuthWebStorageCandidate === STORAGE_MODE_SESSION ||
134
+ nitroAuthWebStorageCandidate === STORAGE_MODE_LOCAL ||
135
+ nitroAuthWebStorageCandidate === STORAGE_MODE_MEMORY
136
+ ? nitroAuthWebStorageCandidate
137
+ : undefined;
138
+
139
+ return {
140
+ googleWebClientId: getOptionalString(value, "googleWebClientId"),
141
+ microsoftClientId: getOptionalString(value, "microsoftClientId"),
142
+ microsoftTenant: getOptionalString(value, "microsoftTenant"),
143
+ microsoftB2cDomain: getOptionalString(value, "microsoftB2cDomain"),
144
+ appleWebClientId: getOptionalString(value, "appleWebClientId"),
145
+ nitroAuthWebStorage,
146
+ nitroAuthPersistTokensOnWeb:
147
+ typeof value.nitroAuthPersistTokensOnWeb === "boolean"
148
+ ? value.nitroAuthPersistTokensOnWeb
149
+ : undefined,
150
+ };
151
+ };
152
+
153
+ const getConfig = (): AuthWebExtraConfig => {
30
154
  try {
31
155
  const Constants = require("expo-constants").default;
32
- return Constants.expoConfig?.extra || {};
33
- } catch {
156
+ return parseAuthWebExtraConfig(Constants.expoConfig?.extra);
157
+ } catch (error) {
158
+ logger.debug(
159
+ "expo-constants unavailable on web, falling back to defaults",
160
+ {
161
+ error: String(error),
162
+ },
163
+ );
34
164
  return {};
35
165
  }
36
166
  };
@@ -40,44 +170,223 @@ class AuthWeb implements Auth {
40
170
  private _grantedScopes: string[] = [];
41
171
  private _listeners: ((user: AuthUser | undefined) => void)[] = [];
42
172
  private _tokenListeners: ((tokens: AuthTokens) => void)[] = [];
43
- private _storageAdapter: AuthStorageAdapter | undefined;
173
+ private _storageAdapter: WebStorageDriver | undefined;
44
174
 
45
175
  constructor() {
46
176
  this.loadFromCache();
47
177
  }
48
178
 
179
+ private isPromiseLike(value: unknown): value is PromiseLike<unknown> {
180
+ if (!isJsonObject(value)) {
181
+ return false;
182
+ }
183
+ return typeof value.then === "function";
184
+ }
185
+
186
+ private createWebStorageDriver(adapter: JSStorageAdapter): WebStorageDriver {
187
+ return {
188
+ save: (key, value) => {
189
+ const result = adapter.save(key, value);
190
+ if (this.isPromiseLike(result)) {
191
+ throw new Error("On web, JSStorageAdapter.save must be synchronous.");
192
+ }
193
+ },
194
+ load: (key) => {
195
+ const result = adapter.load(key);
196
+ if (this.isPromiseLike(result)) {
197
+ throw new Error("On web, JSStorageAdapter.load must be synchronous.");
198
+ }
199
+ return result;
200
+ },
201
+ remove: (key) => {
202
+ const result = adapter.remove(key);
203
+ if (this.isPromiseLike(result)) {
204
+ throw new Error(
205
+ "On web, JSStorageAdapter.remove must be synchronous.",
206
+ );
207
+ }
208
+ },
209
+ };
210
+ }
211
+
212
+ private shouldPersistTokensInStorage(): boolean {
213
+ if (this._storageAdapter) {
214
+ return true;
215
+ }
216
+ return getConfig().nitroAuthPersistTokensOnWeb === true;
217
+ }
218
+
219
+ private getWebStorageMode(): "session" | "local" | "memory" {
220
+ const configuredMode = getConfig().nitroAuthWebStorage;
221
+ if (configuredMode && WEB_STORAGE_MODES.has(configuredMode)) {
222
+ return configuredMode;
223
+ }
224
+ return STORAGE_MODE_SESSION;
225
+ }
226
+
227
+ private getBrowserStorage(): Storage | undefined {
228
+ if (typeof window === "undefined") {
229
+ return undefined;
230
+ }
231
+
232
+ const mode = this.getWebStorageMode();
233
+ if (mode === STORAGE_MODE_MEMORY) {
234
+ return undefined;
235
+ }
236
+
237
+ const storage =
238
+ mode === STORAGE_MODE_LOCAL ? window.localStorage : window.sessionStorage;
239
+ try {
240
+ const testKey = "__nitro_auth_storage_probe__";
241
+ storage.setItem(testKey, "1");
242
+ storage.removeItem(testKey);
243
+ return storage;
244
+ } catch (error) {
245
+ logger.warn(
246
+ "Configured web storage is unavailable; using in-memory fallback",
247
+ {
248
+ mode,
249
+ error: String(error),
250
+ },
251
+ );
252
+ return undefined;
253
+ }
254
+ }
255
+
256
+ private saveValue(key: string, value: string): void {
257
+ if (this._storageAdapter) {
258
+ this._storageAdapter.save(key, value);
259
+ return;
260
+ }
261
+
262
+ const storage = this.getBrowserStorage();
263
+ if (storage) {
264
+ storage.setItem(key, value);
265
+ return;
266
+ }
267
+ inMemoryWebStorage.set(key, value);
268
+ }
269
+
270
+ private loadValue(key: string): string | undefined {
271
+ if (this._storageAdapter) {
272
+ return this._storageAdapter.load(key);
273
+ }
274
+
275
+ const storage = this.getBrowserStorage();
276
+ if (storage) {
277
+ return storage.getItem(key) ?? undefined;
278
+ }
279
+ return inMemoryWebStorage.get(key);
280
+ }
281
+
282
+ private removeValue(key: string): void {
283
+ if (this._storageAdapter) {
284
+ this._storageAdapter.remove(key);
285
+ return;
286
+ }
287
+
288
+ const storage = this.getBrowserStorage();
289
+ if (storage) {
290
+ storage.removeItem(key);
291
+ }
292
+ inMemoryWebStorage.delete(key);
293
+ }
294
+
295
+ private removePersistedBrowserValue(key: string): void {
296
+ if (typeof window === "undefined") {
297
+ return;
298
+ }
299
+
300
+ try {
301
+ window.localStorage.removeItem(key);
302
+ window.sessionStorage.removeItem(key);
303
+ } catch (error) {
304
+ logger.debug("Failed to clear persisted browser value", {
305
+ key,
306
+ error: String(error),
307
+ });
308
+ }
309
+ }
310
+
311
+ private sanitizeUserForPersistence(user: AuthUser): AuthUser {
312
+ if (this.shouldPersistTokensInStorage()) {
313
+ return user;
314
+ }
315
+
316
+ const safeUser = { ...user };
317
+ delete safeUser.accessToken;
318
+ delete safeUser.idToken;
319
+ delete safeUser.serverAuthCode;
320
+ return safeUser;
321
+ }
322
+
323
+ private saveRefreshToken(refreshToken: string): void {
324
+ if (this._storageAdapter || this.shouldPersistTokensInStorage()) {
325
+ this.saveValue(MS_REFRESH_TOKEN_KEY, refreshToken);
326
+ return;
327
+ }
328
+
329
+ // Security-first default: keep refresh tokens in-memory only on web.
330
+ inMemoryWebStorage.set(MS_REFRESH_TOKEN_KEY, refreshToken);
331
+ }
332
+
333
+ private loadRefreshToken(): string | undefined {
334
+ if (this._storageAdapter || this.shouldPersistTokensInStorage()) {
335
+ return this.loadValue(MS_REFRESH_TOKEN_KEY);
336
+ }
337
+ return inMemoryWebStorage.get(MS_REFRESH_TOKEN_KEY);
338
+ }
339
+
49
340
  private loadFromCache() {
50
- const cached = this._storageAdapter
51
- ? this._storageAdapter.load(CACHE_KEY)
52
- : localStorage.getItem(CACHE_KEY);
341
+ const cached = this.loadValue(CACHE_KEY);
53
342
 
54
343
  if (cached) {
55
344
  try {
56
- this._currentUser = JSON.parse(cached);
57
- } catch {
345
+ const parsedUser = parseAuthUser(JSON.parse(cached));
346
+ if (!parsedUser) {
347
+ throw new Error("Expected cached auth user to be a valid AuthUser");
348
+ }
349
+ if (this.shouldPersistTokensInStorage()) {
350
+ this._currentUser = parsedUser;
351
+ } else {
352
+ const safeUser = { ...parsedUser };
353
+ delete safeUser.accessToken;
354
+ delete safeUser.idToken;
355
+ delete safeUser.serverAuthCode;
356
+ this._currentUser = safeUser;
357
+ }
358
+ } catch (error) {
359
+ logger.warn("Failed to parse cached auth user; clearing cache", {
360
+ error: String(error),
361
+ });
58
362
  this.removeFromCache(CACHE_KEY);
59
363
  }
60
364
  }
61
365
 
62
- const scopes = this._storageAdapter
63
- ? this._storageAdapter.load(SCOPES_KEY)
64
- : localStorage.getItem(SCOPES_KEY);
366
+ const scopes = this.loadValue(SCOPES_KEY);
65
367
 
66
368
  if (scopes) {
67
369
  try {
68
- this._grantedScopes = JSON.parse(scopes);
69
- } catch {
370
+ const parsedScopes = parseScopes(JSON.parse(scopes));
371
+ if (!parsedScopes) {
372
+ throw new Error("Expected cached scopes to be an array");
373
+ }
374
+ this._grantedScopes = parsedScopes;
375
+ } catch (error) {
376
+ logger.warn("Failed to parse cached scopes; clearing cache", {
377
+ error: String(error),
378
+ });
70
379
  this.removeFromCache(SCOPES_KEY);
71
380
  }
72
381
  }
382
+
383
+ if (!this.shouldPersistTokensInStorage() && !this._storageAdapter) {
384
+ this.removePersistedBrowserValue(MS_REFRESH_TOKEN_KEY);
385
+ }
73
386
  }
74
387
 
75
388
  private removeFromCache(key: string) {
76
- if (this._storageAdapter) {
77
- this._storageAdapter.remove(key);
78
- } else {
79
- localStorage.removeItem(key);
80
- }
389
+ this.removeValue(key);
81
390
  }
82
391
 
83
392
  get currentUser(): AuthUser | undefined {
@@ -110,7 +419,9 @@ class AuthWeb implements Auth {
110
419
  }
111
420
 
112
421
  private notify() {
113
- this._listeners.forEach((l) => l(this._currentUser));
422
+ this._listeners.forEach((l) => {
423
+ l(this._currentUser);
424
+ });
114
425
  }
115
426
 
116
427
  async login(provider: AuthProvider, options?: LoginOptions): Promise<void> {
@@ -169,14 +480,7 @@ class AuthWeb implements Auth {
169
480
  this._grantedScopes = this._grantedScopes.filter(
170
481
  (s) => !scopes.includes(s),
171
482
  );
172
- if (this._storageAdapter) {
173
- this._storageAdapter.save(
174
- SCOPES_KEY,
175
- JSON.stringify(this._grantedScopes),
176
- );
177
- } else {
178
- localStorage.setItem(SCOPES_KEY, JSON.stringify(this._grantedScopes));
179
- }
483
+ this.saveValue(SCOPES_KEY, JSON.stringify(this._grantedScopes));
180
484
  if (this._currentUser) {
181
485
  this._currentUser.scopes = this._grantedScopes;
182
486
  this.updateUser(this._currentUser);
@@ -194,16 +498,14 @@ class AuthWeb implements Auth {
194
498
  return this._currentUser?.accessToken;
195
499
  }
196
500
 
197
- async refreshToken(): Promise<{ accessToken?: string; idToken?: string }> {
501
+ async refreshToken(): Promise<AuthTokens> {
198
502
  if (!this._currentUser) {
199
503
  throw new Error("No user logged in");
200
504
  }
201
505
 
202
506
  if (this._currentUser.provider === "microsoft") {
203
507
  logger.log("Refreshing Microsoft tokens...");
204
- const refreshToken = this._storageAdapter
205
- ? this._storageAdapter.load("microsoft_refresh_token")
206
- : localStorage.getItem("nitro_auth_microsoft_refresh_token");
508
+ const refreshToken = this.loadRefreshToken();
207
509
 
208
510
  if (!refreshToken) {
209
511
  throw new Error("No refresh token available");
@@ -211,6 +513,11 @@ class AuthWeb implements Auth {
211
513
 
212
514
  const config = getConfig();
213
515
  const clientId = config.microsoftClientId;
516
+ if (!clientId) {
517
+ throw new Error(
518
+ "Microsoft Client ID not configured. Add 'microsoftClientId' to expo.extra in your app.config.js",
519
+ );
520
+ }
214
521
  const tenant = config.microsoftTenant ?? "common";
215
522
  const b2cDomain = config.microsoftB2cDomain;
216
523
 
@@ -230,43 +537,52 @@ class AuthWeb implements Auth {
230
537
  body: body.toString(),
231
538
  });
232
539
 
233
- const json = await response.json();
540
+ const json = await this.parseResponseObject(response);
234
541
  if (!response.ok) {
235
542
  throw new Error(
236
- json.error_description ?? json.error ?? "Token refresh failed",
543
+ getOptionalString(json, "error_description") ??
544
+ getOptionalString(json, "error") ??
545
+ "Token refresh failed",
237
546
  );
238
547
  }
239
548
 
240
- const idToken = json.id_token;
241
- const accessToken = json.access_token;
242
- const newRefreshToken = json.refresh_token;
243
- const expiresIn = json.expires_in;
549
+ const idToken = getOptionalString(json, "id_token");
550
+ const accessToken = getOptionalString(json, "access_token");
551
+ const newRefreshToken = getOptionalString(json, "refresh_token");
552
+ const expiresInSeconds = getOptionalNumber(json, "expires_in");
244
553
 
245
554
  if (newRefreshToken) {
246
- if (this._storageAdapter) {
247
- this._storageAdapter.save("microsoft_refresh_token", newRefreshToken);
248
- } else {
249
- localStorage.setItem(
250
- "nitro_auth_microsoft_refresh_token",
251
- newRefreshToken,
252
- );
253
- }
555
+ this.saveRefreshToken(newRefreshToken);
254
556
  }
255
557
 
256
- const claims = this.decodeMicrosoftJwt(idToken);
558
+ const expirationTime =
559
+ typeof expiresInSeconds === "number"
560
+ ? Date.now() + expiresInSeconds * 1000
561
+ : undefined;
562
+
563
+ const effectiveIdToken = idToken ?? this._currentUser.idToken;
564
+ const claims = effectiveIdToken
565
+ ? this.decodeMicrosoftJwt(effectiveIdToken)
566
+ : {};
257
567
  const user: AuthUser = {
258
568
  ...this._currentUser,
259
- idToken,
569
+ idToken: effectiveIdToken,
260
570
  accessToken: accessToken ?? undefined,
261
- expirationTime: expiresIn
262
- ? Date.now() + parseInt(expiresIn) * 1000
263
- : undefined,
571
+ refreshToken: newRefreshToken ?? this._currentUser.refreshToken,
572
+ expirationTime,
264
573
  ...claims,
265
574
  };
266
575
  this.updateUser(user);
267
576
 
268
- const tokens = { accessToken, idToken };
269
- this._tokenListeners.forEach((l) => l(tokens));
577
+ const tokens: AuthTokens = {
578
+ accessToken: accessToken ?? undefined,
579
+ idToken: effectiveIdToken,
580
+ refreshToken: newRefreshToken ?? undefined,
581
+ expirationTime,
582
+ };
583
+ this._tokenListeners.forEach((l) => {
584
+ l(tokens);
585
+ });
270
586
  return tokens;
271
587
  }
272
588
 
@@ -280,11 +596,15 @@ class AuthWeb implements Auth {
280
596
  await this.loginGoogle(
281
597
  this._grantedScopes.length > 0 ? this._grantedScopes : DEFAULT_SCOPES,
282
598
  );
283
- const tokens = {
599
+ const tokens: AuthTokens = {
284
600
  accessToken: this._currentUser.accessToken,
285
601
  idToken: this._currentUser.idToken,
602
+ refreshToken: this._currentUser.refreshToken,
603
+ expirationTime: this._currentUser.expirationTime,
286
604
  };
287
- this._tokenListeners.forEach((l) => l(tokens));
605
+ this._tokenListeners.forEach((l) => {
606
+ l(tokens);
607
+ });
288
608
  return tokens;
289
609
  }
290
610
 
@@ -295,13 +615,100 @@ class AuthWeb implements Auth {
295
615
 
296
616
  if (msg.includes("cancel") || msg.includes("popup_closed")) {
297
617
  mappedMsg = "cancelled";
618
+ } else if (msg.includes("timeout")) {
619
+ mappedMsg = "timeout";
620
+ } else if (msg.includes("popup blocked")) {
621
+ mappedMsg = "popup_blocked";
298
622
  } else if (msg.includes("network")) {
299
623
  mappedMsg = "network_error";
300
624
  } else if (msg.includes("client id") || msg.includes("config")) {
301
625
  mappedMsg = "configuration_error";
302
626
  }
303
627
 
304
- return Object.assign(new Error(mappedMsg), { underlyingError: rawMessage });
628
+ return new AuthWebError(mappedMsg, rawMessage);
629
+ }
630
+
631
+ private async parseResponseObject(response: Response): Promise<JsonObject> {
632
+ const parsed: unknown = await response.json();
633
+ if (!isJsonObject(parsed)) {
634
+ throw new Error("Expected JSON object response from auth provider");
635
+ }
636
+ return parsed;
637
+ }
638
+
639
+ private parseJwtPayload(token: string): JsonObject {
640
+ const payload = token.split(".")[1];
641
+ if (!payload) {
642
+ throw new Error("Invalid JWT payload");
643
+ }
644
+
645
+ const decoded: unknown = JSON.parse(atob(payload));
646
+ if (!isJsonObject(decoded)) {
647
+ throw new Error("Expected JWT payload to be an object");
648
+ }
649
+ return decoded;
650
+ }
651
+
652
+ private waitForPopupRedirect(
653
+ popup: Window,
654
+ redirectUri: string,
655
+ provider: "Google" | "Microsoft",
656
+ onRedirect: (url: string) => Promise<void> | void,
657
+ ): Promise<void> {
658
+ return new Promise((resolve, reject) => {
659
+ let crossOriginLogShown = false;
660
+
661
+ const cleanup = (
662
+ intervalId: number,
663
+ timeoutId: number,
664
+ shouldClosePopup: boolean,
665
+ ) => {
666
+ window.clearInterval(intervalId);
667
+ window.clearTimeout(timeoutId);
668
+ if (shouldClosePopup && !popup.closed) {
669
+ popup.close();
670
+ }
671
+ };
672
+
673
+ const timeoutId = window.setTimeout(() => {
674
+ cleanup(intervalId, timeoutId, true);
675
+ reject(new Error(`${provider.toLowerCase()}_auth_timeout`));
676
+ }, POPUP_TIMEOUT_MS);
677
+
678
+ const intervalId = window.setInterval(() => {
679
+ if (popup.closed) {
680
+ cleanup(intervalId, timeoutId, false);
681
+ reject(new Error("cancelled"));
682
+ return;
683
+ }
684
+
685
+ let url: string;
686
+ try {
687
+ url = popup.location.href;
688
+ } catch (error) {
689
+ if (!crossOriginLogShown) {
690
+ logger.debug(`Waiting for ${provider} auth redirect`, {
691
+ error: String(error),
692
+ });
693
+ crossOriginLogShown = true;
694
+ }
695
+ return;
696
+ }
697
+
698
+ if (!url.startsWith(redirectUri)) {
699
+ return;
700
+ }
701
+
702
+ cleanup(intervalId, timeoutId, true);
703
+ void Promise.resolve(onRedirect(url))
704
+ .then(() => {
705
+ resolve();
706
+ })
707
+ .catch((error: unknown) => {
708
+ reject(error);
709
+ });
710
+ }, POPUP_POLL_INTERVAL_MS);
711
+ });
305
712
  }
306
713
 
307
714
  private async loginGoogle(
@@ -324,7 +731,7 @@ class AuthWeb implements Auth {
324
731
  authUrl.searchParams.set("redirect_uri", redirectUri);
325
732
  authUrl.searchParams.set("response_type", "id_token token code");
326
733
  authUrl.searchParams.set("scope", scopes.join(" "));
327
- authUrl.searchParams.set("nonce", Math.random().toString(36).slice(2));
734
+ authUrl.searchParams.set("nonce", crypto.randomUUID());
328
735
  authUrl.searchParams.set("access_type", "offline");
329
736
  authUrl.searchParams.set("prompt", "consent");
330
737
 
@@ -348,66 +755,53 @@ class AuthWeb implements Auth {
348
755
  return;
349
756
  }
350
757
 
351
- const checkInterval = setInterval(() => {
352
- try {
353
- if (popup.closed) {
354
- clearInterval(checkInterval);
355
- reject(new Error("cancelled"));
356
- return;
357
- }
758
+ void this.waitForPopupRedirect(popup, redirectUri, "Google", (url) => {
759
+ const hash = new URL(url).hash.slice(1);
760
+ const params = new URLSearchParams(hash);
761
+ const idToken = params.get("id_token");
762
+ const accessToken = params.get("access_token");
763
+ const expiresIn = params.get("expires_in");
764
+ const code = params.get("code");
358
765
 
359
- const url = popup.location.href;
360
- if (url.startsWith(redirectUri)) {
361
- clearInterval(checkInterval);
362
- popup.close();
363
-
364
- const hash = new URL(url).hash.slice(1);
365
- const params = new URLSearchParams(hash);
366
- const idToken = params.get("id_token");
367
- const accessToken = params.get("access_token");
368
- const expiresIn = params.get("expires_in");
369
- const code = params.get("code");
370
-
371
- if (idToken) {
372
- this._grantedScopes = scopes;
373
- if (this._storageAdapter) {
374
- this._storageAdapter.save(SCOPES_KEY, JSON.stringify(scopes));
375
- } else {
376
- localStorage.setItem(SCOPES_KEY, JSON.stringify(scopes));
377
- }
378
-
379
- const user: AuthUser = {
380
- provider: "google",
381
- idToken,
382
- accessToken: accessToken ?? undefined,
383
- serverAuthCode: code ?? undefined,
384
- scopes,
385
- expirationTime: expiresIn
386
- ? Date.now() + parseInt(expiresIn) * 1000
387
- : undefined,
388
- ...this.decodeGoogleJwt(idToken),
389
- };
390
- this.updateUser(user);
391
- resolve();
392
- } else {
393
- reject(new Error("No id_token in response"));
394
- }
395
- }
396
- } catch {}
397
- }, 100);
766
+ if (!idToken) {
767
+ throw new Error("No id_token in response");
768
+ }
769
+
770
+ this._grantedScopes = scopes;
771
+ this.saveValue(SCOPES_KEY, JSON.stringify(scopes));
772
+
773
+ const user: AuthUser = {
774
+ provider: "google",
775
+ idToken,
776
+ accessToken: accessToken ?? undefined,
777
+ serverAuthCode: code ?? undefined,
778
+ scopes,
779
+ expirationTime: expiresIn
780
+ ? Date.now() + parseInt(expiresIn, 10) * 1000
781
+ : undefined,
782
+ ...this.decodeGoogleJwt(idToken),
783
+ };
784
+ this.updateUser(user);
785
+ })
786
+ .then(() => {
787
+ resolve();
788
+ })
789
+ .catch((error: unknown) => {
790
+ reject(error);
791
+ });
398
792
  });
399
793
  }
400
794
 
401
795
  private decodeGoogleJwt(token: string): Partial<AuthUser> {
402
796
  try {
403
- const payload = token.split(".")[1];
404
- const decoded = JSON.parse(atob(payload));
797
+ const decoded = this.parseJwtPayload(token);
405
798
  return {
406
- email: decoded.email,
407
- name: decoded.name,
408
- photo: decoded.picture,
799
+ email: getOptionalString(decoded, "email"),
800
+ name: getOptionalString(decoded, "name"),
801
+ photo: getOptionalString(decoded, "picture"),
409
802
  };
410
- } catch {
803
+ } catch (error) {
804
+ logger.warn("Failed to decode Google ID token", { error: String(error) });
411
805
  return {};
412
806
  }
413
807
  }
@@ -477,58 +871,46 @@ class AuthWeb implements Auth {
477
871
  return;
478
872
  }
479
873
 
480
- const checkInterval = setInterval(async () => {
481
- try {
482
- if (popup.closed) {
483
- clearInterval(checkInterval);
484
- reject(new Error("cancelled"));
485
- return;
874
+ void this.waitForPopupRedirect(
875
+ popup,
876
+ redirectUri,
877
+ "Microsoft",
878
+ async (url) => {
879
+ const urlObj = new URL(url);
880
+ const code = urlObj.searchParams.get("code");
881
+ const returnedState = urlObj.searchParams.get("state");
882
+ const error = urlObj.searchParams.get("error");
883
+ const errorDescription = urlObj.searchParams.get("error_description");
884
+
885
+ if (error) {
886
+ throw new Error(errorDescription ?? error);
887
+ }
888
+
889
+ if (returnedState !== state) {
890
+ throw new Error("State mismatch - possible CSRF attack");
486
891
  }
487
892
 
488
- const url = popup.location.href;
489
- if (url.startsWith(redirectUri)) {
490
- clearInterval(checkInterval);
491
- popup.close();
492
-
493
- const urlObj = new URL(url);
494
- const code = urlObj.searchParams.get("code");
495
- const returnedState = urlObj.searchParams.get("state");
496
- const error = urlObj.searchParams.get("error");
497
- const errorDescription =
498
- urlObj.searchParams.get("error_description");
499
-
500
- if (error) {
501
- reject(new Error(errorDescription ?? error));
502
- return;
503
- }
504
-
505
- if (returnedState !== state) {
506
- reject(new Error("State mismatch - possible CSRF attack"));
507
- return;
508
- }
509
-
510
- if (!code) {
511
- reject(new Error("No authorization code in response"));
512
- return;
513
- }
514
-
515
- try {
516
- await this.exchangeMicrosoftCodeForTokens(
517
- code,
518
- codeVerifier,
519
- clientId,
520
- redirectUri,
521
- effectiveTenant,
522
- nonce,
523
- effectiveScopes,
524
- );
525
- resolve();
526
- } catch (e) {
527
- reject(e);
528
- }
893
+ if (!code) {
894
+ throw new Error("No authorization code in response");
529
895
  }
530
- } catch {}
531
- }, 100);
896
+
897
+ await this.exchangeMicrosoftCodeForTokens(
898
+ code,
899
+ codeVerifier,
900
+ clientId,
901
+ redirectUri,
902
+ effectiveTenant,
903
+ nonce,
904
+ effectiveScopes,
905
+ );
906
+ },
907
+ )
908
+ .then(() => {
909
+ resolve();
910
+ })
911
+ .catch((error: unknown) => {
912
+ reject(error);
913
+ });
532
914
  });
533
915
  }
534
916
 
@@ -582,55 +964,48 @@ class AuthWeb implements Auth {
582
964
  body: body.toString(),
583
965
  });
584
966
 
585
- const json = await response.json();
967
+ const json = await this.parseResponseObject(response);
586
968
 
587
969
  if (!response.ok) {
588
970
  throw new Error(
589
- json.error_description ?? json.error ?? "Token exchange failed",
971
+ getOptionalString(json, "error_description") ??
972
+ getOptionalString(json, "error") ??
973
+ "Token exchange failed",
590
974
  );
591
975
  }
592
976
 
593
- const idToken = json.id_token;
977
+ const idToken = getOptionalString(json, "id_token");
594
978
  if (!idToken) {
595
979
  throw new Error("No id_token in token response");
596
980
  }
597
981
 
598
982
  const claims = this.decodeMicrosoftJwt(idToken);
599
- const payload = JSON.parse(atob(idToken.split(".")[1]));
600
- if (payload.nonce !== expectedNonce) {
983
+ const payload = this.parseJwtPayload(idToken);
984
+ if (getOptionalString(payload, "nonce") !== expectedNonce) {
601
985
  throw new Error("Nonce mismatch - token may be replayed");
602
986
  }
603
987
 
604
- const accessToken = json.access_token;
605
- const refreshToken = json.refresh_token;
606
- const expiresIn = json.expires_in;
988
+ const accessToken = getOptionalString(json, "access_token");
989
+ const refreshToken = getOptionalString(json, "refresh_token");
990
+ const expiresInSeconds = getOptionalNumber(json, "expires_in");
607
991
 
608
992
  if (refreshToken) {
609
- if (this._storageAdapter) {
610
- this._storageAdapter.save("microsoft_refresh_token", refreshToken);
611
- } else {
612
- localStorage.setItem(
613
- "nitro_auth_microsoft_refresh_token",
614
- refreshToken,
615
- );
616
- }
993
+ this.saveRefreshToken(refreshToken);
617
994
  }
618
995
 
619
996
  this._grantedScopes = scopes;
620
- if (this._storageAdapter) {
621
- this._storageAdapter.save(SCOPES_KEY, JSON.stringify(scopes));
622
- } else {
623
- localStorage.setItem(SCOPES_KEY, JSON.stringify(scopes));
624
- }
997
+ this.saveValue(SCOPES_KEY, JSON.stringify(scopes));
625
998
 
626
999
  const user: AuthUser = {
627
1000
  provider: "microsoft",
628
1001
  idToken,
629
1002
  accessToken: accessToken ?? undefined,
1003
+ refreshToken: refreshToken ?? undefined,
630
1004
  scopes,
631
- expirationTime: expiresIn
632
- ? Date.now() + parseInt(expiresIn) * 1000
633
- : undefined,
1005
+ expirationTime:
1006
+ typeof expiresInSeconds === "number"
1007
+ ? Date.now() + expiresInSeconds * 1000
1008
+ : undefined,
634
1009
  ...claims,
635
1010
  };
636
1011
  this.updateUser(user);
@@ -650,13 +1025,17 @@ class AuthWeb implements Auth {
650
1025
 
651
1026
  private decodeMicrosoftJwt(token: string): Partial<AuthUser> {
652
1027
  try {
653
- const payload = token.split(".")[1];
654
- const decoded = JSON.parse(atob(payload));
1028
+ const decoded = this.parseJwtPayload(token);
655
1029
  return {
656
- email: decoded.preferred_username ?? decoded.email,
657
- name: decoded.name,
1030
+ email:
1031
+ getOptionalString(decoded, "preferred_username") ??
1032
+ getOptionalString(decoded, "email"),
1033
+ name: getOptionalString(decoded, "name"),
658
1034
  };
659
- } catch {
1035
+ } catch (error) {
1036
+ logger.warn("Failed to decode Microsoft ID token", {
1037
+ error: String(error),
1038
+ });
660
1039
  return {};
661
1040
  }
662
1041
  }
@@ -701,9 +1080,13 @@ class AuthWeb implements Auth {
701
1080
  this.updateUser(user);
702
1081
  resolve();
703
1082
  })
704
- .catch((err: unknown) => reject(this.mapError(err)));
1083
+ .catch((err: unknown) => {
1084
+ reject(this.mapError(err));
1085
+ });
1086
+ };
1087
+ script.onerror = () => {
1088
+ reject(new Error("Failed to load Apple SDK"));
705
1089
  };
706
- script.onerror = () => reject(new Error("Failed to load Apple SDK"));
707
1090
  document.head.appendChild(script);
708
1091
  });
709
1092
  }
@@ -727,17 +1110,14 @@ class AuthWeb implements Auth {
727
1110
  this._grantedScopes = [];
728
1111
  this.removeFromCache(CACHE_KEY);
729
1112
  this.removeFromCache(SCOPES_KEY);
730
- this.removeFromCache("microsoft_refresh_token");
1113
+ this.removeFromCache(MS_REFRESH_TOKEN_KEY);
731
1114
  this.notify();
732
1115
  }
733
1116
 
734
1117
  private updateUser(user: AuthUser) {
735
1118
  this._currentUser = user;
736
- if (this._storageAdapter) {
737
- this._storageAdapter.save(CACHE_KEY, JSON.stringify(user));
738
- } else {
739
- localStorage.setItem(CACHE_KEY, JSON.stringify(user));
740
- }
1119
+ const userToPersist = this.sanitizeUserForPersistence(user);
1120
+ this.saveValue(CACHE_KEY, JSON.stringify(userToPersist));
741
1121
  this.notify();
742
1122
  }
743
1123
 
@@ -745,12 +1125,12 @@ class AuthWeb implements Auth {
745
1125
  logger.setEnabled(enabled);
746
1126
  }
747
1127
 
748
- setStorageAdapter(adapter: AuthStorageAdapter | undefined): void {
749
- this._storageAdapter = adapter;
750
- if (adapter) {
751
- this.loadFromCache();
752
- this.notify();
753
- }
1128
+ setWebStorageAdapter(adapter: JSStorageAdapter | undefined): void {
1129
+ this._storageAdapter = adapter
1130
+ ? this.createWebStorageDriver(adapter)
1131
+ : undefined;
1132
+ this.loadFromCache();
1133
+ this.notify();
754
1134
  }
755
1135
 
756
1136
  name = "Auth";