react-native-nitro-auth 0.5.3 → 0.5.5

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 (59) hide show
  1. package/README.md +60 -30
  2. package/android/build.gradle +2 -5
  3. package/android/src/main/cpp/JniOnLoad.cpp +3 -1
  4. package/android/src/main/cpp/PlatformAuth+Android.cpp +95 -29
  5. package/android/src/main/java/com/auth/AuthAdapter.kt +124 -126
  6. package/android/src/main/java/com/auth/NitroAuthModule.kt +8 -1
  7. package/cpp/AuthCache.cpp +0 -44
  8. package/cpp/AuthCache.hpp +0 -7
  9. package/cpp/HybridAuth.cpp +20 -2
  10. package/cpp/HybridAuth.hpp +1 -0
  11. package/ios/AuthAdapter.swift +64 -28
  12. package/lib/commonjs/Auth.web.js +96 -43
  13. package/lib/commonjs/Auth.web.js.map +1 -1
  14. package/lib/commonjs/index.js +23 -1
  15. package/lib/commonjs/index.js.map +1 -1
  16. package/lib/commonjs/service.js +33 -8
  17. package/lib/commonjs/service.js.map +1 -1
  18. package/lib/commonjs/use-auth.js +51 -54
  19. package/lib/commonjs/use-auth.js.map +1 -1
  20. package/lib/commonjs/utils/auth-error.js +37 -0
  21. package/lib/commonjs/utils/auth-error.js.map +1 -0
  22. package/lib/module/Auth.web.js +96 -43
  23. package/lib/module/Auth.web.js.map +1 -1
  24. package/lib/module/index.js +1 -0
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/service.js +33 -8
  27. package/lib/module/service.js.map +1 -1
  28. package/lib/module/use-auth.js +51 -54
  29. package/lib/module/use-auth.js.map +1 -1
  30. package/lib/module/utils/auth-error.js +30 -0
  31. package/lib/module/utils/auth-error.js.map +1 -0
  32. package/lib/typescript/commonjs/Auth.web.d.ts +7 -0
  33. package/lib/typescript/commonjs/Auth.web.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/index.d.ts +1 -0
  35. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/service.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/use-auth.d.ts +2 -1
  38. package/lib/typescript/commonjs/use-auth.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/utils/auth-error.d.ts +16 -0
  40. package/lib/typescript/commonjs/utils/auth-error.d.ts.map +1 -0
  41. package/lib/typescript/module/Auth.web.d.ts +7 -0
  42. package/lib/typescript/module/Auth.web.d.ts.map +1 -1
  43. package/lib/typescript/module/index.d.ts +1 -0
  44. package/lib/typescript/module/index.d.ts.map +1 -1
  45. package/lib/typescript/module/service.d.ts.map +1 -1
  46. package/lib/typescript/module/use-auth.d.ts +2 -1
  47. package/lib/typescript/module/use-auth.d.ts.map +1 -1
  48. package/lib/typescript/module/utils/auth-error.d.ts +16 -0
  49. package/lib/typescript/module/utils/auth-error.d.ts.map +1 -0
  50. package/nitrogen/generated/android/NitroAuthOnLoad.cpp +22 -17
  51. package/nitrogen/generated/android/NitroAuthOnLoad.hpp +13 -4
  52. package/package.json +7 -7
  53. package/react-native-nitro-auth.podspec +1 -1
  54. package/src/Auth.web.ts +124 -50
  55. package/src/index.ts +1 -0
  56. package/src/service.ts +34 -8
  57. package/src/use-auth.ts +81 -114
  58. package/src/utils/auth-error.ts +49 -0
  59. package/ios/KeychainStore.swift +0 -43
package/src/Auth.web.ts CHANGED
@@ -166,13 +166,19 @@ const getConfig = (): AuthWebExtraConfig => {
166
166
  };
167
167
 
168
168
  class AuthWeb implements Auth {
169
+ private readonly _config: AuthWebExtraConfig;
169
170
  private _currentUser: AuthUser | undefined;
170
171
  private _grantedScopes: string[] = [];
171
172
  private _listeners: ((user: AuthUser | undefined) => void)[] = [];
172
173
  private _tokenListeners: ((tokens: AuthTokens) => void)[] = [];
173
174
  private _storageAdapter: WebStorageDriver | undefined;
175
+ private _browserStorageResolved = false;
176
+ private _browserStorageCache: Storage | undefined;
177
+ private _refreshPromise: Promise<AuthTokens> | undefined;
178
+ private _appleSdkLoadPromise: Promise<void> | undefined;
174
179
 
175
180
  constructor() {
181
+ this._config = getConfig();
176
182
  this.loadFromCache();
177
183
  }
178
184
 
@@ -213,11 +219,11 @@ class AuthWeb implements Auth {
213
219
  if (this._storageAdapter) {
214
220
  return true;
215
221
  }
216
- return getConfig().nitroAuthPersistTokensOnWeb === true;
222
+ return this._config.nitroAuthPersistTokensOnWeb === true;
217
223
  }
218
224
 
219
225
  private getWebStorageMode(): "session" | "local" | "memory" {
220
- const configuredMode = getConfig().nitroAuthWebStorage;
226
+ const configuredMode = this._config.nitroAuthWebStorage;
221
227
  if (configuredMode && WEB_STORAGE_MODES.has(configuredMode)) {
222
228
  return configuredMode;
223
229
  }
@@ -225,12 +231,19 @@ class AuthWeb implements Auth {
225
231
  }
226
232
 
227
233
  private getBrowserStorage(): Storage | undefined {
234
+ if (this._browserStorageResolved) {
235
+ return this._browserStorageCache;
236
+ }
237
+
238
+ this._browserStorageResolved = true;
228
239
  if (typeof window === "undefined") {
240
+ this._browserStorageCache = undefined;
229
241
  return undefined;
230
242
  }
231
243
 
232
244
  const mode = this.getWebStorageMode();
233
245
  if (mode === STORAGE_MODE_MEMORY) {
246
+ this._browserStorageCache = undefined;
234
247
  return undefined;
235
248
  }
236
249
 
@@ -240,6 +253,7 @@ class AuthWeb implements Auth {
240
253
  const testKey = "__nitro_auth_storage_probe__";
241
254
  storage.setItem(testKey, "1");
242
255
  storage.removeItem(testKey);
256
+ this._browserStorageCache = storage;
243
257
  return storage;
244
258
  } catch (error) {
245
259
  logger.warn(
@@ -249,6 +263,7 @@ class AuthWeb implements Auth {
249
263
  error: String(error),
250
264
  },
251
265
  );
266
+ this._browserStorageCache = undefined;
252
267
  return undefined;
253
268
  }
254
269
  }
@@ -499,6 +514,22 @@ class AuthWeb implements Auth {
499
514
  }
500
515
 
501
516
  async refreshToken(): Promise<AuthTokens> {
517
+ if (this._refreshPromise) {
518
+ return this._refreshPromise;
519
+ }
520
+
521
+ const refreshPromise = this.performRefreshToken();
522
+ this._refreshPromise = refreshPromise;
523
+ try {
524
+ return await refreshPromise;
525
+ } finally {
526
+ if (this._refreshPromise === refreshPromise) {
527
+ this._refreshPromise = undefined;
528
+ }
529
+ }
530
+ }
531
+
532
+ private async performRefreshToken(): Promise<AuthTokens> {
502
533
  if (!this._currentUser) {
503
534
  throw new Error("No user logged in");
504
535
  }
@@ -511,15 +542,14 @@ class AuthWeb implements Auth {
511
542
  throw new Error("No refresh token available");
512
543
  }
513
544
 
514
- const config = getConfig();
515
- const clientId = config.microsoftClientId;
545
+ const clientId = this._config.microsoftClientId;
516
546
  if (!clientId) {
517
547
  throw new Error(
518
548
  "Microsoft Client ID not configured. Add 'microsoftClientId' to expo.extra in your app.config.js",
519
549
  );
520
550
  }
521
- const tenant = config.microsoftTenant ?? "common";
522
- const b2cDomain = config.microsoftB2cDomain;
551
+ const tenant = this._config.microsoftTenant ?? "common";
552
+ const b2cDomain = this._config.microsoftB2cDomain;
523
553
 
524
554
  const authBaseUrl = this.getMicrosoftAuthBaseUrl(tenant, b2cDomain);
525
555
  const tokenUrl = `${authBaseUrl}oauth2/v2.0/token`;
@@ -642,7 +672,9 @@ class AuthWeb implements Auth {
642
672
  throw new Error("Invalid JWT payload");
643
673
  }
644
674
 
645
- const decoded: unknown = JSON.parse(atob(payload));
675
+ const normalizedPayload = payload.replace(/-/g, "+").replace(/_/g, "/");
676
+ const padding = "=".repeat((4 - (normalizedPayload.length % 4)) % 4);
677
+ const decoded: unknown = JSON.parse(atob(`${normalizedPayload}${padding}`));
646
678
  if (!isJsonObject(decoded)) {
647
679
  throw new Error("Expected JWT payload to be an object");
648
680
  }
@@ -715,8 +747,7 @@ class AuthWeb implements Auth {
715
747
  scopes: string[],
716
748
  loginHint?: string,
717
749
  ): Promise<void> {
718
- const config = getConfig();
719
- const clientId = config.googleWebClientId;
750
+ const clientId = this._config.googleWebClientId;
720
751
 
721
752
  if (!clientId) {
722
753
  throw new Error(
@@ -812,8 +843,7 @@ class AuthWeb implements Auth {
812
843
  tenant?: string,
813
844
  prompt?: string,
814
845
  ): Promise<void> {
815
- const config = getConfig();
816
- const clientId = config.microsoftClientId;
846
+ const clientId = this._config.microsoftClientId;
817
847
 
818
848
  if (!clientId) {
819
849
  throw new Error(
@@ -821,8 +851,8 @@ class AuthWeb implements Auth {
821
851
  );
822
852
  }
823
853
 
824
- const effectiveTenant = tenant ?? config.microsoftTenant ?? "common";
825
- const b2cDomain = config.microsoftB2cDomain;
854
+ const effectiveTenant = tenant ?? this._config.microsoftTenant ?? "common";
855
+ const b2cDomain = this._config.microsoftB2cDomain;
826
856
  const authBaseUrl = this.getMicrosoftAuthBaseUrl(
827
857
  effectiveTenant,
828
858
  b2cDomain,
@@ -941,10 +971,9 @@ class AuthWeb implements Auth {
941
971
  expectedNonce: string,
942
972
  scopes: string[],
943
973
  ): Promise<void> {
944
- const config = getConfig();
945
974
  const authBaseUrl = this.getMicrosoftAuthBaseUrl(
946
975
  tenant,
947
- config.microsoftB2cDomain,
976
+ this._config.microsoftB2cDomain,
948
977
  );
949
978
  const tokenUrl = `${authBaseUrl}oauth2/v2.0/token`;
950
979
 
@@ -1040,55 +1069,100 @@ class AuthWeb implements Auth {
1040
1069
  }
1041
1070
  }
1042
1071
 
1043
- private async loginApple(): Promise<void> {
1044
- const config = getConfig();
1045
- const clientId = config.appleWebClientId;
1072
+ private async ensureAppleSdkLoaded(): Promise<void> {
1073
+ if (window.AppleID) {
1074
+ return;
1075
+ }
1046
1076
 
1047
- if (!clientId) {
1048
- throw new Error(
1049
- "Apple Web Client ID not configured. Add 'APPLE_WEB_CLIENT_ID' to your .env file.",
1050
- );
1077
+ if (this._appleSdkLoadPromise) {
1078
+ return this._appleSdkLoadPromise;
1051
1079
  }
1052
1080
 
1053
- return new Promise((resolve, reject) => {
1081
+ this._appleSdkLoadPromise = new Promise<void>((resolve, reject) => {
1082
+ const scriptId = "nitro-auth-apple-sdk";
1083
+ const existingScript = document.getElementById(
1084
+ scriptId,
1085
+ ) as HTMLScriptElement | null;
1086
+
1087
+ if (existingScript) {
1088
+ if (window.AppleID) {
1089
+ resolve();
1090
+ return;
1091
+ }
1092
+
1093
+ existingScript.addEventListener(
1094
+ "load",
1095
+ () => {
1096
+ resolve();
1097
+ },
1098
+ {
1099
+ once: true,
1100
+ },
1101
+ );
1102
+ existingScript.addEventListener(
1103
+ "error",
1104
+ () => {
1105
+ this._appleSdkLoadPromise = undefined;
1106
+ reject(new Error("Failed to load Apple SDK"));
1107
+ },
1108
+ { once: true },
1109
+ );
1110
+ return;
1111
+ }
1112
+
1054
1113
  const script = document.createElement("script");
1114
+ script.id = scriptId;
1055
1115
  script.src =
1056
1116
  "https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js";
1057
1117
  script.async = true;
1058
1118
  script.onload = () => {
1059
- if (!window.AppleID) {
1060
- reject(new Error("Apple SDK not loaded"));
1061
- return;
1062
- }
1063
- window.AppleID.auth.init({
1064
- clientId,
1065
- scope: "name email",
1066
- redirectURI: window.location.origin,
1067
- usePopup: true,
1068
- });
1069
- window.AppleID.auth
1070
- .signIn()
1071
- .then((response: AppleAuthResponse) => {
1072
- const user: AuthUser = {
1073
- provider: "apple",
1074
- idToken: response.authorization.id_token,
1075
- email: response.user?.email,
1076
- name: response.user?.name
1077
- ? `${response.user.name.firstName} ${response.user.name.lastName}`.trim()
1078
- : undefined,
1079
- };
1080
- this.updateUser(user);
1081
- resolve();
1082
- })
1083
- .catch((err: unknown) => {
1084
- reject(this.mapError(err));
1085
- });
1119
+ resolve();
1086
1120
  };
1087
1121
  script.onerror = () => {
1122
+ this._appleSdkLoadPromise = undefined;
1088
1123
  reject(new Error("Failed to load Apple SDK"));
1089
1124
  };
1090
1125
  document.head.appendChild(script);
1091
1126
  });
1127
+
1128
+ return this._appleSdkLoadPromise;
1129
+ }
1130
+
1131
+ private async loginApple(): Promise<void> {
1132
+ const clientId = this._config.appleWebClientId;
1133
+
1134
+ if (!clientId) {
1135
+ throw new Error(
1136
+ "Apple Web Client ID not configured. Add 'APPLE_WEB_CLIENT_ID' to your .env file.",
1137
+ );
1138
+ }
1139
+
1140
+ await this.ensureAppleSdkLoaded();
1141
+ if (!window.AppleID) {
1142
+ throw new Error("Apple SDK not loaded");
1143
+ }
1144
+
1145
+ window.AppleID.auth.init({
1146
+ clientId,
1147
+ scope: "name email",
1148
+ redirectURI: window.location.origin,
1149
+ usePopup: true,
1150
+ });
1151
+
1152
+ try {
1153
+ const response: AppleAuthResponse = await window.AppleID.auth.signIn();
1154
+ const user: AuthUser = {
1155
+ provider: "apple",
1156
+ idToken: response.authorization.id_token,
1157
+ email: response.user?.email,
1158
+ name: response.user?.name
1159
+ ? `${response.user.name.firstName} ${response.user.name.lastName}`.trim()
1160
+ : undefined,
1161
+ };
1162
+ this.updateUser(user);
1163
+ } catch (error) {
1164
+ throw this.mapError(error);
1165
+ }
1092
1166
  }
1093
1167
 
1094
1168
  async silentRestore(): Promise<void> {
package/src/index.ts CHANGED
@@ -2,3 +2,4 @@ export * from "./Auth.nitro";
2
2
  export * from "./ui/social-button";
3
3
  export { useAuth, type UseAuthReturn } from "./use-auth";
4
4
  export { AuthService } from "./service";
5
+ export { AuthError, isAuthErrorCode, toAuthErrorCode } from "./utils/auth-error";
package/src/service.ts CHANGED
@@ -6,8 +6,10 @@ import type {
6
6
  LoginOptions,
7
7
  AuthUser,
8
8
  } from "./Auth.nitro";
9
+ import { AuthError } from "./utils/auth-error";
9
10
 
10
11
  const nitroAuth = NitroModules.createHybridObject<Auth>("Auth");
12
+
11
13
  export const AuthService: Auth = {
12
14
  get name() {
13
15
  return nitroAuth.name;
@@ -26,23 +28,43 @@ export const AuthService: Auth = {
26
28
  },
27
29
 
28
30
  async login(provider: AuthProvider, options?: LoginOptions) {
29
- return nitroAuth.login(provider, options);
31
+ try {
32
+ return await nitroAuth.login(provider, options);
33
+ } catch (e) {
34
+ throw AuthError.from(e);
35
+ }
30
36
  },
31
37
 
32
38
  async requestScopes(scopes: string[]) {
33
- return nitroAuth.requestScopes(scopes);
39
+ try {
40
+ return await nitroAuth.requestScopes(scopes);
41
+ } catch (e) {
42
+ throw AuthError.from(e);
43
+ }
34
44
  },
35
45
 
36
46
  async revokeScopes(scopes: string[]) {
37
- return nitroAuth.revokeScopes(scopes);
47
+ try {
48
+ return await nitroAuth.revokeScopes(scopes);
49
+ } catch (e) {
50
+ throw AuthError.from(e);
51
+ }
38
52
  },
39
53
 
40
54
  async getAccessToken() {
41
- return nitroAuth.getAccessToken();
55
+ try {
56
+ return await nitroAuth.getAccessToken();
57
+ } catch (e) {
58
+ throw AuthError.from(e);
59
+ }
42
60
  },
43
61
 
44
62
  async refreshToken() {
45
- return nitroAuth.refreshToken();
63
+ try {
64
+ return await nitroAuth.refreshToken();
65
+ } catch (e) {
66
+ throw AuthError.from(e);
67
+ }
46
68
  },
47
69
 
48
70
  logout() {
@@ -50,12 +72,16 @@ export const AuthService: Auth = {
50
72
  },
51
73
 
52
74
  async silentRestore() {
53
- return nitroAuth.silentRestore();
75
+ try {
76
+ return await nitroAuth.silentRestore();
77
+ } catch (e) {
78
+ throw AuthError.from(e);
79
+ }
54
80
  },
55
81
 
56
82
  onAuthStateChanged(callback: (user: AuthUser | undefined) => void) {
57
- return nitroAuth.onAuthStateChanged(() => {
58
- callback(AuthService.currentUser);
83
+ return nitroAuth.onAuthStateChanged((user) => {
84
+ callback(user);
59
85
  });
60
86
  },
61
87
 
package/src/use-auth.ts CHANGED
@@ -6,23 +6,22 @@ import type {
6
6
  AuthTokens,
7
7
  } from "./Auth.nitro";
8
8
  import { AuthService } from "./service";
9
+ import { AuthError } from "./utils/auth-error";
9
10
 
10
11
  type AuthState = {
11
12
  user: AuthUser | undefined;
12
13
  scopes: string[];
13
14
  loading: boolean;
14
- error: Error | undefined;
15
+ error: AuthError | undefined;
15
16
  };
16
17
 
17
- class AuthHookError extends Error {
18
- public readonly underlyingError?: string;
19
-
20
- constructor(message: string, underlyingError?: string) {
21
- super(message);
22
- this.name = "AuthHookError";
23
- this.underlyingError = underlyingError;
18
+ const areScopesEqual = (left: string[], right: string[]): boolean => {
19
+ if (left.length !== right.length) return false;
20
+ for (let i = 0; i < left.length; i += 1) {
21
+ if (left[i] !== right[i]) return false;
24
22
  }
25
- }
23
+ return true;
24
+ };
26
25
 
27
26
  export type UseAuthReturn = AuthState & {
28
27
  hasPlayServices: boolean;
@@ -43,81 +42,74 @@ export function useAuth(): UseAuthReturn {
43
42
  error: undefined,
44
43
  });
45
44
 
45
+ const syncStateFromService = useCallback(
46
+ (nextLoading: boolean, nextError: AuthError | undefined) => {
47
+ const nextUser = AuthService.currentUser;
48
+ const nextScopes = AuthService.grantedScopes;
49
+ setState((prev) => {
50
+ if (
51
+ prev.loading === nextLoading &&
52
+ prev.error === nextError &&
53
+ prev.user === nextUser &&
54
+ areScopesEqual(prev.scopes, nextScopes)
55
+ ) {
56
+ return prev;
57
+ }
58
+ return { user: nextUser, scopes: nextScopes, loading: nextLoading, error: nextError };
59
+ });
60
+ },
61
+ [],
62
+ );
63
+
46
64
  const login = useCallback(
47
65
  async (provider: AuthProvider, options?: LoginOptions) => {
48
66
  setState((prev) => ({ ...prev, loading: true, error: undefined }));
49
67
  try {
50
68
  await AuthService.login(provider, options);
51
- setState({
52
- user: AuthService.currentUser,
53
- scopes: AuthService.grantedScopes,
54
- loading: false,
55
- error: undefined,
56
- });
69
+ syncStateFromService(false, undefined);
57
70
  } catch (e) {
58
- const error = e instanceof Error ? e : new Error(String(e));
59
- setState((prev) => ({
60
- ...prev,
61
- loading: false,
62
- error,
63
- }));
71
+ const error = AuthError.from(e);
72
+ setState((prev) => ({ ...prev, loading: false, error }));
64
73
  throw error;
65
74
  }
66
75
  },
67
- [],
76
+ [syncStateFromService],
68
77
  );
69
78
 
70
79
  const logout = useCallback(() => {
71
80
  AuthService.logout();
72
- setState({
73
- user: undefined,
74
- scopes: [],
75
- loading: false,
76
- error: undefined,
77
- });
81
+ setState({ user: undefined, scopes: [], loading: false, error: undefined });
78
82
  }, []);
79
83
 
80
- const requestScopes = useCallback(async (newScopes: string[]) => {
81
- setState((prev) => ({ ...prev, loading: true, error: undefined }));
82
- try {
83
- await AuthService.requestScopes(newScopes);
84
- setState({
85
- user: AuthService.currentUser,
86
- scopes: AuthService.grantedScopes,
87
- loading: false,
88
- error: undefined,
89
- });
90
- } catch (e) {
91
- const error = e instanceof Error ? e : new Error(String(e));
92
- setState((prev) => ({
93
- ...prev,
94
- loading: false,
95
- error,
96
- }));
97
- throw error;
98
- }
99
- }, []);
84
+ const requestScopes = useCallback(
85
+ async (newScopes: string[]) => {
86
+ setState((prev) => ({ ...prev, loading: true, error: undefined }));
87
+ try {
88
+ await AuthService.requestScopes(newScopes);
89
+ syncStateFromService(false, undefined);
90
+ } catch (e) {
91
+ const error = AuthError.from(e);
92
+ setState((prev) => ({ ...prev, loading: false, error }));
93
+ throw error;
94
+ }
95
+ },
96
+ [syncStateFromService],
97
+ );
100
98
 
101
- const revokeScopes = useCallback(async (scopesToRevoke: string[]) => {
102
- setState((prev) => ({ ...prev, loading: true, error: undefined }));
103
- try {
104
- await AuthService.revokeScopes(scopesToRevoke);
105
- setState({
106
- user: AuthService.currentUser,
107
- scopes: AuthService.grantedScopes,
108
- loading: false,
109
- error: undefined,
110
- });
111
- } catch (e) {
112
- const error = e instanceof Error ? e : new Error(String(e));
113
- setState((prev) => ({
114
- ...prev,
115
- loading: false,
116
- error,
117
- }));
118
- throw error;
119
- }
120
- }, []);
99
+ const revokeScopes = useCallback(
100
+ async (scopesToRevoke: string[]) => {
101
+ setState((prev) => ({ ...prev, loading: true, error: undefined }));
102
+ try {
103
+ await AuthService.revokeScopes(scopesToRevoke);
104
+ syncStateFromService(false, undefined);
105
+ } catch (e) {
106
+ const error = AuthError.from(e);
107
+ setState((prev) => ({ ...prev, loading: false, error }));
108
+ throw error;
109
+ }
110
+ },
111
+ [syncStateFromService],
112
+ );
121
113
 
122
114
  const getAccessToken = useCallback(() => AuthService.getAccessToken(), []);
123
115
 
@@ -125,56 +117,40 @@ export function useAuth(): UseAuthReturn {
125
117
  setState((prev) => ({ ...prev, loading: true, error: undefined }));
126
118
  try {
127
119
  const tokens = await AuthService.refreshToken();
128
- setState({
129
- user: AuthService.currentUser,
130
- scopes: AuthService.grantedScopes,
131
- loading: false,
132
- error: undefined,
133
- });
120
+ syncStateFromService(false, undefined);
134
121
  return tokens;
135
122
  } catch (e) {
136
- const msg = e instanceof Error ? e.message : String(e);
137
- const authError = new AuthHookError(
138
- msg,
139
- AuthService.currentUser?.underlyingError,
140
- );
141
- setState((prev) => ({
142
- ...prev,
143
- loading: false,
144
- error: authError,
145
- }));
146
- throw authError;
123
+ const error = AuthError.from(e);
124
+ setState((prev) => ({ ...prev, loading: false, error }));
125
+ throw error;
147
126
  }
148
- }, []);
127
+ }, [syncStateFromService]);
149
128
 
150
129
  const silentRestore = useCallback(async () => {
151
130
  setState((prev) => ({ ...prev, loading: true, error: undefined }));
152
131
  try {
153
132
  await AuthService.silentRestore();
154
- setState({
155
- user: AuthService.currentUser,
156
- scopes: AuthService.grantedScopes,
157
- loading: false,
158
- error: undefined,
159
- });
133
+ syncStateFromService(false, undefined);
160
134
  } catch (e) {
161
- const error = e instanceof Error ? e : new Error(String(e));
162
- setState((prev) => ({
163
- ...prev,
164
- loading: false,
165
- error,
166
- }));
135
+ const error = AuthError.from(e);
136
+ setState((prev) => ({ ...prev, loading: false, error }));
167
137
  throw error;
168
138
  }
169
- }, []);
139
+ }, [syncStateFromService]);
170
140
 
171
141
  useEffect(() => {
172
142
  const unsubscribe = AuthService.onAuthStateChanged((currentUser) => {
173
- setState((prev) => ({
174
- ...prev,
175
- user: currentUser,
176
- scopes: AuthService.grantedScopes,
177
- }));
143
+ const nextScopes = AuthService.grantedScopes;
144
+ setState((prev) => {
145
+ if (
146
+ prev.user === currentUser &&
147
+ areScopesEqual(prev.scopes, nextScopes) &&
148
+ prev.loading === false
149
+ ) {
150
+ return prev;
151
+ }
152
+ return { ...prev, user: currentUser, scopes: nextScopes, loading: false };
153
+ });
178
154
  });
179
155
  return unsubscribe;
180
156
  }, []);
@@ -191,15 +167,6 @@ export function useAuth(): UseAuthReturn {
191
167
  refreshToken,
192
168
  silentRestore,
193
169
  }),
194
- [
195
- state,
196
- login,
197
- logout,
198
- requestScopes,
199
- revokeScopes,
200
- getAccessToken,
201
- refreshToken,
202
- silentRestore,
203
- ],
170
+ [state, login, logout, requestScopes, revokeScopes, getAccessToken, refreshToken, silentRestore],
204
171
  );
205
172
  }
@@ -0,0 +1,49 @@
1
+ import type { AuthErrorCode } from "../Auth.nitro";
2
+
3
+ const AUTH_ERROR_CODES: ReadonlySet<string> = new Set<AuthErrorCode>([
4
+ "cancelled",
5
+ "timeout",
6
+ "popup_blocked",
7
+ "network_error",
8
+ "configuration_error",
9
+ "unsupported_provider",
10
+ "invalid_state",
11
+ "invalid_nonce",
12
+ "token_error",
13
+ "no_id_token",
14
+ "parse_error",
15
+ "refresh_failed",
16
+ "unknown",
17
+ ]);
18
+
19
+ export function isAuthErrorCode(value: string): value is AuthErrorCode {
20
+ return AUTH_ERROR_CODES.has(value);
21
+ }
22
+
23
+ export function toAuthErrorCode(raw: string): AuthErrorCode {
24
+ return isAuthErrorCode(raw) ? raw : "unknown";
25
+ }
26
+
27
+ /**
28
+ * Typed error thrown by all AuthService operations.
29
+ *
30
+ * - `code` — always a valid `AuthErrorCode`, safe to switch on
31
+ * - `underlyingMessage` — the raw platform message when it differs from `code`
32
+ */
33
+ export class AuthError extends Error {
34
+ readonly code: AuthErrorCode;
35
+ readonly underlyingMessage: string | undefined;
36
+
37
+ constructor(raw: unknown) {
38
+ const message = raw instanceof Error ? raw.message : String(raw);
39
+ const code = toAuthErrorCode(message);
40
+ super(code);
41
+ this.name = "AuthError";
42
+ this.code = code;
43
+ this.underlyingMessage = code !== message ? message : undefined;
44
+ }
45
+
46
+ static from(e: unknown): AuthError {
47
+ return e instanceof AuthError ? e : new AuthError(e);
48
+ }
49
+ }