To recap, we need to create a sign up process using password and Google provider. We need to keep all user management on Firebase. We need to add role admin
, and bloodType
attributes for every user.
One thing to keep in mind as we maneuver our way through Firebase Auth, you can get the token only on the client side, and you can set claims only on the server side.
Let's begin with the server routes, and see how we can set custom claims.
I had to ditch StackBlitz for this project, find the code final code on GitHub.
Setting custom claims
The page that user signs in, should have the extra field in it for bloodType
. Let's create an API route for it: POST /api/user
// server/routes.js route to set custom claims
// the sdk created earlier is passed or can be imported directly
// const { getAuth } = require('firebase-admin/auth');
// const sdk = getAuth();
module.exports = function (sdk) {
const router = express.Router();
// update local user with custom claims
router.post('/user', function(req, res) {
// get user from middleware
const user = res.locals.user;
// read body params, could be an object
const bloodType = req.body.bloodType;
// set admin to true, set a new property bloodType
sdk.setCustomUserClaims(user.uid, {admin: true, bloodType}).then(() => {
// return user although we dont have to
res.json({...user, bloodType, admin: true});
}).catch(function(error) {
res.status(401).json({
message: 'Invalid token'
});
});
});
return router;
}
// pass this in main server like this
webapi.use('/api', require('./routes')(sdk));
In AuthService
we pipe a swichMap
to call this API. Let's not be too specific about typing today, thank God for any
// services/auth.service
// a new function to sign up, passing bloodType
Signup(email: string, password: string, custom: any): Observable<any> {
const res = () => createUserWithEmailAndPassword(this.auth, email, password);
return defer(res).pipe(
// send back to API server to create user with custom claims
switchMap(_ => this.UpdateUser(custom))
);
}
UpdateUser(custom: any): Observable<any> {
return this.http.post('/api/user', custom);
}
We could have sent the token in the body, but because updating users might be needed in a different context, we'll rely on the middleware instead. We need to add the authorization header to be sent for the second call. We'll do that right after we figure out Google provider.
Sign in with Google
To Sign in with password, all we have to do is return the user to the client-side, and start using it. The Sign in with Google however is a bit trickier. We have no clue whether the user exists or not. One way to find out is check the custom attribute bloodType
. Another way is to check user metadata, which contains lastLoginAt
, and createdAt
, if they are milliseconds apart, this is a new user. But the best way is to check for additional user info right after login: getAdditionalUserInfo()
If the property isNewUser
is set to true
, we need to request the bloodType
before we move on. So our service will just return the user, and let the component decide what to do with it:
// services/auth.service
// the login with google can check for new user:
LoginGoogle(): Observable<boolean> {
const provider = new GoogleAuthProvider();
const res = () => signInWithPopup(this.auth, provider)
.then((userCredential) => {
// import getAdditionalUserInfo from '@angular/fire/auth'
const info = getAdditionalUserInfo(userCredential);
return info.isNewUser;
});
return defer(res);
}
In the component, how to react to show a sign-up form is beyond the scope of this article. Once we get the bloodType
, we call an update method (on click of submit button). There is nothing extra we need to set, because the middleware will received the token, verify and unpack the user, then set custom claims and return.
// components/public/login.component
loginGoogle() {
this.authService.LoginGoogle().pipe(
catchError(...)
).subscribe({
next: (isNewUser) => {
if (!isNewUser) {
// log user in and redirect
this.router.navigateByUrl('/private/dashboard');
} else {
// somehow redirect or show a sign up form
someVarShowInput = true;
}
}
});
}
// when bood type is provided, finish sign up
updateSingup() {
this.authService.UpdateUser({ bloodType: 'B+' }).subscribe({
next: (user) => {
// logged in and signed up, redirect
this.router.navigateByUrl('/private/dashboard');
}
});
}
Putting them together
One gap we might want to think about is if user signs up with Google, but does not continue to provide the bloodType
. The better way is to rely on our API to check if it exists or not.
Also, a better user experience for the sign in with Password is to sign user in first, and if an error comes back, ask for the bloodType
and sign up, which mimics the Sign in with Google.
We will do both in the next episode.
Adding the authorization header
The token in Firebase is wrapped inside the idToken
subscription, but under the hood, it does not change during the session. We need to find a way to get that token at initialization, and keep it alive during the session.
We begin with the end in sight. The http interceptor function should have a way to add the bearer token:
// services/http.fn
// the getHeaders should add token from auth state
const getHeaders = (): any => {
const authState = inject(AuthState);
// authorization here
let headers: any = {};
// to be implemented:
const _auth = authState.GetToken();
if (_auth && _auth !== '') {
headers['authorization'] = `Bearer ${_auth}`;
}
return headers;
};
export const AppInterceptorFn: HttpInterceptorFn = (
req: HttpRequest<any>,
next: HttpHandlerFn
) => {
const adjustedReq = req.clone({
// ... prefix url here
setHeaders: getHeaders(),
});
return next(adjustedReq);
};
We need an AuthState
service with at least GetToken
that returns a string, we can inject the Firebase Auth
in it, and start listening to the idToken
. We can keep the value in a BehaviorSubject
to get the value when we need it.
// new services/auth.state
@Injectable({ providedIn: 'root' })
export class AuthState {
// let the state have a behavior subject to keep track of value
private token: BehaviorSubject<string> = new BehaviorSubject(null);
token$: Observable<string> = this.token.asObservable();
constructor(private auth: Auth) {
// subscrine to id token changes
idToken(this.auth).subscribe({
next: (token) => {
this.UpdateState(token);
}
});
}
GetToken() {
return this.token.getValue();
}
// update state
UpdateState(token: string) {
this.token.next(token);
}
}
This might look like a good idea at first, but if we immediately call the API, this method is too slow. The token is still null
. Instead, we need to populate the state immediately after sign in or sign up. We need to further build the pipe with idToken
// services/auth.service
export class AuthService {
constructor(
private http: HttpClient,
private authState: AuthState,
private auth: Auth
) {}
// pipe after login, or sign up, to save the token in state
Signup(...): Observable<any> {
// ... createUserWithEmailAndPassword
return defer(res).pipe(
// with idToken
switchMap(_ => idToken(this.auth)),
tap((token) => {
// save state first
this.authState.UpdateState(token);
}),
// then call backend
switchMap(_ => this.UpdateUser(custom)));
}
}
This will ensure that the header has the correct token before calling the API. All other API's can be taken care of with calling the AuthState
immediately at creating the application, and where better to do that than APP_INITIALIZER
? We already had this set up before:
// main.ts
// in our root providers array
bootstrapApplication(AppComponent, {
providers: [
// ...
{
provide: APP_INITIALIZER,
// dummy factory
useFactory: () => () => {},
multi: true,
// injected depdencies, this will be constructed immidiately
deps: [AuthState],
},
],
});
Auth route guard
In AngularFire documentation you can find a reference to built-in Auth guard functionality. We can make use of them, for example if we want to block a route for other than admins.
// main.ts (or in routing)
import { canActivate, hasCustomClaim } from '@angular/fire/auth-guard';
const adminOnly = () => hasCustomClaim('admin');
// define a route
{
path: 'private',
loadChildren: () => import('./app/routes/dashboard.route').then((m) => m.DashboardRoutes),
// use canActivate
...canActivate(adminOnly)
}
They have other examples of mixing and matching and using claims. In our project, I would rather create our own guard:
// services/augh.guard
export const AuthCanActivate: CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> => {
const auth = inject(Auth); // @angular/fire/auth
const router = inject(Router);
// get data from route, for example {date: {role: 'admin'}}
const role = route.data.role;
// watch user (import from @angular/fire/auth)
return user(auth).pipe(
// switch map to get claims
switchMap(_user => _user ? _user.getIdTokenResult() : of(null)),
map(_user => {
// if user exists let them in, else redirect to login
if (!_user) {
router.navigateByUrl('/public/login');
return false;
}
// user exists, match claims to route data
if (!_user.claims.hasOwnProperty(role)) {
router.navigateByUrl('/public/dashboard');
return false;
}
return true;
})
);
};
In routes:
// main.ts
{
path: 'private',
loadChildren: () => import('./app/routes/dashboard.route').then((m) => m.DashboardRoutes),
canActivate: [AuthCanActivate],
data: { role: 'admin'}
},
So by setting claims.admin
to true
, we gain access to the dashboard route.
The curious case of the stale token
After calling our API to set custom claims, the token has changed on Firebase, but it did not reflect in our client side. This could cause problems if we depend on the custom claims, the token on the client at this stage is stale. Here are some scenarios of what could go wrong, and we need to fix them.
- After signing up and updating claims, redirecting to a private route that depends on custom claims
admin
attribute. The token does not have it. - Updating user bloodType to O- then within less than an hour, refreshing the application. The token still says B+. This is a life threatening situation!
- Although rare: leaving a page open for an hour without any interactions, then calling an http with the current token. The server verification will fail.
Refresh after API update
In the first case, on the next augh.guard
call , we used user()
observable, which is wrapped around onIdTokenChange
of Firebase (See code of rxFire). Then we called getIdTokenResult()
. Here is the thing:
-
onIdTokenChange
does not refresh the token -
getIdTokenResult
returns a new token only if the current token has expired (or about to).
None of the AngularFire observables is capable of refreshing the token. To refresh it, we need to call .getIdToken(true)
. So after an update statement, and before we redirect to a private route that uses the guard, we need to hard refresh the token. Since we are already listening to idToken
in the pipe of signing up, this might create a loop. So let's decide now:
- Use closer-to-home
getTokenId()
instead ofidToken
- Force a refresh with
getTokenId(true)
Here is the Firebase documentation of what should happen in normal JavaScript.
And here is our Signup
and UpdateUser
// services/auth.service
Signup(...): Observable<any> {
// ...
return defer(res).pipe(
// first token, directly from getIdToken, this is good enough
// because it is used after a Firebase function "create user"
switchMap(_ => this.auth.currentUser.getIdToken()),
tap(token => {
// save state first, to use token
this.authState.UpdateState(token);
}),
// then call update
switchMap(_ => this.UpdateUser(custom))
);
}
UpdateUser(custom: { bloodType: string; }): Observable<any> {
return this.http.post('/user', custom).pipe(
// force a new token, and wait for it
// it must be a switchMap
switchMap(_ => this.auth.currentUser.getIdToken(true)),
tap(token => {
// update state for future http calls
return this.authState.UpdateState(token)
})
);
}
The switchMap
in the UpdateUser
is the best way to ensure the new token is returned from the Promise
, other operators are too asynchronous to catch in the redirect.
Any call to the UpdateUser
should now force update the token afterwards. Life saved.
Update state after Login
After login (from Firebase), we have no problems because the token received has custom claims, we however need to keep our AuthState
up to date, with the idToken
in the constructor that should be enough. But always pay attention, if there is any immediate http call after login, it must be called in place, the idToken
subscriber might be too slow.
Let's refactor our AuthService
// services/auth.service
// a single pipe for all
private _updateState = (force: boolean) => pipe(
switchMap(_ => this.auth.currentUser.getIdToken(force)),
tap((token) => {
// save state as well
this.authState.UpdateState(token);
})
);
Signup(email: string, password: string, custom: any): Observable<any> {
// ...
return defer(res).pipe(this._updateState(false),
switchMap((_) => this.UpdateUser(custom))
);
}
UpdateUser(custom: any): Observable<any> {
return this.http.post('/user', custom).pipe(this._updateState(true));
}
Refresh 401 token
Instead of trying to keep the token fresh by manipulating events, it's better to let the API http call return a 401 when the token expires, then handle it.
Having the
auth.guard
on multiple routes is good enough to keep the token fresh. But it's not good enough to update our state with the new token.
We've already had this feature implemented in our Angular Authentication series. Here it is as an http interceptor function, for standalone, instead of an interceptor class.
// http interceptor with refresh token sequence
let isBusy = false;
let recall: Subject<boolean> = new Subject();
const handle401Error = (originalReq: HttpRequest<any>, next: HttpHandlerFn, authState: AuthState): Observable<any> => {
if (!isBusy) {
isBusy = true;
// progress subject to false
recall.next(false);
// this RefreshToken needs to be implemented
return authState.RefreshToken().pipe(
switchMap((result: any) => {
if (result) {
recall.next(true);
return next(
// pass around the authstate injected
originalReq.clone({ setHeaders: getHeaders(authState) })
);
}
}),
catchError((error) => {
// logout may be? doesn't matter
return throwError(() => error);
}),
finalize(() => {
isBusy = false;
})
);
} else {
return recall.pipe(
filter((ready) => ready === true),
switchMap((ready) => {
return next(
originalReq.clone({ setHeaders: getHeaders(authState) })
);
})
);
}
};
export const AppInterceptorFn: HttpInterceptorFn = (
req: HttpRequest<any>,
next: HttpHandlerFn
) => {
const url = 'prefixurl' + req.url;
// inject and pass around
const authState = inject(AuthState);
const adjustedReq = req.clone({
url: url,
setHeaders: getHeaders(authState),
});
return next(adjustedReq).pipe(
catchError((error) => {
// if this is really an http error
if (
error instanceof HttpErrorResponse &&
// and of 401 status
error.status === 401
// filter out login calls
req.url.indexOf('login') < 0
) {
return handle401Error(adjustedReq, next, authState);
}
// rethrow error
return throwError(() => error);
})
);
};
The AuthState
RefreshToken
returns an observable that calls the getIdToken(true)
to be sure it is fresh (though it isn't needed, we already know it will be refreshed).
// services/auth.state
RefreshToken(): Observable<any> {
// defer, or from() have the same effect
return defer(() => this.auth.currentUser.getIdToken(true)).pipe(
tap((token) => {
// update state with token for next http call
this.UpdateState(token);
})
);
}
Tried it. And it works as expected. I had to create a page with a button and leave it for an hour till the token expired!
Off-app updates and sensitive changes
Another scenario often referred to in Firebase documentation in a subtle way is when the token that becomes invalid for changes happening off our app. For example if we have an admin portal where we can change the bloodType
of a certain user, while they are using the client app. The token will expire in at least an hour. According to Firebase, that is okay. And it is, most of the time.
Firebase also suggests using re-authentication for serious actions, and it actively errors out on certain sensitive actions if user is not recently logged in. This scenario is out of scope of this article, but I just wanted to drive your attention that this is indeed a case with no solution. You just have to have soul.
Getting user custom attributes
In order to read the custom claims for the logged in user, we can use getUserTokenResult()
then read the user.claims
. We can do that by subscribing to the user
AngularFire subscriber, or we can map to our internal user model, and update it upon sign in, sign up, and user update. All roads lead to Jerusalem. In a simple listener, we need to pipe (again):
// home.component
// an example of usning claims
// OnInit:
this.status$ = user(this.auth).pipe(
filter(user => !!user),
// use the promise directly, this works
switchMap(user => user.getIdTokenResult()),
map(token => token.claims)
);
// in the template
`{{ (status$ | async)?.bloodType }}`
But it is much better to wrap everything in a proper user model, that we can carry around, and scale as we go. We can place that in the AuthState
as well, instead of wrapping the token alone, it can include all user properties. Mapping to an internal model is a subject we will dig into in the final recommended solution.
I have no idea what the point is in burying the claims a level too low like that!
Logout
To logout, we use the Firebase signOut
then we need to nullify the token in our own AuthState
. First inside the AuthService
:
// services/auth.service
Signout(): Observable<boolean> {
// signOut from '@angular/fire/auth'
const res = () => signOut(this.auth).then(() => {
this.authState.Logout();
return true;
});
// catch error and return false if needed
return defer(res);
}
Then in our AuthState
// services/auth.state
Logout() {
this.token.next(null);
}
Finally in our component, a click handler:
// any component click handler
Logout() {
this.authService.Signout().subscribe({
next: () => {
this.router.navigateByUrl('/public/login');
}
})
}
Let us move on to the other recommended solution. Next Tuesday inshalla. 🔻
RESOURCES
- GitHub project
- Firebase Docs - User Metadata
- Firebase Docs - getAdditionalUserInfo
- AngularFire Auth Guard
- RxFire source code
- Firebase Docs - Custom Claims
- Firebase Docs - Reauthentication
Top comments (0)