Using SuperTokens with Angular and Firebase

Part IV: Integrating SuperTokens with Angular and Firebase

--

This post is the last in my four-part series:

Using our SuperTokens demo app, we will now start tracking cats. Oh, and in the meantime, we will also learn how to integrate SuperTokens with Angular and Firebase.

We will basically follow along with the ThirdPartyEmailPassword Recipe on the SuperTokens website (as of mid-August 2022). However, we will make some tweaks to the recipe in order to integrate it with our Cat Tracker. When I first tried to integrate SuperTokens with an existing Angular application, I ran into a number of small issues, which were preventing me from achieving immediate success. Most of the issues had little or nothing to do with SuperTokens. Too many new things were happening all at once, which made it difficult for me to hone in on the real causes of the problems. I quickly got bogged down and frustrated. So, I took a step back and started from an existing demo provided by SuperTokens, and incrementally refactored step by step into my own simple demo using Angular and Firebase, ensuring that upon each small step the application continued to work correctly. This demo was possible thanks to the lessons I learned during that process.

What I hope to show in this demo is how to integrate SuperTokens into your existing Angular + Firebase application. Hopefully I can help you avoid the issues I faced during my own journey.

There are a number of steps we will take:

  1. Step 1 — Frontend I: Setting up the authentication UI
  2. Step 2 — Frontend II: Connecting the application
  3. Step 3 — Frontend III: Beefing up security
  4. Step 4 — Backend I: Connecting to SuperTokens
  5. Step 5 — Improving the application header
  6. Step 6 — Backend II: Securing our API
  7. Step 7 — Backend III: Connecting to Firebase Authentication

Step 1 — Frontend I: Setting up the authentication UI

First, we will set up the frontend. We start with the most obvious part of the frontend: the SuperTokens pre-build UI. It is possible to use a UI from a different third party (like Firebase, for instance) or to make your own custom UI, but I highly recommend that you start by using the pre-built UI. I discovered that it is designed quite well, and suits my purposes, so I am sticking with it for now for use with my own applications.

For Angular developers, there is a small drawback: the UI component is built using React. Thankfully, it is possible to integrate a React component into Angular. I had never done this before, but it turned out to be quite easy. I don’t like it because it makes my app “noisy” or “messy”, meaning that it introduces new concept and different coding practices which increases cognitive load, and requires additional dependencies, which I prefer to keep to the strictest minimum. However, in this case, in my judgement, the benefit outweighs these costs, so I have decided to stick with it. Perhaps in the future, we could consider contributing an actual native Angular component.

Before we do anything, let’s import the dependencies we required for this step:

npm i -s supertokens-auth-react react react-dom @types/react-dom

Next, let’s create a new SignInComponent:

npm g c components/signin --skip-import

This will simply be a proxy to the React component. Now create a new file supertokens-ui.tsx, which we will place in our components/signin directory. The file will contain these contents:

import * as React from "react";
import * as SuperTokens from "supertokens-auth-react";
import * as ThirdPartyEmailPassword from "supertokens-auth-react/recipe/thirdpartyemailpassword";
import { Github, Google } from "supertokens-auth-react/recipe/thirdpartyemailpassword";
import Session from "supertokens-auth-react/recipe/session";

const apiPort = 5001;
const apiDomain = `http://localhost:${apiPort}`;
const websitePort = 4200;
const websiteDomain = `http://localhost:${websitePort}`;

SuperTokens.init({
appInfo: {
appName: "SuperTokens Demo App", // TODO: Your app name
apiDomain, // TODO: Change to your app's API domain
websiteDomain, // TODO: Change to your app's website domain
// Add these to function properly with Firebase Functions on the backend
// TODO: Change to match your own settings
apiBasePath: '/auth',
apiGatewayPath: '/supertokens-demo-20220805/us-central1',
websiteBasePath: '/auth'
},
recipeList: [
ThirdPartyEmailPassword.init({
signInAndUpFeature: {
providers: [Github.init(), Google.init()],
},
emailVerificationFeature: {
mode: "REQUIRED",
},
onHandleEvent: async (context) => {
console.log('onHandleEvent', JSON.stringify(context));
if (context.action === "SESSION_ALREADY_EXISTS") {
// TODO:
console.log('Session Already Exists!');
} else if (context.action === "SUCCESS") {
let { id, email } = context.user;
if (context.isNewUser) {
// TODO: Sign up
console.log('New User');
} else {
// TODO: Sign in
console.log('Welcome back!');
}
} else {
console.error('WTF???');
}
}
}),
Session.init(),
],
});

class SuperTokensUI extends React.Component {
override render() {
if (SuperTokens.canHandleRoute()) {
return SuperTokens.getRoutingComponent();
}
return "Route not found";
}
}

export default SuperTokensUI;

The contents here expose the SuperTokens react UI component, and also initialize the component. Note the ThirdPartyEmailPassword and the Session lines in the recipeList. These are all imported from the supertokens-auth-react module. I had not noticed this in my initial attempt, and got confused between similarly-named code that is imported from other modules. I left in a bunch of console.log() statements to help understand what is going on. These will be removed later.

Be sure to add these lines to the compilerOptions in your tsconfig.json file:

"jsx": "react",
"allowSyntheticDefaultImports": true,

Now refactor the SignInComponent:

import { Component, OnDestroy, AfterViewInit } from "@angular/core";
import {CommonModule} from "@angular/common";
import {RouterModule} from "@angular/router";
import SuperTokensUI from "./supertokens-ui";
import * as React from "react";
import * as ReactDOM from "react-dom"

@Component({
selector: "app-signin",
template: '<div [id]="rootId"></div>',
standalone: true,
imports: [
CommonModule,
RouterModule,
],
})
export class SignInComponent implements OnDestroy, AfterViewInit {

public rootId = "rootId";

ngAfterViewInit() {
ReactDOM.render(React.createElement(SuperTokensUI), document.getElementById(this.rootId));
}

ngOnDestroy() {
ReactDOM.unmountComponentAtNode(document.getElementById(this.rootId) as Element);
}
}

The above uses React 17. If you are using React 18, the code should look more like this:

public rootId = "rootId";
root!: Root;

ngAfterViewInit() {
const container = document.getElementById(this.rootId);
this.root = createRoot(container!);
this.root.render(React.createElement(SuperTokensReactComponent));
}

ngOnDestroy() {
this.root.unmount();
}

Since we are using a literal template, you can delete the unnecessary html, scss, and spec files if they are there.

To actually access this component, update the application routes.

Start the app, manually navigate to http://localhost:4200/auth, and ensure that you can see the new SuperTokens UI component. You will see errors in the console at this point, but we will address those in a later step. If you don’t yet see the UI, you need to stop here and get it working before moving on. You should see something like this:

Step 2 — Frontend II: Connecting the application

In this step, we will continue to set up the frontend, but we will now turn our attention to integrating SuperTokens Sessions. Sessions are required for a number of reasons:

  1. Without Sessions, the user would have to sign in each time they refreshed the browser
  2. Sessions will allow us to authenticate with Firestore and eventually other third party services
  3. Sessions will allow us to connect with our own backend API services

To integrate Sessions, you need to insert another Session.init(). It is also described on the same page in the SuperTokens docs, but it was not immediately obvious to me that it was something different, and there were a few things that I did not quite appreciate.

Before we do anything else:

npm i -s supertokens-web-js

The code to integrate looks like this:

SuperTokens.init({
appInfo: {
appName: "Cat Tracker", // Or the name of your app
apiDomain: "http://localhost:8080", // Or whatever
},
recipeList: [Session.init()],
});

For starters, we are importing from a completely different module: supertokens-web-js. Although the code may look similar, it is actually doing something very different. Including this code allows us to connect with the backend, which in turn allows us to connect with the SuperTokens Core, which is where the authentication state is held. In other words, no Session.init(), no Sessions!

To ensure that this code is called for each route, SuperTokens suggests including it in the app.component.ts, but since we are using standalone components, we will instead include it in our main.ts file.

Check out this commit to see how I did it using an APP_INITIALIZER token. Now we “have” Sessions. It really is that simple! To actually make use of them, we still have some work to do, though. We can’t even really test the Session integration at this point. We’ll come back to that later.

Step 3— Frontend III: Beefing up security

The reason we want SuperTokens in the first place is to secure the application. We will do this the Angular way: with services and guards.

We will create a SuperTokensAuthService that looks like this:

import {Injectable} from '@angular/core';
import {BehaviorSubject, mergeMap, of} from "rxjs";
import * as Session from "supertokens-web-js/recipe/session";

@Injectable({
providedIn: 'root'
})
export class SuperTokensAuthService {

hasSessionSubject = new BehaviorSubject<boolean>(false);
hasSession$ = this.hasSessionSubject.asObservable();
userId$ = this.hasSession$
.pipe(
mergeMap(hasSession => hasSession ? Session.getUserId() : of(null)),
);

constructor() { }

async checkForSession(): Promise<boolean> {
const doesSuperTokensSessionExist = await Session.doesSessionExist();
this.hasSessionSubject.next(doesSuperTokensSessionExist);
return doesSuperTokensSessionExist;
}

signOut(): Promise<void> {
return Session.signOut()
.then(() => this.hasSessionSubject.next(false));
}
}

This service will be used by components, and also by our authentication guard. Here is the guard:

import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from '@angular/router';
import {SuperTokensAuthService} from "../services/supertokens-auth.service";

@Injectable({
providedIn: 'root'
})
export class IsAuthenticatedGuard implements CanActivate {

constructor(
private auth: SuperTokensAuthService,
private router: Router) {}

async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> {
const doesSessionExist = await this.auth.checkForSession();
if (!doesSessionExist) {
return this.router.navigate(['/auth']);
}

return doesSessionExist;
}
}

We can now use the guard to protect all routes that need to be secured. See the commit for details.

In this demo, to show that Firestore and backend API routes do not function as we would expect, we will not put a guard on those, so to test the guard, let’s add another route, and protect the new route with our guard:

ng g c components/secret --skip-import

We will also update the tracker page and the fact page to ensure that we are not sharing any feline know-how to anybody who is not authorized.

You should now be able to run the application, and confirm that the SecretComponent is inaccessible, and that the other pages are no longer leaking any secret information. When you try to access http://localhost:4200/secret, you will get redirected to the SignInComponent. When you access the other pages, you will notice that they offer a sign-in link.

Step 4— Backend I: Connecting to SuperTokens

Now we will turn our attention to the backend. We need to include the SuperTokens backend SDK to make our application function as expected. The backend SDK acts as the bridge between our frontend and the SuperTokens core. The documentation is here.

We will use the NodeJS express SDK, but we will have to make a few tweaks to make it work with our Firebase Functions.

First, let’s restructure the backend code. Right now it is all in a single index.ts file. We will split up the code into separate files.

As usual, start with adding the necessary dependencies:

cd functions
npm i -s supertokens-node

Next, take a look at this commit to see how I divided up the code into “auth” (for SuperTokens authentication) and “cats” (for our backend API).

To make the SuperTokens backend work properly with Firebase Functions, you need to make a few tweaks to the init configuration. The configuration for the API in the appInfo should look something like this:

 appInfo: {
...
apiBasePath: '/',
apiGatewayPath: '/{your_firebase_app_name}/{firebase_server}/auth',
...
},

In the code, you will see that I left in some function overrides in the init configuration and included some log messages so you can see what is happening behind the scenes. We will be using some of these later, so don’t remove anything just yet.

Build the code:

npm run build

Start the emulators:

./firebase-start.sh

Before starting and testing the app, make sure you update the /auth route in the frontend to accept all child routes. This is necessary because the social logins will redirect back to a child route, which needs to be handled by the SuperTokens frontend.

{
path: 'auth',
title: 'SuperTokens Demo – Sign In',
children: [
{
path: '**',
component: SignInComponent,
},
],
},

Now test the application again. You should now be able to sign in and view the (updated) secret page. We still have a little work to do before the tracker and the fact pages work correctly.

Step 5— Improving the application header

Right now, the only way we can “sign out” of the application is to delete the cookies that contain the session data. That is not very convenient. It is time to update the application header to make it more useful. We will add theses features:

  • When the user is signed out, the header will show a “sign in” link.
  • When the user is signed in, the header will show a “sign out” link.

This commit shows the results of the changes.

Step 6— Backend II: Securing our API

We will now focus our attention on the fact page and the backend API.

On the frontend, because of the way SuperTokens is implemented, we need to make sure that the Session is fetched to determine if the user is authenticated or not. To validate a Session, SuperTokens requires that you insert code like this:

import Session from 'supertokens-web-js/recipe/session';async function doesSessionExist() {
if (await Session.doesSessionExist()) {
// user is logged in
} else {
// user has not logged in yet
}
}

In our Angular application, this code happens to be in our SuperTokensAuthService. It gets called by any page that is guarded by our IsAuthenticatedGuard, but the fact page is not guarded, so we need to explicitly make the call. It is possible to do so in the constructor or in ngOnInit(); I opt to do it in the constructor of the CatsComponent.

constructor(
private cats: CatFactsService,
private auth: SuperTokensAuthService) {
auth.checkForSession();
this.isAuthenticated$ = auth.isAuthenticated$;
}

The above code was already committed previously, so there isn’t anything more to do on the frontend.

To secure the API, we also need to check the authentication token on the backend. It would be a mistake to secure the frontend routes without also ensuring that unauthorized parties cannot access the API. Add the verifySession() handler.

import {verifySession} from "supertokens-node/recipe/session/framework/express";...app.get('/fact', configureResponseHandler, verifySession(), getFactHandler)

There are a few important changes to make before this will work. First, ensure that the CORS configuration is correct. As shown in the docs, it should look something like this:

app.use(
cors({
origin: websiteDomain,
allowedHeaders: ["content-type", ...supertokens.getAllCORSHeaders()],
credentials: true,
})
);

SuperTokens is not (yet?) specialized to work with Angular. When Sessions are installed on the frontend, SuperTokens uses interceptors, but not Angular interceptors. Rather, it uses interceptors for fetch. Assuming that you use HttpClient when you communicate with a backend API, this is a problem. Since SuperTokens does not integrate with HttpClient, the session cookie will not get communicated, and the API call will not work.

To solve this problem, we will create a custom HttpClient that uses fetch in the background. This was heavily inspired by Stuart Tottle.

import {
HttpBackend,
HttpErrorResponse,
HttpEventType,
HttpHeaders,
HttpRequest,
HttpResponse,
HttpSentEvent,
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import {from, Observable, of, OperatorFunction, throwError} from 'rxjs';
import { fromFetch } from 'rxjs/fetch';
import { catchError, map, startWith, switchMap } from 'rxjs/operators';

const XSSI_PREFIX = /^\)\]\}',?\n/;

/**
* Heavily inspired from https://stuarttottle.medium.com/use-the-fetch-api-in-angular-1acafa67bbf2.
*/
@Injectable({
providedIn: "root"
})
export class HttpFetchBackend implements HttpBackend {

handle(req: HttpRequest<unknown>) {
if (!window.fetch) {
throw new Error('Fatal Error: no fetch implementation found')
}

return fromFetch(req.url, this.mapToRequestInit(req))
.pipe(
this.handleResponse(req),
this.parseResponse(req),
startWith({ type: HttpEventType.Sent } as HttpSentEvent)
);
}

private mapToRequestInit = <T>(req: HttpRequest<T>): RequestInit => {
return {
method: req.method,
body: req.serializeBody() as BodyInit,
headers: this.mapFromHttpHeaders(req),
// credentials: req.withCredentials ? 'include' : 'omit',
};
}

private mapFromHttpHeaders = <T>(req: HttpRequest<T>): HeadersInit => {
return req.headers
.keys()
.reduce(
(headers, name) => ({ ...headers, [name]: req.headers.get(name) }),
{
Accept: 'application/json, text/plain, */*',
'Content-Type': req.detectContentTypeHeader(),
}
) as HeadersInit;
}

private handleResponse = <T>(req: HttpRequest<T>): OperatorFunction<Response, Response> => {
const operatorFn = (res: Observable<Response>) => {
return res.pipe(
catchError((error) =>
throwError(() => new HttpErrorResponse({
error,
status: 0,
statusText: 'Unknown Error',
url: req.url,
})),
),
switchMap((res: Response) => {
if (!res.ok) {
return this.parseBody(res, req)
.pipe(
catchError((error: any) => throwError(() => new HttpErrorResponse({
error,
headers: this.mapToHttpHeaders(res.headers),
status: res.status,
statusText: res.statusText,
url: res.url || undefined,
}))),
)
} else {
return of(res);
}
}),
map((res) => res as Response),
);
}

return operatorFn;
}

private parseResponse = <T>(req: HttpRequest<T>): OperatorFunction<Response, HttpResponse<unknown>> => {
return (res) =>
res.pipe(
switchMap((res) =>
this.parseBody(res, req)
.pipe(
catchError((error) => throwError(() => new HttpErrorResponse({
error: {
error,
text: res.body,
},
headers: this.mapToHttpHeaders(res.headers),
status: res.status,
statusText: res.statusText,
url: res.url || undefined,
})
)),
map((body) => this.mapToHttpResponse(body, res))
)
)
);
}

private parseBody = <T>(res: Response, req: HttpRequest<T>): Observable<unknown> => {
switch (req.responseType) {
case 'json':
return from(res.text()).pipe(
map((body) => body.replace(XSSI_PREFIX, '')),
map((body) => (body !== '' ? JSON.parse(body) : null))
);
case 'blob':
return from(res.blob());
case 'arraybuffer':
return from(res.arrayBuffer());
default:
return from(res.text());
}
}

private mapToHttpResponse(body: unknown, res: Response) {
return new HttpResponse({
body,
headers: this.mapToHttpHeaders(res.headers),
status: res.status,
statusText: res.statusText,
url: res.url,
});
}

private mapToHttpHeaders = (res: Headers) => {
const headers = new HttpHeaders();
res.forEach((val, key) => headers.set(key, val));
return headers;
}
}

Note in this commit how I injected this custom service.

Now, with these changes, when you run the application, the fact page should be working as expected, and you should immediately become an expert on cat trivia.

Step 7— Backend III: Connecting to Firebase Authentication

At last, we will connect the SuperTokens authentication session to the Firebase Authentication session.

For the purposes of this demo, we now want Firestore itself to manage security based on whether or not the user is authenticated. We will do this by updating the Firestore rules. To see this in action, on the frontend we will need to remove the isAuthenticated flag from the TrackComponent. See this commit for details.

Now to integrate. First, we need to make use of the Firebase Admin in order to integrate Firebase Authentication with SuperTokens in the backend. We will override two SuperTokens functions in the Session init configuration: createNewSession and signOutPOST.

...
import * as admin from 'firebase-admin';
...Session.init({
override: {
functions: function (originalImplementation) {
return {
...originalImplementation,
createNewSession: async function (input) {
const uid = input.userId;
await admin.auth().updateUser(uid, {emailVerified: true});
const firebaseToken = await admin.auth().createCustomToken(uid);
input.accessTokenPayload = {
...input.accessTokenPayload,
firebaseToken
};
return originalImplementation.createNewSession(input);
},
};
},
apis: (originalImplementation) => {
return {
...originalImplementation,
signOutPOST: async function (input) {
let session = await Session.getSession(input.options.req, input.options.res, input.userContext);
const uid = session.getUserId();
await admin.auth().revokeRefreshTokens(uid);
return originalImplementation.signOutPOST!(input);
},
}
},
},
}),

Next, to integrate on the frontend, we will create an additional service: FirebaseAuthService.

export class FirebaseAuthService {

constructor(private auth: AngularFireAuth) {}

async signInOrRefreshSession(firebaseToken: string): Promise<boolean> {
const existingToken = await lastValueFrom(this.auth.idToken.pipe(first()));
if (existingToken) {
return false;
}

await this.auth.signInWithCustomToken(firebaseToken);
return true;
}

signOut(): Promise<void> {
return this.auth.signOut();
}
}

We will add these lines in checkForSession():

if (doesSuperTokensSessionExist) {
const token = (await Session.getAccessTokenPayloadSecurely()).firebaseToken;
// Don't need to await this: run in parallel
this.firebase.signInOrRefreshSession(token);
}

Then add this line to signOut():

await this.firebase.signOut();

One last thing: update the Firestore rules:

rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {

match /items/{document=**} {
allow read, write: if request.auth != null;
}
}
}

You can test the application. You will need to sign out, then sign in again, but everything should now work as expected!

Closing remarks

So far, I am quite satisfied with SuperTokens. It was the right choice for my needs. Shoutout to rp for all his help on the SuperTokens Discord channel.

--

--

David Leangen | Entrepreneur & Software Engineer

Business-oriented engineer & technically oriented executive and entrepreneur. I apply technology to help small businesses thrive.