DEV Community

Cover image for III - Firebase For Authentication only in Angular
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

III - Firebase For Authentication only in Angular

The middle ground is to use Firebase Auth, for authentication, and use our own API for user management.

Find the project on Github.

Express API routes

The idea of having two servers, one for authentication and one for profiling is to deal with the profile server (our API) as a normal data source, with authentication required. The authentication server is expected to send in an access token, which is verified first, before it moves down the pipe of creating and editing user profile. So after signing in and signing up with password, or after signing in with Google, we need to send the token to our Express server to create the user, fetch user, or patch user.

// server/routes
router.post('/auth/login', function (req, res) {
  // if id does not exist create user, else update user
  const payload = req.body;
  const token = payload.token;
  // we need to first verify token
  sdk.auth().verifyIdToken(token).then(function (decodedToken) {
    // then find user by id
    const id = decodedToken.uid;
    // find profile in db, example
    const profile = {...};

    // if user does not exist, create one
    if (!profile) {
      // save new user to db then return
      // we don't have bloodType just yet
      const _newuser = {
        id: decodedToken.uid,
        picture: decodedToken.picture,
        // use the decodeToken for now
        email: decodedToken.email,
        // some hard coded attributes
        admin: true,
        // this flag is important
        newUser: true};
      // save _newuser in db here, then return
      // ...
      res.json(_newuser);
    } else {
      // return existing user with full profile, including bloodtype
      // watch out for client-side gap, bloodType may still not exist
      res.json(profile);
    }

  }).catch(function (error) {
    // 401
  });
});

// update logged in user
router.patch('/user', function(req, res) {
  // get from middleware
  const user = res.locals.user;

  if (user) {
    // update user in db with body
    const payload = req.body;
    let profile = {...user};
    profile.bloodType = payload.bloodType;
    // switch off newUser
    profile.newUser = false;
    // save to db
    // ...
    // update locals
    res.locals.user = profile;

    // return profile
    res.json(profile);
  } else {
     res.status(401).json({
      message: 'Access denied',
      code: 'ACCESS_DENIED'
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

As you can see I have saved the newUser flag in our server, because it is crucial to close a UI gap, we'll cover later.

Sign in with password in AuthService would call the first route.

// services/auth.service
// login changed, we'll map to IAuthInfo later
Login(email: string, password: string): Observable<IAuthInfo> {
  const res = () => signInWithEmailAndPassword(this.auth, email, password);
  return defer(res).pipe(
    // get the token to force a fresh one
    switchMap(auth => auth.user.getIdToken()),
    // login user in our API
    switchMap(token => this.LoginUser(token))
  );
}

LoginUser(token: string): Observable<IAuthInfo> {
  return this.http.post('/auth/login', { token });
  // we need to later save into state
}
Enter fullscreen mode Exit fullscreen mode

Sign up with password would call the first route, then request the bloodType from the user, then call the PATCH /user next.

// services/auth.service
// sign up changed
SingUp(email: string, password: string, custom: any): Observable<IAuthInfo> {
  // here send a sign up request, with extra params
  const res = () =>
    createUserWithEmailAndPassword(this.auth, email, password);

  return defer(res).pipe(
    // first new token
    switchMap(auth => auth.user.getIdToken()),
    // log user in and request extra information
    switchMap(token => this.LoginUser(token)),
    // then patch
    switchMap(_ => this.UpdateUser(custom)
  );
}

UpdateUser(custom: any): Observable<IAuthInfo> {
  return this.http.patch('/user', custom).pipe(
    // we need to update state again here
  );
}
Enter fullscreen mode Exit fullscreen mode

In our components so far nothing has changed. But we can do better. In a minute. First, let's see what changed on Sign in with Google.

Remember that we faced an issue with null tokens before, so here we getIdToken before any action to API.

Sign in with Google

In the previous episode we depended on the Firebase service to tell us whether the user is new or not using getAdditonalUserInfo, before we proceeded to show the sign up form. Now we need to log the user in our API server first. Our API server can take on the job and check if uid already exists, and return newUser from our data source. Initially the service call looks like this

// serivces/auth.service

// Login by Google initially:
LoginGoogle(): Observable<IAuthInfo> {
  const provider = new GoogleAuthProvider();
  const res = () => signInWithPopup(this.auth, provider);

  return defer(res).pipe(
    switchMap(auth => auth.user.getIdToken()),
    // call our API and check for uid
    switchMap(token => this.LoginUser(token))
  );
}
Enter fullscreen mode Exit fullscreen mode

We can use the IAuthInfo model to check if user is new (remember the /auth/login returns newUser flag).

// components/public/login.component

// login with Google
loginGoogle() {
  this.authService.LoginGoogle()
  .subscribe({
    next: (user) => {
      // read newUser from our API
      if (!user.newUser) {
        this.router.navigateByUrl('/private/dashboard');
      } else {
        // show the sign up field (somehow)
        this.showMeForm = true;
      }
    },
  });
}
// finish sign up by google
update() {
  // grab form bloodType, example: B+
  this.authService.UpdateUser({ bloodType: 'B+' }).subscribe({
    next: (user) => this.router.navigateByUrl('/private/dashboard');
  });
}
Enter fullscreen mode Exit fullscreen mode

If user is new, after submitting and calling update we proceed normally to call UpdateUser({bloodType}) to PATCH user.

The new user flag

The first thing we need to fix is this gap between Login and Sign Up, if user does not finish sign up and comes back later to try with the same Google email, Firebase will return an existing user. But that user never provided their bloodType. To fix that, we need to keep track of the newUser flag. The flag is turned off only when user finishes the sign up and provide bloodType. Which is in the PATCH /user route.

This is one of the two issues I promised we'd fix in the previous episode.

Request Email

The second issue to fix is the email. If you do not need an email in your application this should be fine. But if you, like the rest of us, would like to annoy your customers with endless propaganda emails, then you need the email. First we addScope:

 // serivces/auth.service
LoginGoogle(): Observable<IAuthInfo> {
  const provider = new GoogleAuthProvider();
  provider.addScope('email');
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The returned token does not have the email. Nice going Firebase! The extra piece of information is found in two places,

  • userCredential.user.providerData[]. An array of well-formed data (UserInfo[])
  • AdditionalUserInfo.profile. The format depends on the provider, needs to be requested via getAdditionalUserInfo but also has a very important piece of information: isNewUser. But we can do without it now.

For the particular case of Google sign in, we'll rely on the first method. But you might want to investigate further for X (Twitter) provider, and no, not Facebook, we're boycotting Facebook.

The change affects three places: LoginGoogleLoginUser, and the route /auth/login:

// services/auth.service
// catch email in google, and send it to API
LoginGoogle(): Observable<IAuthInfo> {
 //...

  return defer(res).pipe(
      switchMap(auth => auth.user.getIdToken()),
            // login with provider email
      switchMap(token => this.LoginUser(token, this.auth.currentUser.providerData[0].email))
  );
}
// catch email and send to API
LoginUser(token: string, email?: string): Observable<IAuthInfo> {
    return this.http.post('/auth/login', { token, email });
}
Enter fullscreen mode Exit fullscreen mode

On the server:

// in server/routes.js
router.post('/auth/login', function (req, res) {

  const payload = req.body;
  const token = payload.token;

  sdk.auth().verifyIdToken(token).then(function (decodedToken) {
    //...
    if (!profile) {
      // ...
      // read email from payload instead of decodedToken
      const _newuser = {
        email: payload.email
        // ...
      } 
      // ...
      res.json(_newuser);
    }
    //...
  })
});
Enter fullscreen mode Exit fullscreen mode

Which means, we need to send the email with Sign in with Password. Let's also do the promised enhancement of combining sign in and sign up with password.

Combine sign in and sign up

For best user experience, we should allow user to sign in first, and if the user is new, request additional information for sign up. This matches the sequence of events handled with Google login, or any third party login.

In our component we need just one login call, that switches to sign up if the user is new.

// components/public/login.component
// change to login to allow sign up
login() {
  this.authService
    .Login('email@address.com', 'valid_firebase_password')
    .pipe(catchError...)
      .subscribe({
      next: (user) => {
        // read newUser from our API
        if (!user.newUser) {
          this.router.navigateByUrl('/private/dashboard');
        } else {
          // show the sign up field
          this.showMe = true;
        }
      },
   });
}
Enter fullscreen mode Exit fullscreen mode

This looks exactly like the loginGoogle method. We are on the right track.

In our AuthService, the Login needs to change a bit, it will catch the invalid credentials error, to switch to SignUp.

// services/auth.service
// Login first
Login(email: string, password: string): Observable<IAuthInfo> {
  const res = () => signInWithEmailAndPassword(this.auth, email, password);
  return defer(res).pipe(
    switchMap(auth => auth.user.getIdToken()),
    // send email
    switchMap(token => this.LoginUser(token, email)),
    catchError(err => {
      // catch invalid credentials to sign up
      if (err.code === 'auth/invalid-credential') {
        return this.SingUp(email, password);
      }
      // throw everything else
      return throwError(() => err);
    })
  );
}
// remove custom attributes
SingUp(email: string, password: string): Observable<IAuthInfo> {
  const res = () =>
    createUserWithEmailAndPassword(this.auth, email, password);
  return defer(res).pipe(
    switchMap(auth => auth.user.getIdToken()),
    // stop here and return, send email as well
    switchMap(token => this.LoginUser(token, email)),
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the update and UpdateUser take care of patching the bloodType, just as in the Google sign in sequence. Great. Now what?

Authentication header

In order for the PATCH /user call to work, we need to pass a fresh token into the header. The following is the authentication middleware, nothing fancy, we just get the user from data source first, match to the uid returned by Firebase token.

// server/auth.middleware.js
// update to find profile by uid first
sdk.auth().verifyIdToken(authheader).then(function (decodedToken) {
  // example:
  let profile = profiles.find((profile) => profile.id === decodedToken.uid);
  // if found, set, else nullify
  if (profile){
    res.locals.user = profile;
  } else {
    res.locals.user = null;
  }
  // next
  next();
})
// ...
Enter fullscreen mode Exit fullscreen mode

Our http interceptor is the same we created in the last episode. It too will have a 401 error catch, that will request a fresh token from Firebase. Let's dig in the AuthState and user model to see how we can save the token.

Maintaining state

The end result we want for the user state is to be able to display the information based on a state item (like the one we developed in our Angular Authentication series).

// components/some component
// template
`<div *ngIf="status$ | async as s">
  {{ s.email }} {{ s.bloodType }}
</div>`

status$: Observable<IAuthInfo>;
constructor(private authState: AuthState) {}
ngOnInit(): void {
  // watch auth state item
  this.status$ = this.authState.stateItem$;
}
Enter fullscreen mode Exit fullscreen mode

Here are the ingredients:

  • An IAuthInfo to model our local user model
  • Update state after calling LoginUser and UpdateUser in AuthService
  • An AuthState service to keep track of user state
  • An AuthGuard to protect private route
  • Initiate the token property directly from Firebase

Auth user model

The IAuthInfo has the basic attributes, and a public method to map our user

// services/auth.model
export interface IAuthInfo {
  id: string;
  bloodType?: string;
  admin?: boolean;
  // some properties from firebase
  picture?: string;
  email?: string;
  // a place for the token
  token?: string;
  // a boolean for newUser
  newUser?: boolean;
}

export const MapAuth = (auth: any): IAuthInfo => {
  // map incoming from db with our user
  // this isn't required, but preferable
  // token is not mapped here
  return {
    id: auth.id,
    email: auth.email,
    admin: auth.admin,
    bloodType: auth.bloodType,
    picture: auth.picture,
    newUser: auth.newUser
  }
}
Enter fullscreen mode Exit fullscreen mode

The token is not mapped directly from API, it will be added from Firebase.

Update service

After every visit to the API, we need to update the state, preferably by mapping to our local model, passing the fresh token coming back.

// services/auth.service
// update state after API calls
LoginUser(token: string, email?: any): Observable<IAuthInfo> {
  return this.http.post('/auth/login', { token, email }).pipe(
    map((auth: any) => {
      // map and save user in localstorage here, including token
      const _user = MapAuth(auth);
      this.authState.UpdateState({ ..._user, token });
      return _user;
    }),
  );
}
UpdateUser(custom: any): Observable<IAuthInfo> {
  return this.http.patch('/user', custom).pipe(
    map(auth => {
      // now update localstorage again
      const _user = MapAuth(auth);
      this.authState.UpdateState({..._user});
      return _user;
      })
  );
}
Enter fullscreen mode Exit fullscreen mode

AuthState service

The main elements of our AuthState besides the constructor is a state item BehaviorSubject and a proper UpdateState method that updates the Subject and saves into local storage. It should inject the AngularFire Auth service as well. We should add the GetToken and RefreshToken to use in our http interceptor. Here it is 🔻

@Injectable({ providedIn: 'root' })
export class AuthState {
  // create an internal subject and an observable to keep track
  private stateItem: BehaviorSubject<IAuthInfo | null> = new BehaviorSubject(null);
  stateItem$: Observable<IAuthInfo | null> = this.stateItem.asObservable();
  constructor(
    // inject from '@angular/fire/auth'
    private auth: Auth
  )
  {
    // TODO: initiate state
  }

  UpdateState(item: Partial<IAuthInfo>): Observable<IAuthInfo> {
    // update existing state
    const newItem = { ...this.stateItem.getValue(), ...item };
    this.stateItem.next(newItem);
    // save into a key in localStorage
    localStorage.setItem('user', JSON.stringify(newItem));
    // return observable
    return this.stateItem$;
  }

  GetToken() {
    // return token as is
    const _auth = this.stateItem.getValue();
    return _auth?.token || null;
  }
  RefreshToken() {
    // refresh by calling getIdToken with `true`
    return defer(() => 
      this.auth.currentUser.getIdToken(true)).pipe(
      switchMap(token => {
        // update state then return an observable to pipe to
        return this.UpdateState({ token });
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Read more about a localStorage Angular wrapper, and RxJS based state management to have a fuller solution.

Auth route guard

The guard now reads directly from our state item.

// services/auth.guard

export const AuthCanActivate: CanActivateFn = (...): Observable<boolean> => {
  // inject our auth state
  const auth = inject(AuthState);
  const router = inject(Router);

  const role = route.data.role;

  // watch user
  return auth.stateItem$.pipe(
    map(_user => {
     // if user exists let them in, else redirect to login
      if (!_user) {
        router.navigateByUrl('/public/login');
        return false;
      }
       // user exists, match property to route data
      if (!_user.hasOwnProperty(role)) {
        router.navigateByUrl('/public/login');
        return false;
      }
      return true;
    })
  );
};
Enter fullscreen mode Exit fullscreen mode

Initiate the token

When the application is launched, we can initiate the state from the local storage, through the same APP_INITIALIZER factory we already have. The constructor looks like this

// services/auth.state

constructor(...) {
  // initialize state directly from localStorage
    const _localuser: IAuthInfo = JSON.parse(localStorage.getItem('user'));
  if (_localuser) {
    this.UpdateState(_localuser);
  }
}
Enter fullscreen mode Exit fullscreen mode

The most extreme case is when user lands on a protected route, and that user already has a saved state in local storage. The synchronous way of setting the initial state means the Auth Guard will have something to use. But the token used, is not only stale, but expired. What does that mean?

The curious case of an expired token

Should we expel user? Given the fact that Firebase token is designed around the idea of refreshing itself in an hour, we should not worry about it. Also remember, the route guard is cosmetic, the real security is on API. The next API call will throw a 401, which will initiate a refresh sequence.

We can also make sure that 401 doesn't happen as often, if we update the token on initialization at least once:

// services/auth.state

constructor(...)
{
  // .. update localStorage first
  // take just 1, this will only get a new one if it's expired
  idToken(this.auth).pipe(
    take(1),
  ).subscribe({
    next: (token) => {
      if (token) {
        this.UpdateState({ token });
      }
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

If changes occur off the system, like user changes their password, we simply have to wait till it expires. Or again, we can guard important actions with our own "forced" token. For example, to request a list of codes only after user is logged in, we can do this

// example of a tighter security call
GetSafeCodes(): Observable<something> {
  // first get token with force flag
  return defer(() => this.auth.currentUser.getIdToken(true)).pipe(
    switchMap(token => {
      // then call API if token exists\
      return this.GetCodes();
    }),
    catchError(...)
  );
}
Enter fullscreen mode Exit fullscreen mode

Logout

Last bit to add is the logout, it is exactly like the one we had last episode, with the addition of cleaning localStorage in AuthState

// services/auth.state
Logout() {
  this.stateItem.next(null);
  localStorage.removeItem('user');
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

So this is it. Exposed and laid down. Here are some extra points to mention

  • Firebase Auth is a front layer that hides a lot of operations that take care of authentication, especially with third party
  • It is mostly asynchronous calls, returns Promises. This is a bit annoying, as you lose information down the pipe of multiple promises.
  • Use the modular SDK, for tree-shaking
  • The documentation will make you lose few pounds, or a few years of your life expectancy, if you are working with web, you have go through only these:
  • The decoded token of Firebase in Admin SDK has a different model than the one returned after user sign-in on client-side. For example, picture, is for photoUrl. There is no displayName in Admin SDK. There is a name property, but it is not documented!

In summary:

  • We created a NodeJs Express server to handle Firebase verification
  • We created a service that handles sign in, and sign up, with Password and Google, that have the same sequence
  • We saved information in our local storage
  • We hunted down the Firebase token, refreshed it when needed, and relaxed when it was okay, relied on 401 when it made sense
  • We looked into two different implementations, one is more recommended than the other, and it is my personal go for

Happy 2024. Are we there yet?

Thanks for reading this far, if you have comments and questions, let me know. 🔻

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

LocalStorage wrapper service in Angular - Sekrab Garage

Storage and Caching. Browser Storage is accessible in browser platform only, and it is just one way to cache in browser. Thus we should avoid accessing it directly in code, wrapping it with internal service allows us to.... Posted in Angular, Design

favicon garage.sekrab.com

RxJS based state management in Angular - Part I - Sekrab Garage

Angular state management simple solution. Google it, Angular State Management, odds are, you will end up on an ngRx solution. Referring to this greate article Choosing the State Management Approach in Angular App , I am here to explore and i.... Posted in Angular, Design

favicon garage.sekrab.com

Top comments (0)