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.
- package/README.md +362 -190
- package/android/build.gradle +2 -5
- package/android/src/main/cpp/PlatformAuth+Android.cpp +84 -18
- package/android/src/main/java/com/auth/AuthAdapter.kt +82 -182
- package/android/src/main/java/com/auth/NitroAuthPackage.kt +1 -1
- package/app.plugin.js +2 -9
- package/cpp/AuthCache.cpp +0 -134
- package/cpp/AuthCache.hpp +0 -7
- package/cpp/HybridAuth.cpp +57 -63
- package/cpp/HybridAuth.hpp +3 -4
- package/ios/AuthAdapter.swift +23 -25
- package/lib/commonjs/Auth.web.js +523 -201
- package/lib/commonjs/Auth.web.js.map +1 -1
- package/lib/commonjs/index.js +0 -12
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +0 -12
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/js-storage-adapter.js +2 -0
- package/lib/commonjs/js-storage-adapter.js.map +1 -0
- package/lib/commonjs/service.js +9 -86
- package/lib/commonjs/service.js.map +1 -1
- package/lib/commonjs/service.web.js +1 -5
- package/lib/commonjs/service.web.js.map +1 -1
- package/lib/commonjs/ui/social-button.js +44 -29
- package/lib/commonjs/ui/social-button.js.map +1 -1
- package/lib/commonjs/ui/social-button.web.js +44 -29
- package/lib/commonjs/ui/social-button.web.js.map +1 -1
- package/lib/commonjs/use-auth.js +56 -42
- package/lib/commonjs/use-auth.js.map +1 -1
- package/lib/commonjs/utils/logger.js +12 -4
- package/lib/commonjs/utils/logger.js.map +1 -1
- package/lib/module/Auth.web.js +523 -201
- package/lib/module/Auth.web.js.map +1 -1
- package/lib/module/index.js +0 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +0 -1
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/js-storage-adapter.js +2 -0
- package/lib/module/js-storage-adapter.js.map +1 -0
- package/lib/module/service.js +9 -86
- package/lib/module/service.js.map +1 -1
- package/lib/module/service.web.js +1 -5
- package/lib/module/service.web.js.map +1 -1
- package/lib/module/ui/social-button.js +44 -29
- package/lib/module/ui/social-button.js.map +1 -1
- package/lib/module/ui/social-button.web.js +44 -29
- package/lib/module/ui/social-button.web.js.map +1 -1
- package/lib/module/use-auth.js +56 -42
- package/lib/module/use-auth.js.map +1 -1
- package/lib/module/utils/logger.js +12 -4
- package/lib/module/utils/logger.js.map +1 -1
- package/lib/typescript/commonjs/Auth.nitro.d.ts +3 -3
- package/lib/typescript/commonjs/Auth.nitro.d.ts.map +1 -1
- package/lib/typescript/commonjs/Auth.web.d.ts +25 -6
- package/lib/typescript/commonjs/Auth.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -2
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +0 -1
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/js-storage-adapter.d.ts +6 -0
- package/lib/typescript/commonjs/js-storage-adapter.d.ts.map +1 -0
- package/lib/typescript/commonjs/service.d.ts +1 -8
- package/lib/typescript/commonjs/service.d.ts.map +1 -1
- package/lib/typescript/commonjs/service.web.d.ts +1 -8
- package/lib/typescript/commonjs/service.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/social-button.d.ts +6 -6
- package/lib/typescript/commonjs/ui/social-button.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/social-button.web.d.ts +6 -6
- package/lib/typescript/commonjs/ui/social-button.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/use-auth.d.ts +4 -4
- package/lib/typescript/commonjs/use-auth.d.ts.map +1 -1
- package/lib/typescript/commonjs/utils/logger.d.ts +4 -4
- package/lib/typescript/commonjs/utils/logger.d.ts.map +1 -1
- package/lib/typescript/module/Auth.nitro.d.ts +3 -3
- package/lib/typescript/module/Auth.nitro.d.ts.map +1 -1
- package/lib/typescript/module/Auth.web.d.ts +25 -6
- package/lib/typescript/module/Auth.web.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -2
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +0 -1
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/lib/typescript/module/js-storage-adapter.d.ts +6 -0
- package/lib/typescript/module/js-storage-adapter.d.ts.map +1 -0
- package/lib/typescript/module/service.d.ts +1 -8
- package/lib/typescript/module/service.d.ts.map +1 -1
- package/lib/typescript/module/service.web.d.ts +1 -8
- package/lib/typescript/module/service.web.d.ts.map +1 -1
- package/lib/typescript/module/ui/social-button.d.ts +6 -6
- package/lib/typescript/module/ui/social-button.d.ts.map +1 -1
- package/lib/typescript/module/ui/social-button.web.d.ts +6 -6
- package/lib/typescript/module/ui/social-button.web.d.ts.map +1 -1
- package/lib/typescript/module/use-auth.d.ts +4 -4
- package/lib/typescript/module/use-auth.d.ts.map +1 -1
- package/lib/typescript/module/utils/logger.d.ts +4 -4
- package/lib/typescript/module/utils/logger.d.ts.map +1 -1
- package/nitrogen/generated/android/NitroAuth+autolinking.cmake +0 -1
- package/nitrogen/generated/shared/c++/AuthTokens.hpp +5 -1
- package/nitrogen/generated/shared/c++/AuthUser.hpp +5 -1
- package/nitrogen/generated/shared/c++/HybridAuthSpec.cpp +0 -1
- package/nitrogen/generated/shared/c++/HybridAuthSpec.hpp +0 -5
- package/package.json +11 -8
- package/react-native-nitro-auth.podspec +1 -1
- package/src/Auth.nitro.ts +4 -3
- package/src/Auth.web.ts +700 -246
- package/src/global.d.ts +0 -1
- package/src/index.ts +1 -2
- package/src/index.web.ts +0 -1
- package/src/js-storage-adapter.ts +5 -0
- package/src/service.ts +13 -106
- package/src/service.web.ts +0 -7
- package/src/ui/social-button.tsx +66 -43
- package/src/ui/social-button.web.tsx +67 -44
- package/src/use-auth.ts +116 -72
- package/src/utils/logger.ts +12 -4
- package/ios/KeychainStore.swift +0 -43
- package/lib/commonjs/AuthStorage.nitro.js +0 -6
- package/lib/commonjs/AuthStorage.nitro.js.map +0 -1
- package/lib/module/AuthStorage.nitro.js +0 -4
- package/lib/module/AuthStorage.nitro.js.map +0 -1
- package/lib/typescript/commonjs/AuthStorage.nitro.d.ts +0 -26
- package/lib/typescript/commonjs/AuthStorage.nitro.d.ts.map +0 -1
- package/lib/typescript/module/AuthStorage.nitro.d.ts +0 -26
- package/lib/typescript/module/AuthStorage.nitro.d.ts.map +0 -1
- package/nitrogen/generated/shared/c++/HybridAuthStorageAdapterSpec.cpp +0 -23
- package/nitrogen/generated/shared/c++/HybridAuthStorageAdapterSpec.hpp +0 -65
- 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 {
|
|
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,58 +44,364 @@ type AppleAuthResponse = {
|
|
|
26
44
|
};
|
|
27
45
|
};
|
|
28
46
|
|
|
29
|
-
|
|
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
|
};
|
|
37
167
|
|
|
38
168
|
class AuthWeb implements Auth {
|
|
169
|
+
private readonly _config: AuthWebExtraConfig;
|
|
39
170
|
private _currentUser: AuthUser | undefined;
|
|
40
171
|
private _grantedScopes: string[] = [];
|
|
41
172
|
private _listeners: ((user: AuthUser | undefined) => void)[] = [];
|
|
42
173
|
private _tokenListeners: ((tokens: AuthTokens) => void)[] = [];
|
|
43
|
-
private _storageAdapter:
|
|
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;
|
|
44
179
|
|
|
45
180
|
constructor() {
|
|
181
|
+
this._config = getConfig();
|
|
46
182
|
this.loadFromCache();
|
|
47
183
|
}
|
|
48
184
|
|
|
185
|
+
private isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
186
|
+
if (!isJsonObject(value)) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return typeof value.then === "function";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private createWebStorageDriver(adapter: JSStorageAdapter): WebStorageDriver {
|
|
193
|
+
return {
|
|
194
|
+
save: (key, value) => {
|
|
195
|
+
const result = adapter.save(key, value);
|
|
196
|
+
if (this.isPromiseLike(result)) {
|
|
197
|
+
throw new Error("On web, JSStorageAdapter.save must be synchronous.");
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
load: (key) => {
|
|
201
|
+
const result = adapter.load(key);
|
|
202
|
+
if (this.isPromiseLike(result)) {
|
|
203
|
+
throw new Error("On web, JSStorageAdapter.load must be synchronous.");
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
},
|
|
207
|
+
remove: (key) => {
|
|
208
|
+
const result = adapter.remove(key);
|
|
209
|
+
if (this.isPromiseLike(result)) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
"On web, JSStorageAdapter.remove must be synchronous.",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private shouldPersistTokensInStorage(): boolean {
|
|
219
|
+
if (this._storageAdapter) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
return this._config.nitroAuthPersistTokensOnWeb === true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private getWebStorageMode(): "session" | "local" | "memory" {
|
|
226
|
+
const configuredMode = this._config.nitroAuthWebStorage;
|
|
227
|
+
if (configuredMode && WEB_STORAGE_MODES.has(configuredMode)) {
|
|
228
|
+
return configuredMode;
|
|
229
|
+
}
|
|
230
|
+
return STORAGE_MODE_SESSION;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private getBrowserStorage(): Storage | undefined {
|
|
234
|
+
if (this._browserStorageResolved) {
|
|
235
|
+
return this._browserStorageCache;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this._browserStorageResolved = true;
|
|
239
|
+
if (typeof window === "undefined") {
|
|
240
|
+
this._browserStorageCache = undefined;
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const mode = this.getWebStorageMode();
|
|
245
|
+
if (mode === STORAGE_MODE_MEMORY) {
|
|
246
|
+
this._browserStorageCache = undefined;
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const storage =
|
|
251
|
+
mode === STORAGE_MODE_LOCAL ? window.localStorage : window.sessionStorage;
|
|
252
|
+
try {
|
|
253
|
+
const testKey = "__nitro_auth_storage_probe__";
|
|
254
|
+
storage.setItem(testKey, "1");
|
|
255
|
+
storage.removeItem(testKey);
|
|
256
|
+
this._browserStorageCache = storage;
|
|
257
|
+
return storage;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
logger.warn(
|
|
260
|
+
"Configured web storage is unavailable; using in-memory fallback",
|
|
261
|
+
{
|
|
262
|
+
mode,
|
|
263
|
+
error: String(error),
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
this._browserStorageCache = undefined;
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private saveValue(key: string, value: string): void {
|
|
272
|
+
if (this._storageAdapter) {
|
|
273
|
+
this._storageAdapter.save(key, value);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const storage = this.getBrowserStorage();
|
|
278
|
+
if (storage) {
|
|
279
|
+
storage.setItem(key, value);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
inMemoryWebStorage.set(key, value);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private loadValue(key: string): string | undefined {
|
|
286
|
+
if (this._storageAdapter) {
|
|
287
|
+
return this._storageAdapter.load(key);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const storage = this.getBrowserStorage();
|
|
291
|
+
if (storage) {
|
|
292
|
+
return storage.getItem(key) ?? undefined;
|
|
293
|
+
}
|
|
294
|
+
return inMemoryWebStorage.get(key);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private removeValue(key: string): void {
|
|
298
|
+
if (this._storageAdapter) {
|
|
299
|
+
this._storageAdapter.remove(key);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const storage = this.getBrowserStorage();
|
|
304
|
+
if (storage) {
|
|
305
|
+
storage.removeItem(key);
|
|
306
|
+
}
|
|
307
|
+
inMemoryWebStorage.delete(key);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private removePersistedBrowserValue(key: string): void {
|
|
311
|
+
if (typeof window === "undefined") {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
window.localStorage.removeItem(key);
|
|
317
|
+
window.sessionStorage.removeItem(key);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
logger.debug("Failed to clear persisted browser value", {
|
|
320
|
+
key,
|
|
321
|
+
error: String(error),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private sanitizeUserForPersistence(user: AuthUser): AuthUser {
|
|
327
|
+
if (this.shouldPersistTokensInStorage()) {
|
|
328
|
+
return user;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const safeUser = { ...user };
|
|
332
|
+
delete safeUser.accessToken;
|
|
333
|
+
delete safeUser.idToken;
|
|
334
|
+
delete safeUser.serverAuthCode;
|
|
335
|
+
return safeUser;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private saveRefreshToken(refreshToken: string): void {
|
|
339
|
+
if (this._storageAdapter || this.shouldPersistTokensInStorage()) {
|
|
340
|
+
this.saveValue(MS_REFRESH_TOKEN_KEY, refreshToken);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Security-first default: keep refresh tokens in-memory only on web.
|
|
345
|
+
inMemoryWebStorage.set(MS_REFRESH_TOKEN_KEY, refreshToken);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private loadRefreshToken(): string | undefined {
|
|
349
|
+
if (this._storageAdapter || this.shouldPersistTokensInStorage()) {
|
|
350
|
+
return this.loadValue(MS_REFRESH_TOKEN_KEY);
|
|
351
|
+
}
|
|
352
|
+
return inMemoryWebStorage.get(MS_REFRESH_TOKEN_KEY);
|
|
353
|
+
}
|
|
354
|
+
|
|
49
355
|
private loadFromCache() {
|
|
50
|
-
const cached = this.
|
|
51
|
-
? this._storageAdapter.load(CACHE_KEY)
|
|
52
|
-
: localStorage.getItem(CACHE_KEY);
|
|
356
|
+
const cached = this.loadValue(CACHE_KEY);
|
|
53
357
|
|
|
54
358
|
if (cached) {
|
|
55
359
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
360
|
+
const parsedUser = parseAuthUser(JSON.parse(cached));
|
|
361
|
+
if (!parsedUser) {
|
|
362
|
+
throw new Error("Expected cached auth user to be a valid AuthUser");
|
|
363
|
+
}
|
|
364
|
+
if (this.shouldPersistTokensInStorage()) {
|
|
365
|
+
this._currentUser = parsedUser;
|
|
366
|
+
} else {
|
|
367
|
+
const safeUser = { ...parsedUser };
|
|
368
|
+
delete safeUser.accessToken;
|
|
369
|
+
delete safeUser.idToken;
|
|
370
|
+
delete safeUser.serverAuthCode;
|
|
371
|
+
this._currentUser = safeUser;
|
|
372
|
+
}
|
|
373
|
+
} catch (error) {
|
|
374
|
+
logger.warn("Failed to parse cached auth user; clearing cache", {
|
|
375
|
+
error: String(error),
|
|
376
|
+
});
|
|
58
377
|
this.removeFromCache(CACHE_KEY);
|
|
59
378
|
}
|
|
60
379
|
}
|
|
61
380
|
|
|
62
|
-
const scopes = this.
|
|
63
|
-
? this._storageAdapter.load(SCOPES_KEY)
|
|
64
|
-
: localStorage.getItem(SCOPES_KEY);
|
|
381
|
+
const scopes = this.loadValue(SCOPES_KEY);
|
|
65
382
|
|
|
66
383
|
if (scopes) {
|
|
67
384
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
385
|
+
const parsedScopes = parseScopes(JSON.parse(scopes));
|
|
386
|
+
if (!parsedScopes) {
|
|
387
|
+
throw new Error("Expected cached scopes to be an array");
|
|
388
|
+
}
|
|
389
|
+
this._grantedScopes = parsedScopes;
|
|
390
|
+
} catch (error) {
|
|
391
|
+
logger.warn("Failed to parse cached scopes; clearing cache", {
|
|
392
|
+
error: String(error),
|
|
393
|
+
});
|
|
70
394
|
this.removeFromCache(SCOPES_KEY);
|
|
71
395
|
}
|
|
72
396
|
}
|
|
397
|
+
|
|
398
|
+
if (!this.shouldPersistTokensInStorage() && !this._storageAdapter) {
|
|
399
|
+
this.removePersistedBrowserValue(MS_REFRESH_TOKEN_KEY);
|
|
400
|
+
}
|
|
73
401
|
}
|
|
74
402
|
|
|
75
403
|
private removeFromCache(key: string) {
|
|
76
|
-
|
|
77
|
-
this._storageAdapter.remove(key);
|
|
78
|
-
} else {
|
|
79
|
-
localStorage.removeItem(key);
|
|
80
|
-
}
|
|
404
|
+
this.removeValue(key);
|
|
81
405
|
}
|
|
82
406
|
|
|
83
407
|
get currentUser(): AuthUser | undefined {
|
|
@@ -110,7 +434,9 @@ class AuthWeb implements Auth {
|
|
|
110
434
|
}
|
|
111
435
|
|
|
112
436
|
private notify() {
|
|
113
|
-
this._listeners.forEach((l) =>
|
|
437
|
+
this._listeners.forEach((l) => {
|
|
438
|
+
l(this._currentUser);
|
|
439
|
+
});
|
|
114
440
|
}
|
|
115
441
|
|
|
116
442
|
async login(provider: AuthProvider, options?: LoginOptions): Promise<void> {
|
|
@@ -169,14 +495,7 @@ class AuthWeb implements Auth {
|
|
|
169
495
|
this._grantedScopes = this._grantedScopes.filter(
|
|
170
496
|
(s) => !scopes.includes(s),
|
|
171
497
|
);
|
|
172
|
-
|
|
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
|
-
}
|
|
498
|
+
this.saveValue(SCOPES_KEY, JSON.stringify(this._grantedScopes));
|
|
180
499
|
if (this._currentUser) {
|
|
181
500
|
this._currentUser.scopes = this._grantedScopes;
|
|
182
501
|
this.updateUser(this._currentUser);
|
|
@@ -194,25 +513,43 @@ class AuthWeb implements Auth {
|
|
|
194
513
|
return this._currentUser?.accessToken;
|
|
195
514
|
}
|
|
196
515
|
|
|
197
|
-
async refreshToken(): Promise<
|
|
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> {
|
|
198
533
|
if (!this._currentUser) {
|
|
199
534
|
throw new Error("No user logged in");
|
|
200
535
|
}
|
|
201
536
|
|
|
202
537
|
if (this._currentUser.provider === "microsoft") {
|
|
203
538
|
logger.log("Refreshing Microsoft tokens...");
|
|
204
|
-
const refreshToken = this.
|
|
205
|
-
? this._storageAdapter.load("microsoft_refresh_token")
|
|
206
|
-
: localStorage.getItem("nitro_auth_microsoft_refresh_token");
|
|
539
|
+
const refreshToken = this.loadRefreshToken();
|
|
207
540
|
|
|
208
541
|
if (!refreshToken) {
|
|
209
542
|
throw new Error("No refresh token available");
|
|
210
543
|
}
|
|
211
544
|
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
545
|
+
const clientId = this._config.microsoftClientId;
|
|
546
|
+
if (!clientId) {
|
|
547
|
+
throw new Error(
|
|
548
|
+
"Microsoft Client ID not configured. Add 'microsoftClientId' to expo.extra in your app.config.js",
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
const tenant = this._config.microsoftTenant ?? "common";
|
|
552
|
+
const b2cDomain = this._config.microsoftB2cDomain;
|
|
216
553
|
|
|
217
554
|
const authBaseUrl = this.getMicrosoftAuthBaseUrl(tenant, b2cDomain);
|
|
218
555
|
const tokenUrl = `${authBaseUrl}oauth2/v2.0/token`;
|
|
@@ -230,43 +567,52 @@ class AuthWeb implements Auth {
|
|
|
230
567
|
body: body.toString(),
|
|
231
568
|
});
|
|
232
569
|
|
|
233
|
-
const json = await
|
|
570
|
+
const json = await this.parseResponseObject(response);
|
|
234
571
|
if (!response.ok) {
|
|
235
572
|
throw new Error(
|
|
236
|
-
json
|
|
573
|
+
getOptionalString(json, "error_description") ??
|
|
574
|
+
getOptionalString(json, "error") ??
|
|
575
|
+
"Token refresh failed",
|
|
237
576
|
);
|
|
238
577
|
}
|
|
239
578
|
|
|
240
|
-
const idToken = json
|
|
241
|
-
const accessToken = json
|
|
242
|
-
const newRefreshToken = json
|
|
243
|
-
const
|
|
579
|
+
const idToken = getOptionalString(json, "id_token");
|
|
580
|
+
const accessToken = getOptionalString(json, "access_token");
|
|
581
|
+
const newRefreshToken = getOptionalString(json, "refresh_token");
|
|
582
|
+
const expiresInSeconds = getOptionalNumber(json, "expires_in");
|
|
244
583
|
|
|
245
584
|
if (newRefreshToken) {
|
|
246
|
-
|
|
247
|
-
this._storageAdapter.save("microsoft_refresh_token", newRefreshToken);
|
|
248
|
-
} else {
|
|
249
|
-
localStorage.setItem(
|
|
250
|
-
"nitro_auth_microsoft_refresh_token",
|
|
251
|
-
newRefreshToken,
|
|
252
|
-
);
|
|
253
|
-
}
|
|
585
|
+
this.saveRefreshToken(newRefreshToken);
|
|
254
586
|
}
|
|
255
587
|
|
|
256
|
-
const
|
|
588
|
+
const expirationTime =
|
|
589
|
+
typeof expiresInSeconds === "number"
|
|
590
|
+
? Date.now() + expiresInSeconds * 1000
|
|
591
|
+
: undefined;
|
|
592
|
+
|
|
593
|
+
const effectiveIdToken = idToken ?? this._currentUser.idToken;
|
|
594
|
+
const claims = effectiveIdToken
|
|
595
|
+
? this.decodeMicrosoftJwt(effectiveIdToken)
|
|
596
|
+
: {};
|
|
257
597
|
const user: AuthUser = {
|
|
258
598
|
...this._currentUser,
|
|
259
|
-
idToken,
|
|
599
|
+
idToken: effectiveIdToken,
|
|
260
600
|
accessToken: accessToken ?? undefined,
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
: undefined,
|
|
601
|
+
refreshToken: newRefreshToken ?? this._currentUser.refreshToken,
|
|
602
|
+
expirationTime,
|
|
264
603
|
...claims,
|
|
265
604
|
};
|
|
266
605
|
this.updateUser(user);
|
|
267
606
|
|
|
268
|
-
const tokens = {
|
|
269
|
-
|
|
607
|
+
const tokens: AuthTokens = {
|
|
608
|
+
accessToken: accessToken ?? undefined,
|
|
609
|
+
idToken: effectiveIdToken,
|
|
610
|
+
refreshToken: newRefreshToken ?? undefined,
|
|
611
|
+
expirationTime,
|
|
612
|
+
};
|
|
613
|
+
this._tokenListeners.forEach((l) => {
|
|
614
|
+
l(tokens);
|
|
615
|
+
});
|
|
270
616
|
return tokens;
|
|
271
617
|
}
|
|
272
618
|
|
|
@@ -280,11 +626,15 @@ class AuthWeb implements Auth {
|
|
|
280
626
|
await this.loginGoogle(
|
|
281
627
|
this._grantedScopes.length > 0 ? this._grantedScopes : DEFAULT_SCOPES,
|
|
282
628
|
);
|
|
283
|
-
const tokens = {
|
|
629
|
+
const tokens: AuthTokens = {
|
|
284
630
|
accessToken: this._currentUser.accessToken,
|
|
285
631
|
idToken: this._currentUser.idToken,
|
|
632
|
+
refreshToken: this._currentUser.refreshToken,
|
|
633
|
+
expirationTime: this._currentUser.expirationTime,
|
|
286
634
|
};
|
|
287
|
-
this._tokenListeners.forEach((l) =>
|
|
635
|
+
this._tokenListeners.forEach((l) => {
|
|
636
|
+
l(tokens);
|
|
637
|
+
});
|
|
288
638
|
return tokens;
|
|
289
639
|
}
|
|
290
640
|
|
|
@@ -295,21 +645,109 @@ class AuthWeb implements Auth {
|
|
|
295
645
|
|
|
296
646
|
if (msg.includes("cancel") || msg.includes("popup_closed")) {
|
|
297
647
|
mappedMsg = "cancelled";
|
|
648
|
+
} else if (msg.includes("timeout")) {
|
|
649
|
+
mappedMsg = "timeout";
|
|
650
|
+
} else if (msg.includes("popup blocked")) {
|
|
651
|
+
mappedMsg = "popup_blocked";
|
|
298
652
|
} else if (msg.includes("network")) {
|
|
299
653
|
mappedMsg = "network_error";
|
|
300
654
|
} else if (msg.includes("client id") || msg.includes("config")) {
|
|
301
655
|
mappedMsg = "configuration_error";
|
|
302
656
|
}
|
|
303
657
|
|
|
304
|
-
return
|
|
658
|
+
return new AuthWebError(mappedMsg, rawMessage);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private async parseResponseObject(response: Response): Promise<JsonObject> {
|
|
662
|
+
const parsed: unknown = await response.json();
|
|
663
|
+
if (!isJsonObject(parsed)) {
|
|
664
|
+
throw new Error("Expected JSON object response from auth provider");
|
|
665
|
+
}
|
|
666
|
+
return parsed;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private parseJwtPayload(token: string): JsonObject {
|
|
670
|
+
const payload = token.split(".")[1];
|
|
671
|
+
if (!payload) {
|
|
672
|
+
throw new Error("Invalid JWT payload");
|
|
673
|
+
}
|
|
674
|
+
|
|
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}`));
|
|
678
|
+
if (!isJsonObject(decoded)) {
|
|
679
|
+
throw new Error("Expected JWT payload to be an object");
|
|
680
|
+
}
|
|
681
|
+
return decoded;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private waitForPopupRedirect(
|
|
685
|
+
popup: Window,
|
|
686
|
+
redirectUri: string,
|
|
687
|
+
provider: "Google" | "Microsoft",
|
|
688
|
+
onRedirect: (url: string) => Promise<void> | void,
|
|
689
|
+
): Promise<void> {
|
|
690
|
+
return new Promise((resolve, reject) => {
|
|
691
|
+
let crossOriginLogShown = false;
|
|
692
|
+
|
|
693
|
+
const cleanup = (
|
|
694
|
+
intervalId: number,
|
|
695
|
+
timeoutId: number,
|
|
696
|
+
shouldClosePopup: boolean,
|
|
697
|
+
) => {
|
|
698
|
+
window.clearInterval(intervalId);
|
|
699
|
+
window.clearTimeout(timeoutId);
|
|
700
|
+
if (shouldClosePopup && !popup.closed) {
|
|
701
|
+
popup.close();
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const timeoutId = window.setTimeout(() => {
|
|
706
|
+
cleanup(intervalId, timeoutId, true);
|
|
707
|
+
reject(new Error(`${provider.toLowerCase()}_auth_timeout`));
|
|
708
|
+
}, POPUP_TIMEOUT_MS);
|
|
709
|
+
|
|
710
|
+
const intervalId = window.setInterval(() => {
|
|
711
|
+
if (popup.closed) {
|
|
712
|
+
cleanup(intervalId, timeoutId, false);
|
|
713
|
+
reject(new Error("cancelled"));
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
let url: string;
|
|
718
|
+
try {
|
|
719
|
+
url = popup.location.href;
|
|
720
|
+
} catch (error) {
|
|
721
|
+
if (!crossOriginLogShown) {
|
|
722
|
+
logger.debug(`Waiting for ${provider} auth redirect`, {
|
|
723
|
+
error: String(error),
|
|
724
|
+
});
|
|
725
|
+
crossOriginLogShown = true;
|
|
726
|
+
}
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (!url.startsWith(redirectUri)) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
cleanup(intervalId, timeoutId, true);
|
|
735
|
+
void Promise.resolve(onRedirect(url))
|
|
736
|
+
.then(() => {
|
|
737
|
+
resolve();
|
|
738
|
+
})
|
|
739
|
+
.catch((error: unknown) => {
|
|
740
|
+
reject(error);
|
|
741
|
+
});
|
|
742
|
+
}, POPUP_POLL_INTERVAL_MS);
|
|
743
|
+
});
|
|
305
744
|
}
|
|
306
745
|
|
|
307
746
|
private async loginGoogle(
|
|
308
747
|
scopes: string[],
|
|
309
748
|
loginHint?: string,
|
|
310
749
|
): Promise<void> {
|
|
311
|
-
const
|
|
312
|
-
const clientId = config.googleWebClientId;
|
|
750
|
+
const clientId = this._config.googleWebClientId;
|
|
313
751
|
|
|
314
752
|
if (!clientId) {
|
|
315
753
|
throw new Error(
|
|
@@ -324,7 +762,7 @@ class AuthWeb implements Auth {
|
|
|
324
762
|
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
325
763
|
authUrl.searchParams.set("response_type", "id_token token code");
|
|
326
764
|
authUrl.searchParams.set("scope", scopes.join(" "));
|
|
327
|
-
authUrl.searchParams.set("nonce",
|
|
765
|
+
authUrl.searchParams.set("nonce", crypto.randomUUID());
|
|
328
766
|
authUrl.searchParams.set("access_type", "offline");
|
|
329
767
|
authUrl.searchParams.set("prompt", "consent");
|
|
330
768
|
|
|
@@ -348,66 +786,53 @@ class AuthWeb implements Auth {
|
|
|
348
786
|
return;
|
|
349
787
|
}
|
|
350
788
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
789
|
+
void this.waitForPopupRedirect(popup, redirectUri, "Google", (url) => {
|
|
790
|
+
const hash = new URL(url).hash.slice(1);
|
|
791
|
+
const params = new URLSearchParams(hash);
|
|
792
|
+
const idToken = params.get("id_token");
|
|
793
|
+
const accessToken = params.get("access_token");
|
|
794
|
+
const expiresIn = params.get("expires_in");
|
|
795
|
+
const code = params.get("code");
|
|
358
796
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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);
|
|
797
|
+
if (!idToken) {
|
|
798
|
+
throw new Error("No id_token in response");
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
this._grantedScopes = scopes;
|
|
802
|
+
this.saveValue(SCOPES_KEY, JSON.stringify(scopes));
|
|
803
|
+
|
|
804
|
+
const user: AuthUser = {
|
|
805
|
+
provider: "google",
|
|
806
|
+
idToken,
|
|
807
|
+
accessToken: accessToken ?? undefined,
|
|
808
|
+
serverAuthCode: code ?? undefined,
|
|
809
|
+
scopes,
|
|
810
|
+
expirationTime: expiresIn
|
|
811
|
+
? Date.now() + parseInt(expiresIn, 10) * 1000
|
|
812
|
+
: undefined,
|
|
813
|
+
...this.decodeGoogleJwt(idToken),
|
|
814
|
+
};
|
|
815
|
+
this.updateUser(user);
|
|
816
|
+
})
|
|
817
|
+
.then(() => {
|
|
818
|
+
resolve();
|
|
819
|
+
})
|
|
820
|
+
.catch((error: unknown) => {
|
|
821
|
+
reject(error);
|
|
822
|
+
});
|
|
398
823
|
});
|
|
399
824
|
}
|
|
400
825
|
|
|
401
826
|
private decodeGoogleJwt(token: string): Partial<AuthUser> {
|
|
402
827
|
try {
|
|
403
|
-
const
|
|
404
|
-
const decoded = JSON.parse(atob(payload));
|
|
828
|
+
const decoded = this.parseJwtPayload(token);
|
|
405
829
|
return {
|
|
406
|
-
email: decoded
|
|
407
|
-
name: decoded
|
|
408
|
-
photo: decoded
|
|
830
|
+
email: getOptionalString(decoded, "email"),
|
|
831
|
+
name: getOptionalString(decoded, "name"),
|
|
832
|
+
photo: getOptionalString(decoded, "picture"),
|
|
409
833
|
};
|
|
410
|
-
} catch {
|
|
834
|
+
} catch (error) {
|
|
835
|
+
logger.warn("Failed to decode Google ID token", { error: String(error) });
|
|
411
836
|
return {};
|
|
412
837
|
}
|
|
413
838
|
}
|
|
@@ -418,8 +843,7 @@ class AuthWeb implements Auth {
|
|
|
418
843
|
tenant?: string,
|
|
419
844
|
prompt?: string,
|
|
420
845
|
): Promise<void> {
|
|
421
|
-
const
|
|
422
|
-
const clientId = config.microsoftClientId;
|
|
846
|
+
const clientId = this._config.microsoftClientId;
|
|
423
847
|
|
|
424
848
|
if (!clientId) {
|
|
425
849
|
throw new Error(
|
|
@@ -427,8 +851,8 @@ class AuthWeb implements Auth {
|
|
|
427
851
|
);
|
|
428
852
|
}
|
|
429
853
|
|
|
430
|
-
const effectiveTenant = tenant ??
|
|
431
|
-
const b2cDomain =
|
|
854
|
+
const effectiveTenant = tenant ?? this._config.microsoftTenant ?? "common";
|
|
855
|
+
const b2cDomain = this._config.microsoftB2cDomain;
|
|
432
856
|
const authBaseUrl = this.getMicrosoftAuthBaseUrl(
|
|
433
857
|
effectiveTenant,
|
|
434
858
|
b2cDomain,
|
|
@@ -477,58 +901,46 @@ class AuthWeb implements Auth {
|
|
|
477
901
|
return;
|
|
478
902
|
}
|
|
479
903
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
904
|
+
void this.waitForPopupRedirect(
|
|
905
|
+
popup,
|
|
906
|
+
redirectUri,
|
|
907
|
+
"Microsoft",
|
|
908
|
+
async (url) => {
|
|
909
|
+
const urlObj = new URL(url);
|
|
910
|
+
const code = urlObj.searchParams.get("code");
|
|
911
|
+
const returnedState = urlObj.searchParams.get("state");
|
|
912
|
+
const error = urlObj.searchParams.get("error");
|
|
913
|
+
const errorDescription = urlObj.searchParams.get("error_description");
|
|
914
|
+
|
|
915
|
+
if (error) {
|
|
916
|
+
throw new Error(errorDescription ?? error);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (returnedState !== state) {
|
|
920
|
+
throw new Error("State mismatch - possible CSRF attack");
|
|
486
921
|
}
|
|
487
922
|
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
}
|
|
923
|
+
if (!code) {
|
|
924
|
+
throw new Error("No authorization code in response");
|
|
529
925
|
}
|
|
530
|
-
|
|
531
|
-
|
|
926
|
+
|
|
927
|
+
await this.exchangeMicrosoftCodeForTokens(
|
|
928
|
+
code,
|
|
929
|
+
codeVerifier,
|
|
930
|
+
clientId,
|
|
931
|
+
redirectUri,
|
|
932
|
+
effectiveTenant,
|
|
933
|
+
nonce,
|
|
934
|
+
effectiveScopes,
|
|
935
|
+
);
|
|
936
|
+
},
|
|
937
|
+
)
|
|
938
|
+
.then(() => {
|
|
939
|
+
resolve();
|
|
940
|
+
})
|
|
941
|
+
.catch((error: unknown) => {
|
|
942
|
+
reject(error);
|
|
943
|
+
});
|
|
532
944
|
});
|
|
533
945
|
}
|
|
534
946
|
|
|
@@ -559,10 +971,9 @@ class AuthWeb implements Auth {
|
|
|
559
971
|
expectedNonce: string,
|
|
560
972
|
scopes: string[],
|
|
561
973
|
): Promise<void> {
|
|
562
|
-
const config = getConfig();
|
|
563
974
|
const authBaseUrl = this.getMicrosoftAuthBaseUrl(
|
|
564
975
|
tenant,
|
|
565
|
-
|
|
976
|
+
this._config.microsoftB2cDomain,
|
|
566
977
|
);
|
|
567
978
|
const tokenUrl = `${authBaseUrl}oauth2/v2.0/token`;
|
|
568
979
|
|
|
@@ -582,55 +993,48 @@ class AuthWeb implements Auth {
|
|
|
582
993
|
body: body.toString(),
|
|
583
994
|
});
|
|
584
995
|
|
|
585
|
-
const json = await
|
|
996
|
+
const json = await this.parseResponseObject(response);
|
|
586
997
|
|
|
587
998
|
if (!response.ok) {
|
|
588
999
|
throw new Error(
|
|
589
|
-
json
|
|
1000
|
+
getOptionalString(json, "error_description") ??
|
|
1001
|
+
getOptionalString(json, "error") ??
|
|
1002
|
+
"Token exchange failed",
|
|
590
1003
|
);
|
|
591
1004
|
}
|
|
592
1005
|
|
|
593
|
-
const idToken = json
|
|
1006
|
+
const idToken = getOptionalString(json, "id_token");
|
|
594
1007
|
if (!idToken) {
|
|
595
1008
|
throw new Error("No id_token in token response");
|
|
596
1009
|
}
|
|
597
1010
|
|
|
598
1011
|
const claims = this.decodeMicrosoftJwt(idToken);
|
|
599
|
-
const payload =
|
|
600
|
-
if (payload
|
|
1012
|
+
const payload = this.parseJwtPayload(idToken);
|
|
1013
|
+
if (getOptionalString(payload, "nonce") !== expectedNonce) {
|
|
601
1014
|
throw new Error("Nonce mismatch - token may be replayed");
|
|
602
1015
|
}
|
|
603
1016
|
|
|
604
|
-
const accessToken = json
|
|
605
|
-
const refreshToken = json
|
|
606
|
-
const
|
|
1017
|
+
const accessToken = getOptionalString(json, "access_token");
|
|
1018
|
+
const refreshToken = getOptionalString(json, "refresh_token");
|
|
1019
|
+
const expiresInSeconds = getOptionalNumber(json, "expires_in");
|
|
607
1020
|
|
|
608
1021
|
if (refreshToken) {
|
|
609
|
-
|
|
610
|
-
this._storageAdapter.save("microsoft_refresh_token", refreshToken);
|
|
611
|
-
} else {
|
|
612
|
-
localStorage.setItem(
|
|
613
|
-
"nitro_auth_microsoft_refresh_token",
|
|
614
|
-
refreshToken,
|
|
615
|
-
);
|
|
616
|
-
}
|
|
1022
|
+
this.saveRefreshToken(refreshToken);
|
|
617
1023
|
}
|
|
618
1024
|
|
|
619
1025
|
this._grantedScopes = scopes;
|
|
620
|
-
|
|
621
|
-
this._storageAdapter.save(SCOPES_KEY, JSON.stringify(scopes));
|
|
622
|
-
} else {
|
|
623
|
-
localStorage.setItem(SCOPES_KEY, JSON.stringify(scopes));
|
|
624
|
-
}
|
|
1026
|
+
this.saveValue(SCOPES_KEY, JSON.stringify(scopes));
|
|
625
1027
|
|
|
626
1028
|
const user: AuthUser = {
|
|
627
1029
|
provider: "microsoft",
|
|
628
1030
|
idToken,
|
|
629
1031
|
accessToken: accessToken ?? undefined,
|
|
1032
|
+
refreshToken: refreshToken ?? undefined,
|
|
630
1033
|
scopes,
|
|
631
|
-
expirationTime:
|
|
632
|
-
|
|
633
|
-
|
|
1034
|
+
expirationTime:
|
|
1035
|
+
typeof expiresInSeconds === "number"
|
|
1036
|
+
? Date.now() + expiresInSeconds * 1000
|
|
1037
|
+
: undefined,
|
|
634
1038
|
...claims,
|
|
635
1039
|
};
|
|
636
1040
|
this.updateUser(user);
|
|
@@ -650,62 +1054,115 @@ class AuthWeb implements Auth {
|
|
|
650
1054
|
|
|
651
1055
|
private decodeMicrosoftJwt(token: string): Partial<AuthUser> {
|
|
652
1056
|
try {
|
|
653
|
-
const
|
|
654
|
-
const decoded = JSON.parse(atob(payload));
|
|
1057
|
+
const decoded = this.parseJwtPayload(token);
|
|
655
1058
|
return {
|
|
656
|
-
email:
|
|
657
|
-
|
|
1059
|
+
email:
|
|
1060
|
+
getOptionalString(decoded, "preferred_username") ??
|
|
1061
|
+
getOptionalString(decoded, "email"),
|
|
1062
|
+
name: getOptionalString(decoded, "name"),
|
|
658
1063
|
};
|
|
659
|
-
} catch {
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
logger.warn("Failed to decode Microsoft ID token", {
|
|
1066
|
+
error: String(error),
|
|
1067
|
+
});
|
|
660
1068
|
return {};
|
|
661
1069
|
}
|
|
662
1070
|
}
|
|
663
1071
|
|
|
664
|
-
private async
|
|
665
|
-
|
|
666
|
-
|
|
1072
|
+
private async ensureAppleSdkLoaded(): Promise<void> {
|
|
1073
|
+
if (window.AppleID) {
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
667
1076
|
|
|
668
|
-
if (
|
|
669
|
-
|
|
670
|
-
"Apple Web Client ID not configured. Add 'APPLE_WEB_CLIENT_ID' to your .env file.",
|
|
671
|
-
);
|
|
1077
|
+
if (this._appleSdkLoadPromise) {
|
|
1078
|
+
return this._appleSdkLoadPromise;
|
|
672
1079
|
}
|
|
673
1080
|
|
|
674
|
-
|
|
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
|
+
|
|
675
1113
|
const script = document.createElement("script");
|
|
1114
|
+
script.id = scriptId;
|
|
676
1115
|
script.src =
|
|
677
1116
|
"https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js";
|
|
678
1117
|
script.async = true;
|
|
679
1118
|
script.onload = () => {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
clientId,
|
|
686
|
-
scope: "name email",
|
|
687
|
-
redirectURI: window.location.origin,
|
|
688
|
-
usePopup: true,
|
|
689
|
-
});
|
|
690
|
-
window.AppleID.auth
|
|
691
|
-
.signIn()
|
|
692
|
-
.then((response: AppleAuthResponse) => {
|
|
693
|
-
const user: AuthUser = {
|
|
694
|
-
provider: "apple",
|
|
695
|
-
idToken: response.authorization.id_token,
|
|
696
|
-
email: response.user?.email,
|
|
697
|
-
name: response.user?.name
|
|
698
|
-
? `${response.user.name.firstName} ${response.user.name.lastName}`.trim()
|
|
699
|
-
: undefined,
|
|
700
|
-
};
|
|
701
|
-
this.updateUser(user);
|
|
702
|
-
resolve();
|
|
703
|
-
})
|
|
704
|
-
.catch((err: unknown) => reject(this.mapError(err)));
|
|
1119
|
+
resolve();
|
|
1120
|
+
};
|
|
1121
|
+
script.onerror = () => {
|
|
1122
|
+
this._appleSdkLoadPromise = undefined;
|
|
1123
|
+
reject(new Error("Failed to load Apple SDK"));
|
|
705
1124
|
};
|
|
706
|
-
script.onerror = () => reject(new Error("Failed to load Apple SDK"));
|
|
707
1125
|
document.head.appendChild(script);
|
|
708
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
|
+
}
|
|
709
1166
|
}
|
|
710
1167
|
|
|
711
1168
|
async silentRestore(): Promise<void> {
|
|
@@ -727,17 +1184,14 @@ class AuthWeb implements Auth {
|
|
|
727
1184
|
this._grantedScopes = [];
|
|
728
1185
|
this.removeFromCache(CACHE_KEY);
|
|
729
1186
|
this.removeFromCache(SCOPES_KEY);
|
|
730
|
-
this.removeFromCache(
|
|
1187
|
+
this.removeFromCache(MS_REFRESH_TOKEN_KEY);
|
|
731
1188
|
this.notify();
|
|
732
1189
|
}
|
|
733
1190
|
|
|
734
1191
|
private updateUser(user: AuthUser) {
|
|
735
1192
|
this._currentUser = user;
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
} else {
|
|
739
|
-
localStorage.setItem(CACHE_KEY, JSON.stringify(user));
|
|
740
|
-
}
|
|
1193
|
+
const userToPersist = this.sanitizeUserForPersistence(user);
|
|
1194
|
+
this.saveValue(CACHE_KEY, JSON.stringify(userToPersist));
|
|
741
1195
|
this.notify();
|
|
742
1196
|
}
|
|
743
1197
|
|
|
@@ -745,12 +1199,12 @@ class AuthWeb implements Auth {
|
|
|
745
1199
|
logger.setEnabled(enabled);
|
|
746
1200
|
}
|
|
747
1201
|
|
|
748
|
-
|
|
749
|
-
this._storageAdapter = adapter
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1202
|
+
setWebStorageAdapter(adapter: JSStorageAdapter | undefined): void {
|
|
1203
|
+
this._storageAdapter = adapter
|
|
1204
|
+
? this.createWebStorageDriver(adapter)
|
|
1205
|
+
: undefined;
|
|
1206
|
+
this.loadFromCache();
|
|
1207
|
+
this.notify();
|
|
754
1208
|
}
|
|
755
1209
|
|
|
756
1210
|
name = "Auth";
|