DEV Community

Cover image for II - Firebase User Management in Angular
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

II - Firebase User Management in Angular

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));
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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');
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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)));
    }
}
Enter fullscreen mode Exit fullscreen mode

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],
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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;
      })
    );
};
Enter fullscreen mode Exit fullscreen mode

In routes:

// main.ts

{
  path: 'private',
  loadChildren: () => import('./app/routes/dashboard.route').then((m) => m.DashboardRoutes),
  canActivate: [AuthCanActivate],
  data: { role: 'admin'}
},
Enter fullscreen mode Exit fullscreen mode

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 of idToken
  • 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)
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
    })
  );
};
Enter fullscreen mode Exit fullscreen mode

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);
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

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 }}`
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Then in our AuthState

// services/auth.state
Logout() {
  this.token.next(null);
}
Enter fullscreen mode Exit fullscreen mode

Finally in our component, a click handler:

// any component click handler
Logout() {
  this.authService.Signout().subscribe({
    next: () => {
      this.router.navigateByUrl('/public/login');
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Let us move on to the other recommended solution. Next Tuesday inshalla. 🔻

RESOURCES

RELATED POSTS

Authentication in Angular, why it is so hard to wrap your head around it - Sekrab Garage

The basic ingredients. Authentication ingredientsA full cycle of authentication and authorization in an SPA may go through the following factors:<ul><li>User login: getting an access token, and possibly a refresh token.</li.... Posted in Angular

favicon garage.sekrab.com

Top comments (0)