-
Notifications
You must be signed in to change notification settings - Fork 410
/
Copy pathauth.tsx
268 lines (232 loc) · 9.16 KB
/
auth.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
import * as React from 'react';
import { user } from 'rxfire/auth';
import { preloadObservable, ReactFireOptions, useAuth, useObservable, ObservableStatus, ReactFireError } from './';
import { from, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { useSuspenseEnabledFromConfigAndContext } from './firebaseApp';
import type { Auth, User, IdTokenResult } from 'firebase/auth';
type Claims = IdTokenResult['claims'];
export async function preloadUser(authResolver: () => Promise<Auth>) {
const auth = await authResolver();
const user$ = preloadObservable(user(auth), `auth:user:${auth.name}`);
return user$.toPromise();
}
/**
* Subscribe to Firebase auth state changes, including token refresh
*
* @param options
*/
export function useUser<T = unknown>(options?: ReactFireOptions<T>): ObservableStatus<User | null> {
const auth = useAuth();
const observableId = `auth:user:${auth.name}`;
const observable$ = user(auth);
return useObservable(observableId, observable$, options);
}
export function useIdTokenResult(user: User, forceRefresh = false, options?: ReactFireOptions<IdTokenResult>): ObservableStatus<IdTokenResult> {
if (!user) {
throw new Error('you must provide a user');
}
const observableId = `auth:idTokenResult:${user.uid}:forceRefresh=${forceRefresh}`;
const observable$ = from(user.getIdTokenResult(forceRefresh));
return useObservable(observableId, observable$, options);
}
export interface AuthCheckProps {
fallback: React.ReactNode;
children: React.ReactNode;
requiredClaims?: Object;
}
export interface ClaimsCheckProps {
user: User;
fallback: React.ReactNode;
children: React.ReactNode;
requiredClaims: { [key: string]: any };
}
export interface ClaimCheckErrors {
[key: string]: any[];
}
export type SigninCheckResult =
| {
signedIn: false;
hasRequiredClaims: false;
errors: {};
user: null;
}
| {
signedIn: true;
hasRequiredClaims: boolean;
errors: ClaimCheckErrors;
user: User;
};
export interface SignInCheckOptionsBasic extends ReactFireOptions<SigninCheckResult> {
forceRefresh?: boolean;
}
export interface SignInCheckOptionsClaimsObject extends SignInCheckOptionsBasic {
requiredClaims: Claims;
}
export interface ClaimsValidator {
(claims: Claims): {
hasRequiredClaims: boolean;
errors: ClaimCheckErrors | {};
};
}
export interface SignInCheckOptionsClaimsValidator extends SignInCheckOptionsBasic {
validateCustomClaims: ClaimsValidator;
}
/**
* Subscribe to the signed-in status of a user.
*
* ```ts
* const { status, data:signInCheckResult } = useSigninCheck();
*
* if (status === 'loading') {
* return <LoadingSpinner />}
*
*
* if (signInCheckResult.signedIn === true) {
* return <ProfilePage user={signInCheckResult.user}/>
* } else {
* return <SignInForm />
* }
* ```
*
* Optionally check [custom claims](https://firebase.google.com/docs/auth/admin/custom-claims) of a user as well.
*
* ```ts
* // pass in an object describing the custom claims a user must have
* const {status, data: signInCheckResult} = useSigninCheck({requiredClaims: {admin: true}});
*
* // pass in a custom claims validator function
* const {status, data: signInCheckResult} = useSigninCheck({validateCustomClaims: (userClaims) => {
* // custom validation logic...
* }});
*
* // You can optionally force-refresh the token
* const {status, data: signInCheckResult} = useSigninCheck({forceRefresh: true, requiredClaims: {admin: true}});
* ```
*/
export function useSigninCheck(
options?: SignInCheckOptionsBasic | SignInCheckOptionsClaimsObject | SignInCheckOptionsClaimsValidator
): ObservableStatus<SigninCheckResult> {
// If both `requiredClaims` and `validateCustomClaims` are provided, we won't know which one to use
if (options?.hasOwnProperty('requiredClaims') && options?.hasOwnProperty('validateCustomClaims')) {
throw new Error('Cannot have both "requiredClaims" and "validateCustomClaims". Use one or the other.');
}
const auth = useAuth();
// ObservableId should change for different options configurations to ensure no cache collisions
let observableId = `auth:signInCheck:${auth.name}::forceRefresh:${!!options?.forceRefresh}`;
if (options?.forceRefresh) {
observableId = `${observableId}:forceRefresh:${options.forceRefresh}`;
}
if (options?.hasOwnProperty('requiredClaims')) {
observableId = `${observableId}:requiredClaims:${JSON.stringify((options as SignInCheckOptionsClaimsObject).requiredClaims)}`;
} else if (options?.hasOwnProperty('validateCustomClaims')) {
// TODO(jamesdaniels): Check if stringifying this function breaks in IE11
observableId = `${observableId}:validateCustomClaims:${JSON.stringify((options as SignInCheckOptionsClaimsValidator).validateCustomClaims)}`;
}
const observable = user(auth).pipe(
switchMap((user) => {
if (!user) {
const result: SigninCheckResult = { signedIn: false, hasRequiredClaims: false, errors: {}, user: null };
return of(result);
} else if (options && (options.hasOwnProperty('requiredClaims') || options.hasOwnProperty('validateCustomClaims'))) {
return from(user.getIdTokenResult(options?.forceRefresh ?? false)).pipe(
map((idTokenResult) => {
let validator: ClaimsValidator;
if (options.hasOwnProperty('requiredClaims')) {
validator = getClaimsObjectValidator((options as SignInCheckOptionsClaimsObject).requiredClaims);
} else {
validator = (options as SignInCheckOptionsClaimsValidator).validateCustomClaims;
}
const { hasRequiredClaims, errors } = validator(idTokenResult.claims);
const result: SigninCheckResult = { signedIn: true, hasRequiredClaims, errors, user: user };
return result;
})
);
} else {
// If no claims are provided to be checked, `hasRequiredClaims` is true
const result: SigninCheckResult = { signedIn: true, hasRequiredClaims: true, errors: {}, user: user };
return of(result);
}
})
);
return useObservable(observableId, observable, options);
}
function getClaimsObjectValidator(requiredClaims: Claims): ClaimsValidator {
return function claimsObjectValidator(userClaims) {
const errors: { [key: string]: ReactFireError[] } = {};
Object.keys(requiredClaims).forEach((claim) => {
if (requiredClaims[claim] !== userClaims[claim]) {
errors[claim] = [new ReactFireError('auth/missing-claim', `Expected "${requiredClaims[claim]}", but user has "${userClaims[claim]}" instead`)];
}
});
return {
hasRequiredClaims: Object.keys(errors).length === 0,
errors,
};
};
}
/**
* @deprecated Use `useSigninCheck` instead
*
* Conditionally render children based on [custom claims](https://firebase.google.com/docs/auth/admin/custom-claims).
*
* Meant for Concurrent mode only (`<FirebaseAppProvider suspense=true />`). [More detail](https://github.com/FirebaseExtended/reactfire/issues/325#issuecomment-827654376).
*/
export function ClaimsCheck({ user, fallback, children, requiredClaims }: ClaimsCheckProps) {
const { data, status, error } = useIdTokenResult(user, false);
if (status === 'loading') {
throw new Error('ClaimsCheck must be run in Suspense mode');
} else if (status === 'error') {
throw error
}
const { claims } = data;
const missingClaims: { [key: string]: { expected: string; actual: string | undefined } } = {};
const suspenseMode = useSuspenseEnabledFromConfigAndContext();
if (!suspenseMode) {
console.warn(
'ClaimsCheck is deprecated and only works when ReactFire is in experimental Suspense Mode. Use useSigninCheck or set suspense={true} in FirebaseAppProvider if you want to use this component.'
);
}
if (requiredClaims) {
Object.keys(requiredClaims).forEach((claim) => {
if (requiredClaims[claim] !== claims[claim]) {
missingClaims[claim] = {
expected: requiredClaims[claim],
actual: claims[claim]?.toString(),
};
}
});
}
if (Object.keys(missingClaims).length === 0) {
return <>{children}</>;
} else {
return <>{fallback}</>;
}
}
/**
* @deprecated Use `useSigninCheck` instead
*
* Conditionally render children based on signed-in status and [custom claims](https://firebase.google.com/docs/auth/admin/custom-claims).
*
* Meant for Concurrent mode only (`<FirebaseAppProvider suspense=true />`). [More detail](https://github.com/FirebaseExtended/reactfire/issues/325#issuecomment-827654376).
*/
export function AuthCheck({ fallback, children, requiredClaims }: AuthCheckProps): JSX.Element {
const { data: user } = useUser<User>();
const suspenseMode = useSuspenseEnabledFromConfigAndContext();
if (!suspenseMode) {
console.warn(
'AuthCheck is deprecated and only works when ReactFire is in experimental Suspense Mode. Use useSigninCheck or set suspense={true} in FirebaseAppProvider if you want to use this component.'
);
}
if (user) {
return requiredClaims ? (
<ClaimsCheck user={user} fallback={fallback} requiredClaims={requiredClaims}>
{children}
</ClaimsCheck>
) : (
<>{children}</>
);
} else {
return <>{fallback}</>;
}
}