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'
});
}
});
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
}
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
);
}
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))
);
}
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');
});
}
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');
// ...
}
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 viagetAdditionalUserInfo
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: LoginGoogle
, LoginUser
, 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 });
}
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);
}
//...
})
});
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;
}
},
});
}
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)),
);
}
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();
})
// ...
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$;
}
Here are the ingredients:
- An
IAuthInfo
to model our local user model - Update state after calling
LoginUser
andUpdateUser
inAuthService
- 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
}
}
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;
})
);
}
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 });
})
);
}
}
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;
})
);
};
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);
}
}
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 });
}
}
});
}
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(...)
);
}
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');
}
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 forphotoUrl
. There is nodisplayName
in Admin SDK. There is aname
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. 🔻
Top comments (0)