By definition of an SPA: single page application, all elements on the screen are no longer part of a global page state, but have their own existence, and lifespan. Authentication, and authorization., affect some or all of the elements on the screen, making it the odd one out.
Authentication ingredients
A full cycle of authentication and authorization in an SPA may go through the following factors:
- User login: getting an access token, and possibly a refresh token.
- Storing on client: in local storage along with minimal details
- Login resolver: to direct away from the login page if the user is already authenticated
- Router auth guard: to direct to login page if user is not authenticated
- Logout: remove storage
Use cases:
- Http interceptor: to use the token for API calls
- 401 handling: to request a new token
- User display: getting extra details to display on the screen
- Redirect URL: an extra piece of information to track
Other concerns:
- Third-party: how much do we need to change to adapt
- Server-side rendering: getting the access token by other means
We should always remind ourselves as we build authentication that:
- Not all routes go through an auth guard
- Some components display user status anyway
- There is a sequence of events taking place in an Angular app
Follow along on StackBlitz
The simplest login
Let us begin with the simplest format of authentication, a form with user name and password, that returns a profile with an access token, and possibly a refresh token. Over the years, I have come to terms with a few things and tend to never argue about them:
- Yes sign the token on the server and return both access token and refresh token to the client
- Yes, save them both in
LocalStorage
- Yes, extract and save everything you need on the client
- No, you don't have to have a server for the front end
The API is a simple POST that accepts username and password and returns a dummy access token and a refresh token. First, let's get one thing out of the way. A login button.
Avoiding
standalone
on app root, and routed components is the best setup, as we previously concluded.
We are creating a route from application root, /public/login
// components login.component.ts
login() {
// we are going to mimic a click of a login button
someAuthService.login(username, password).subscribe()
}
Let's create the AuthService
to handle the login.
Auth Service
The AuthService
has everything that needs to be done on the server. We will send a request to our API, and in future episodes touch on third-party auth servers. The bare minimum is a service that logs user in and returns a typed object of, let's call it, IAuthInfo
.
// Auth service
@Injectable({ providedIn: 'root' })
export class AuthService {
private _loginUrl = '/auth/login';
constructor(private _http: HttpClient) {}
// login method
Login(username: string, password: string): Observable<any> {
return this._http.post(this._loginUrl, { username, password }).pipe(
map((response) => {
// prepare the response to be handled, then return
return response;
})
);
}
}
Assume the body of the response to be as follows. Notice that detailed user information is usually not the responsibility of an authentication server, so the payload is slim.
{ accessToken: 'some_acces_token',
refreshToken: 'some_refresh_token',
payload: {
"id": "841fca92-eba8-48c8-8c96-92616554c590",
"email": "aa@gmail.com"
},
expiresIn: 3600}
Let us build proper models to set authentication information apart from user information (the payload).
// user model
export interface IUser {
email: string;
id: string;
}
// auth model
export interface IAuthInfo {
payload?: IUser;
accessToken?: string;
refreshToken?: string;
expiresAt?: number
}
Back in our service, when login is successful we need to save information in the localStorage
.
Login(username: string, password: string): Observable<any> {
return this._http.post(this._loginUrl, { username, password }).pipe(
map((response) => {
// prepare the response to be handled, then return
// we'll tidy up later
const retUser: IAuthInfo = <IAuthInfo>(<any>response).data;
// save in localStorage
localStorage.setItem('user', JSON.stringify(retUser));
return retUser;
})
);
}
Direct cast to IAuthInfo
and using localStorage
without a wrapper is not ideal, but we have more important issues to cover.
When the user visits the site again, I need to populate user information from localStorage
. If not found, there are two scenarios
- if private route: return to a safe route
- if public route: allow user in, but flag user as not logged in
With many hidden caveats and twists and turns, it is easier said than done.
The bottom line
Here is a diagram of different elements of a random app.
Reading the user state from localStorage
needs to happen as early as possible, because the rest of the app depends on it.
Let me demonstrate.
Let's create a couple of routes, and a partial component to display user info in the root application component. I choose the root because it is the most extreme location to display anything. Under private/dashbaord
we'll have a component, that login redirects to.
// login component
login() {
this.authService.Login('username', 'password').subscribe({
next: (result) => {
// redirect to dashbaord
this.router.navigateByUrl('/private/dashboard');
},
// ...
});
}
Our app module now looks like this
// app module
const AppRoutes: Routes = [
// ...
// add private path for dashboard
{
path: 'private',
loadChildren: () =>
import('./routes/dashboard.route').then((m) => m.AccountRoutingModule),
},
// and throw in another public route
{
path: 'projects',
loadChildren: () =>
import('./routes/project.route').then((m) => m.ProjectRoutingModule),
},
];
@NgModule({
imports: [...,
// import standalone component to be used in root
AccountStatusPartialComponent
],
// ...
})
export class AppModule {}
The status component in its simplest format should read localStorage
and decide what to do with it.
// components/private/status.partial
@Component({
selector: 'cr-account-status',
template: `
<div class="box">Email: {{ s.email }}</div>
`,
//...
})
export class AccountStatusPartialComponent implements OnInit {
s: any;
ngOnInit(): void {
// the simplest case, get localstroage and parse it, then read payload
this.s = JSON.parse(localStorage.getItem('user') || '').payload;
}
}
In our application component, we add our partial component.
// app.component.html
<header>
// ...
<cr-account-status></cr-account-status>
</header>
After clicking on login, and redirecting to dashboard, this component should reflect the new value. It doesn't. Normally. We need a way to listen and update. State management is in the house.
Auth State
The first issue we are facing is the responsiveness of the status component right after login. To fix that we are going to use RxJS state management: a behavior subject, and an observable. (This is the most repetitive pattern of auth state in Angular). Let's adjust the AuthService
to carry the state item.
// update services/auth.service
export class AuthService {
// 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(private _http: HttpClient) {}
// login method
Login(username: string, password: string): Observable<any> {
return this._http.post(this._loginUrl, { username, password }).pipe(
map((response) => {
// ...
// also update state
this.stateItem.next(retUser);
return retUser;
})
);
}
}
Our status partial component is fed in two ways:
- From
AuthService
stateitem$
observable: after user login - From
localStorage
: after page hard refresh
Our status partial should only read the observable.
// components/account/status.partial
// this is not the place to intialize state, only read state
export class AccountStatusPartialComponent implements OnInit {
status$: Observable<IUser>;
constructor(private authService: AuthService) {
}
ngOnInit(): void {
// read only the payload for this component
this.status$ = this.authService.stateItem$.pipe(map(state => state?.payload));
}
}
We need to think of another place to initialize state from localStorage
. This place must be on the root of all evil, I mean, application. We need to make sure the state is initialized immediately before any other checks. And what better place to do that than APP_INITIALIZER
token provider?
Auth State in APP_INITIALIZER
In our app module, we provide a service for this token:
// app.module
@NgModule({
// ...
// add a provider to array of providers
providers: [
{
provide: APP_INITIALIZER,
useFactory: authFactory,
multi: true,
deps: [AuthService],
}
],
})
export class AppModule {}
And for now, let's dump everything in the AuthService
(this soon shall move to its own state service)
const CheckAuth = (auth: IAuthInfo): boolean => {
// check if accesstoken exists and that it has not expired yet
// ...
// return if profile is valid
return true;
};
export const authFactory = (authService: AuthService) => () => {
// initialize auth state
// check item validity
const _localuser: IAuthInfo = JSON.parse(localStorage.getItem('user'));
if (CheckAuth(_localuser)) {
authService.SetState(_localuser);
} else {
// remove leftover
authService.RemoveState();
// and clean localstroage
localStorage.removeItem('user');
}
};
export class AuthService {
// ... add two more methods that shall move soon to auth state service
SetState(item: IAuthInfo) {
this.stateItem.next(item);
}
RemoveState() {
this.stateItem.next(null);
}
}
For the sole purpose of demonstrating that we got the sequence right, this should be enough. Now testing in our app, with no login, then login, then refresh. It works. The authentication state is always up to date.
Await remote configuration
There could be another initializer in line, let's imagine the key of the localStorage
is stored in a remote configuration file, which we load via an Http request (read about loading external configurations via http using APP_INITIALIZER). Not only is the sequence of initializers important, but the async nature of the Http request adds a slight complexity, let's fix it. We should wait for the configuration to be ready before we attempt to use it.
// auth factory change
// add second attribute for config service which has the config$ observable
export const authFactory = (authService: AuthService, configService: ConfigService) => () => {
// wait for config to be ready
configService.config$.pipe(
filter(config => config.isServed)
).subscribe({
next: config => {
// do the rest using config key
const _localuser: IAuthInfo = JSON.parse(
localStorage.getItem(config.Auth.userAccessKey)
);
if (CheckAuth(_localuser)) {
authService.SetState(_localuser);
} else {
// remove leftover
authService.RemoveState();
// and clean localstroage
localStorage.removeItem(config.Auth.userAccessKey);
}
}
}
);
}
In our provider, we add ConfigService
to the deps
array. We also can make this initializer above the configuration initializer, since it has to wait for it anyway.
We can enhance this a little: move everything into the AuthService
(or future AuthState
service), and clean up our code. The token in app module would look this dumb:
// app.module, providers
{
provide: APP_INITIALIZER,
// dummy factory
useFactory: () => () => {},
multi: true,
// injected depdencies, this will be constructed immidiately
deps: [AuthService]
},
Now we can move the logic to AuthService
(or our future AuthState
)
// auth.service constructor can have everything we need
constructor(private configService: ConfigService) {
this.configService.config$.pipe(
filter(config => config.isServed)
).subscribe({
next: config => {
// ...
}
);
}
Login Resolve
A login resolve is to redirect user away from the login page, if user is already logged in.
// services/login.resolve
@Injectable({ providedIn: 'root' })
export class LoginResolve implements Resolve<boolean> {
// bring in Auth future State service
constructor(private authState: AuthService, private router: Router) { }
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
// listen to state changes
return this.authState.stateItem$.pipe(
// if item exists redirect to default
// later we will enhance this with a redirect url
map(user => {
if (user) {
this.router.navigateByUrl('/private/dashboard');
}
// does not really matter, I either go in or navigate away
return true;
})
);
}
}
// in public route:
{
path: 'public/login',
component: PublicLoginComponent,
resolve: {
ready: LoginResolve,
},
},
There is no magic there, since the AuthService
used is ready at this point.
PS: I am using
AuthService
and calling itauthState
on purpose, we'll touch on it later
Auth Guard
This is to guard private routes and redirect back to login if user is not logged in.
// services/auth.guard
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate, CanActivateChild {
// bring in our Auth future State
constructor(private authState: AuthService, private _router: Router) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
return this.secure(route);
}
canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
return this.secure(route);
}
private secure(route: ActivatedRouteSnapshot | Route): Observable<boolean> {
// listen to auth state
return this.authState.stateItem$.pipe(
map(user => {
// if user exists let them in, else redirect to login
if (!user) {
this._router.navigateByUrl('/login');
return false;
}
// user exists
return true;
})
);
}
}
Before we go on, let me inspect another choice that would have been tempting, just to demonstrate how terrible it is.
Inject in root component
There is a slight difference for this one, it really is late enough the configuration is definitely be loaded by then. But the login resolve and auth guard are usually faster than root component, so this might be a bit too late. Let's inspect.
// app.component
export class AppComponent {
constructor(
private authService: AuthService
) {
// initiate state of auth
const _localuser: IAuthInfo = JSON.parse(
localStorage.getItem('key here from config')
);
if (this.authService.CheckAuth(_localuser)) {
this.authService.SetState(_localuser);
} else {
this.authService.RemoveState();
localStorage.removeItem('key here from config');
}
}
}
The configuration is guaranteed to be loaded at this point. We can also place this logic in the AuthService
constructor, but we still need to inject it in the app root component. I don't know about you, but I don't like injecting something I am not going to use!
Be careful as you test different settings, the
authService
injection may happen elsewhere and ruin your conclusions, always make sure there is no other injection for most guaranteed results.
Now running with LoginResolve
, and AuthGuard
, I can immediately spot the problem. They are too early. Refreshing the URL /login
will not redirect to dashboard even if the user is found. To fix that, the logic must be in the AuthService
constructor, which is --- messy.
Logout
Logout is as simple as removing state, and localStorage
. The Logout feature itself can be placed anywhere it is needed, but the true user-triggered logout, needs an extra redirect to close the cycle. We already have the logic in AuthService
we just need to move it to its own method.
// services/auth.service
// add another method, and use it when needed
Logout() {
// remove leftover
this.RemoveState();
// and clean localstroage, you can use static configuration at this point
localStorage.removeItem(ConfigService.Config.Auth.userAccessKey);
}
PS: at this point, the configuration is loaded, you can use static configuration for the key.
In our root component I will add a link that calls a logout function, which uses the AuthService
Logout, and then reroutes:
// app.component
export class AppComponent {
constructor(private authService: AuthService, private router: Router) {}
Logout() {
this.authService.Logout();
// also reroute to login
this.router.navigateByUrl('/public/login');
}
}
// in HTML templte: (click)="Logout()"
Use cases
Let us add the access token to our API Http calls, and deal with a 401. That and a few other use cases will have to wait till next week. 😴
Thank you for reading this far, did you check your injections?
Top comments (0)