When loading a localStorage-authenticated page, the SSR version kicks in with no access to the token. An easy fix involves setting a cookie on the server of the front end.
Before we move on to further features, let’s test our application in an SSR environment. Let's digress a bit to know which of the scenarios of Angular server side rendering we want to handle authentication for.
anchorTo SSR, or not to SSR
There are multiple reasons to do SSR, one of them is performance gain. That reason alone is not enough to go fully fledged SSR as there are usually better ways to load content faster for users, like prerendering static content, or lazy loading different modules.
Another reason to SSR is searchability, or SEO, and shareability. Some bots still do not run JavaScript to load content, thus need a static server-side generated copy. When sharing a link on social networks, the bot that previews the content does not run JavaScript either, so a prerendered static version or SSR is indeed helpful.
Serving limited apps for limited devices is also a use case mentioned on Angular docs website. If the main target audience is of such limited devices, we are better off without Angular. This is a case where the Login feature itself is server-side. I am not going to speak of this case.
Of all those scenarios, content that sits behind authentication wall falls under one of the following shapes:
- Protected routes
- Public routes with user context: like displaying the user avatar on public routes.
- Public routes with API call returning user context. like favored tweets of public accounts.
anchorProtected routes
These routes need not be rendered on server for SEO purposes. For users however, the pages will flicker if not handled on server. When routing to a protected route with server side rendering, it all seems on the surface to be working fine, since the initial server load would deny user entrance and reroute to login page, then hydration occurs, and LoginResolve
kicks in with a new value fed from localStorage
, and that’s when it reroutes back to the protected route. This will look like a flicker.
anchorPublic routes with user context
Again, user information need not be rendered on server for SEO purposes. In this case, hydration will display the new value for client, if we properly use async
pipe to listen to AuthState
Observable stateItem$
. This use-case practically does not need intervention.
anchorPublic routes with API user related content
If an API request is made on a public route, that returns partial data related to the user logged in, like favored tweets of a public profile, the authentication header needs to be sent along. In most cases the API call happens fast enough on the server, the result is then saved in JavaScript cached object, which is then populated after hydration. This result set is public, and will not contain contextual information, nor will there be another API call. The user will see a list of tweets without knowing which ones he or she favored. This is frustrating for the user.
anchorSolutions
There are multiple ways I am sure, some of them are quite complicated, like calling a different API in JavaScript to handle user related content, I’ve seen solutions that involve tapping into the TransferState
, solutions that isolated the login page on its own project, and other solutions that created a different provider for localStorage
. Today, I am going with a very simple solution: save cookie in session
.
async
pipe with proper Http requests on SSR, you’d be better avoid standalone provideHttpClient
and use regular HttpClientModule
Things to remember as we fix this:
- The Login form is always client-side
- The use-cases are when loading route from the server, not when rerouting on client-side
- We only need to save the access token, but will save other information as well
- This is not a discussion of cookie security, there are ways to keep the cookie secure, we will try our best and keep an eye on security measures
anchorWorking backwards
Let’s begin with the end in mind. There are two main places where we need the user info available to fix the flicker, and the API call: AuthGuard
, and HttpInterceptor
. In the former we need the existence of the user, and later we need the roles if they exist. In the latter we only need the access token, but it must be checked against the expiration date.
anchorSave cookie in session
Let’s revisit our AuthState
and update our code to manipulate the cookies, right when it touches on localStorage
// services/auth.state
private _SaveUser(user: IAuthInfo) {
localStorage.setItem('user', JSON.stringify(user));
// also save cookie here
this._SetCookie(user);
}
private _RemoveUser() {
localStorage.removeItem('user');
// also delete cookie
this._DeleteCookie();
}
private _GetUser(): IAuthInfo | null {
// redo this, if on SSR, read cookie
if (onServer()) {
// read cookie here from express js
}
const _localuser: IAuthInfo = JSON.parse(localStorage.getItem('user'));
if (_localuser && _localuser.accessToken) {
return <IAuthInfo>_localuser;
}
return null;
}
Let’s create those two functions to see what we need
// auth.state
// I am setting everything in cookie, but I would be selective
// in real life and save only the needed parts:
// accessToken, roles, and expiresAt, and maybe refresh token
// which will make the process easier
private _SetCookie(user: IAuthInfo) {
// save cookie with user, be selective in real life as to what to save in cookie
let cookieStr = encodeURIComponent('CrCookie') + '=' + encodeURIComponent(JSON.stringify(user));
// use expiration tp expire the cookie
const dtExpires = new Date(user.expiresAt);
cookieStr += ';expires=' + dtExpires.toUTCString();
cookieStr += ';path=/';
// some good security measures:
cookieStr += ';samesite=lax';
// when in production
// cookieStr += ';secure';
// be strong:
document.cookie = cookieStr;
}
private _DeleteCookie(): void {
// void accessToken but more importantly expire
this._SetCookie({accessToken: '', expiresAt: 0});
}
In order to read the cookie, we need to do that only on server side. Meaning, when Request
is injected and exists. To do that, we need to inject the Request that is provided in the ngExpressEngine
renderer, according to the documentation:
// this is how we use nguniversal express-engine, we normally provide the server side request
app.get('/**/*', (req: Request, res: Response) => {
res.render('../dist/index', {
req,
res,
});
});
Then in our AuthState
service, let’s inject the REQUEST
token
// auth.state
import { REQUEST } from '@nguniversal/express-engine/tokens';
import { Request } from 'express';
@Injectable({ providedIn: 'root' })
export class AuthState {
constructor(
// ...
// inject REQUEST token
@Optional() @Inject(REQUEST) private request: Request
) {
// ...
}
Now we can adapt the _GetUser
method to read from cookie if on server:
// auth.state
private _GetUser(): IAuthInfo | null {
// if on server
if (this.request) {
const _serverCookie = this.request.cookies['CrCookie'];
if (_serverCookie) {
try {
return JSON.parse(_serverCookie);
} catch (e) {
// silence
}
}
}
// else read from localStorage
const _localuser: IAuthInfo = this.localStorage.getItem('user');
if (_localuser && _localuser.accessToken) {
return <IAuthInfo>_localuser;
}
return null;
}
StackBlitz project is not set up to be a NodeJs application, so you’re gonna have to take my word for it. It works. The protected routes loaded without flickering, and the API was properly mounted with the access token. You can add extra features like SameSite:strict
and Secure
attribute to further secure the token.
anchorPushing the envelope: set cookie on server
There are quite a lot of good measures to keep the cookie safe with the JavaScript available attributes. But if we can’t sleep at night knowing that our cookie can be read via JavaScript, let’s try a new way. Let’s create another request, to our local front end server, specifically to set the cookie in NodeJs.
anchorFilter out local calls
For that to work, we need to first tap into the HttpInterceptor
, and filter out this specific call, since it is not an API call. Then we let it pass with the correct URL.
// services/http
// in interceptor filter out the local call
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (req.url.indexOf('localdata') > -1) {
// this call is local, thus the url is relative to this same server
// if your server cannot handle relative calls, prefix it with the proper
// url, like https://my.domain/ + url
return next.handle(req);
}
// ...
}
anchorExpress Route
The NodeJs that fired up our server could be written in server.ts
(like they do in Angular docs), or you could have written your own server, like we’ve learned to do in Isolating the server. In any case, the express route needs to do the following:
// express routes, anywhere they are
app.post('/localdata/setsession', (req, res) => {
// read req and save cookie
const body = req.body;
// notice the HttpOnly
res.cookie(body.cookieName, JSON.stringify(body.auth),
expires: new Date(body.auth.expiresAt),
sameSite: 'lax',
// when in production set secure
secure: true,
httpOnly: true,
});
// silence is gold
res.send(true);
});
anchorLogin request
In our login response, we are going to add another silent http request for our localhost/setsession
right after service Login
.
// in services/auth.service
SetLocalSession(user: IAuthInfo): Observable<IAuthInfo> {
// prepare the information to use in the cookie
// basically the auth info and the cookie name
const data = PrepSetSession(user);
// notice the relative url, this is the path you need to setup in your server
return this.http.post('localdata/setsession', data).pipe(
map((response) => {
// return user as is to facilitate chaining
return user;
})
);
}
// switch map Login:
Login(username: string, password: string): Observable<any> {
return this.http.post(this._loginUrl, { username, password }).pipe(
map((response) => {
const retUser: IAuthInfo = NewAuthInfo((<any>response).data);
return this.authState.SaveSession(retUser);
}),
// here switch map to call the local setter
switchMap((user) => this.SetLocalSession(user))
);
}
// in services/auth.model, prepare
export const PrepSetSession = (auth: IAuthInfo): any => {
// in real life, return only information the server might need
return {
auth: auth,
cookieName: 'CrCookie', // this better be saved in external config
};
};
I am testing this on my localhost, in development it will throw an error, which is okay.
Debugging on Angular universal with a separate express server could drive one crazy, the easiest way is to setoptimization
to false inangular.json
server configuration, and open the generated scripts, to debug directly.
After building, and logging in, I can see the CrCookie
being set. The test is whether I can read it with document.cookie
. It indeed does not return the cookie information.
Now to see if Angular is reading it when it needs it, let’s turn off JavaScript, and refresh a protected route after running the server. It should not redirect to login. Indeed, it does not.
anchorLogout and refresh token
To tighten loose-ends, there are two more places where our local server needs to be called, after a refresh token, and upon logout. The refresh token request can be piped to our newly created method: SetLocalSession
, and the logout link is a new silent call. Here they are:
// logout button click
logout() {
// ...
this.authService.Logout().subscribe();
}
// services/auth.service
Logout(): Observable<boolean> {
// logout locally
const data = PrepLogout();
return this.http.post('localdata/logout', data).pipe(
map((response) => {
return true;
})
);
}
// in services/auth.model
export const PrepLogout = (): any => {
return {
cookieName: 'CrCookie'
}
}
The express route for logout would simply clear the cookie with the same options
// on server, express route for logout
app.post('/localdata/logout', (req, res) => {
// read req and save cookie
const body = req.body;
res.clearCookie(body.cookieName, {
sameSite: 'lax',
secure: true,
httpOnly: true,
});
res.send(true);
});
This is a silent call. If it fails it is not a big deal because any interactive calls to API will happen on client side, which does not have any access tokens.
As for refresh token, I changed the returned value to be IAuthInfo
instead of Boolean
to keep things simple.
// services/auth.service
RefreshToken(): Observable<IAuthInfo> {
return (
this.http
.post(this._refreshUrl, { token: this.authState.GetRefreshToken() })
.pipe(
map((response) => {
// ...
// return user
return user;
}),
// then switch map and set local session
switchMap((response) => this.SetLocalSession(response))
)
);
}
anchorOverkilling it
If you are still concerned, don’t use cookies, nor localStorage
. Use sessions that invalidate at the end of the browser session. Better yet, don’t use Angular for authentication, isolate the login path to be served by the server. But remember, all precautions fall apart in front of a committed hacker, so stay calm and keep walking. Personally, I do not like the extreme nature of the second solution, most web apps are fine with simple cookie settings, coupled with API white-listing.
anchorMore features
There are more things to do around authentication, roles, forgot password, and change password are some examples. I will save these for future articles. This article took a little too long to come out, because I was traveling. Forgive me.
Thanks for reading this far though, I hope you forgave me.