I am trying to find/create a proper (the best) way to get and use custom claims in an Angular app. I added an admin custom claim via a cloud function. What I want to have (and what I tried to do until) now is:
auth.service
)auth.service
)(by only reading (subscribing to) the authState
in one place (e.g. authService.ts
), thus making maintenance easier and allowing other components to synchronously read the claims from the authService.ts
attributes/fields)
auth.service.ts
// imports omitted for brevity...
@Injectable()
export class AuthService {
user: Observable<User> = of(null);
uid: string;
claims: any = {};
claimsSubject = new BehaviorSubject(0);
constructor(private afAuth: AngularFireAuth,
private afStore: AngularFirestore,
private functions: AngularFireFunctions) {
this.afAuth.authState
.subscribe(
async authUser => {
if (authUser) { // logged in
console.log(`Auth Service says: ${authUser.displayName} is logged in.`);
this.uid = authUser.uid;
this.claims = (await authUser.getIdTokenResult()).claims;
// POINT_IN_CODE_#1
this.claimsSubject.next(1);
const userDocumentRef = this.afStore.doc<User>(`users/${authUser.uid}`);
// if provider is Google (or Facebook <later> (OR any other 3rd party))
// document doesn't exist on the first login and needs to be created
if (authUser.providerData[0].providerId === 'google.com') {
userDocumentRef.get()
.subscribe( async snapshot => {
if ( ! snapshot.exists) { // if the document does not exist
console.log(`\nNew document being created for: ${authUser.displayName}...`); // create a user document
await userDocumentRef.set({name: authUser.displayName, email: authUser.email, provider: 'google.com'});
}
});
}
this.user = userDocumentRef.valueChanges();
}
else { // logged out
console.log('Auth Service says: no User is logged in.');
}
}
);
}
login(email, password): Promise<any> {
return this.afAuth.auth.signInWithEmailAndPassword(email, password);
}
hasClaim(claim): boolean {
return this.hasAnyClaim([claim]);
}
hasAnyClaim(paramClaims): boolean {
for (let paramClaim of paramClaims) {
if (this.claims[paramClaim]) {
return true;
}
}
return false;
}
}
login.component.ts
// imports...
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
form: FormGroup;
hide = true;
errorMessage = '';
loading = false;
constructor(private fb: FormBuilder,
public authService: AuthService,
private router: Router) {}
ngOnInit() {
this.logout();
this.form = this.fb.group({
username: ['test@test.te', Validators.compose([Validators.required, Validators.email])],
password: ['Asdqwe123', Validators.compose([Validators.required])]
});
}
submit() {
this.loading = true;
this.authService.login(this.form.value.username, this.form.value.password)
.then(resp => {
this.loading = false;
// POINT_IN_CODE_#2
// what I am doing right now, and what doesn't work...
this.authService.user
.subscribe(
resp => {
if (this.authService.hasClaim('admin')) {
this.router.navigate(['/admin']);
}
else {
this.router.navigate(['/items']);
}
}
);
// POINT_IN_CODE_#3
//this.authService.claimsSubject
// .subscribe(
// num => {
// if (num === 1) {
// if (this.authService.hasClaim('admin')) {
// this.router.navigate(['/admin']);
// }
// else {
// this.router.navigate(['/items']);
// }
// }
// });
}
logout() {
this.authService.logout();
}
}
In auth.service.ts
at POINT_IN_CODE_#1
- I had the idea to emit from this subject claimsSubject
and in login.component.ts
at POINT_IN_CODE_#3
subscribe to it and know that, if it has a value of 1
, the claims have been retrieved in auth.service.ts
from the authState
.
In login.component.ts
at POINT_IN_CODE_#2
I know that I could get the claims from resp.getIdTokenResult
but it just doesn't "feel" right... which is what this question is about, mostly...
I want to be able to redirect the user after login to the admin
page if he has the 'admin' custom claims.
I would like to do it, as I stated above (if possible AND if it is good/improving-readability/improving_maintainability), without subscribing to the authState
directly, but through some "thing" from the auth.service.ts
.
I would use the same "logic" to make, for example, an AuthGuard
which would just call authService.hasClaim('admin')
, and not have to subscribe to authState
itself to do the check.
N.B. I want to know if the way I did it is good, if it has any caveats or just simple improvements. All suggestions and comments are welcome, so please do comment, especially on my Why do I want this? part!
Edit-1: Added typescript code highlighting and pointed out the exact place in my code that doesn't work the way I want.
Edit-2: Edited-out some comments regarding reasons why my authService.user was null... (I had some code run which set it to null before it was checked in the login component...)
OK, so... I've found a way to do it.
First, to clarify what exactly I "felt" was wrong with the idea of subscribing to the authState
in every component that needed to know something from it (be it the logged in state of a user, the user document or the claims):
It would be very difficult to maintain (especially make changes to) the code in every component, because I would have to update any logic regarding the retrieval of the data. Also, every component would have to do the checking of the data (e.g. check if the claims contain 'admin') by themselves, and surely it could be done only once upon the login/logout of the user and propagated to the ones who require it.
In the solution I made, that is exactly what I did. Everything regarding claims and logged-state of the user is managed by the authService
.
I managed to do this by using RxJS Subjects.
The typescript code of my service, login component and nav-bar component now looks like this:
auth.service.ts
// imports...
@Injectable()
export class AuthService {
uid: string = null;
user: User = null;
claims: any = {};
isAdmin = false;
isLoggedInSubject = new Subject<boolean>();
userSubject = new Subject();
claimsSubject = new Subject();
isAdminSubject = new Subject<boolean>();
constructor(private afAuth: AngularFireAuth,
private afStore: AngularFirestore,
private router: Router,
private functions: AngularFireFunctions) {
// the only subsription to authState
this.afAuth.authState
.subscribe(
authUser => {
if (authUser) { // logged in
this.isLoggedInSubject.next(true);
this.uid = authUser.uid;
this.claims = authUser.getIdTokenResult()
.then( idTokenResult => {
this.claims = idTokenResult.claims;
this.isAdmin = this.hasClaim('admin');
this.isAdminSubject.next(this.isAdmin);
this.claimsSubject.next(this.claims);
});
this.afStore.doc<User>(`users/${authUser.uid}`).get()
.subscribe( (snapshot: DocumentSnapshot<User>) => {
this.user = snapshot.data();
this.userSubject.next(this.user);
});
}
else { // logged out
console.log('Auth Service says: no User is logged in.');
}
}
);
}
login(email, password): Promise<any> {
return this.afAuth.auth.signInWithEmailAndPassword(email, password);
}
logout() {
this.resetState();
this.afAuth.auth.signOut();
console.log('User just signed out.');
}
hasClaim(claim): boolean {
return !!this.claims[claim];
}
resetState() {
this.uid = null;
this.claims = {};
this.user = null;
this.isAdmin = false;
this.isLoggedInSubject.next(false);
this.isAdminSubject.next(false);
this.claimsSubject.next(this.claims);
this.userSubject.next(this.user);
}
}
login.component.ts
// imports
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
providers = AuthProvider;
form: FormGroup;
hide = true;
errorMessage = '';
loading = false;
constructor(private fb: FormBuilder,
public authService: AuthService, // public - since we want to bind it to the HTML
private router: Router,
private afStore: AngularFirestore) {}
ngOnInit() {
this.form = this.fb.group({
username: ['test@test.te', Validators.compose([Validators.required, Validators.email])],
password: ['Asdqwe123', Validators.compose([Validators.required])]
});
}
/**
* Called after the user successfully logs in via Google. User is created in CloudFirestore with displayName, email etc.
* @param user - The user received from the ngx-auth-firebase upon successful Google login.
*/
loginWithGoogleSuccess(user) {
console.log('\nprovidedLoginWithGoogle(user)');
console.log(user);
this.doClaimsNavigation();
}
loginWithGoogleError(err) {
console.log('\nloginWithGoogleError');
console.log(err);
}
submit() {
this.loading = true;
this.authService.login(this.form.value.username, this.form.value.password)
.then(resp => {
this.loading = false;
this.doClaimsNavigation();
})
.catch(error => {
this.loading = false;
const errorCode = error.code;
if (errorCode === 'auth/wrong-password') {
this.errorMessage = 'Wrong password!';
}
else if (errorCode === 'auth/user-not-found') {
this.errorMessage = 'User with given username does not exist!';
} else {
this.errorMessage = `Error: ${errorCode}.`;
}
this.form.reset({username: this.form.value.username, password: ''});
});
}
/**
* Subscribes to claimsSubject (BehaviorSubject) of authService and routes the app based on the current user's claims.
*
*
* Ensures that the routing only happens AFTER the claims have been loaded to the authService's "claim" property/field.
*/
doClaimsNavigation() {
console.log('\nWaiting for claims navigation...')
this.authService.isAdminSubject
.pipe(take(1)) // completes the observable after 1 take ==> to not run this after user logs out... because the subject will be updated again
.subscribe(
isAdmin => {
if (isAdmin) {
this.router.navigate(['/admin']);
}
else {
this.router.navigate(['/items']);
}
}
)
}
}
nav-bar.component.ts
// imports
@Component({
selector: 'app-nav-bar',
templateUrl: './nav-bar.component.html',
styleUrls: ['./nav-bar.component.css']
})
export class NavBarComponent implements OnInit {
navColor = 'primary';
isLoggedIn = false;
userSubscription = null;
isAdmin = false;
user = null;
loginClicked = false;
logoutClicked = false;
constructor(private authService: AuthService,
private router: Router) {
this.authService.isLoggedInSubject
.subscribe( isLoggedIn => {
this.isLoggedIn = isLoggedIn;
});
this.authService.isAdminSubject
.subscribe( isAdmin => {
this.isAdmin = isAdmin;
});
this.authService.userSubject
.subscribe( user => {
this.user = user;
});
}
ngOnInit() {}
}
I hope it helps someone who shares my "bad feeling" about subscribing everywhere to authState
.
Note: I will mark this as the accepted answer, but feel free to comment and ask questions.