Picking up as we move on, there are a couple more features we want to implement to close the circle. Today we will talk about redirect URLs. Then let's rant about one of the recurring scenarios in SPA authenticated apps.
And of course, the StackBlitz project,
Redirect back to where user came from
A better user experience is to return the user to their last route before they were expelled for the lack of authentication. It is a simple property that keeps track of the route, in the AuthGuard
itself.
Sub ranting about where to place that property
There is no silver bullet. Initially it looks like a property of the IAuthInfo
model, since it will be treated the same way, but it isn't. This is a strictly client side property.
We might also think that the private methods that deal with localStorage
in AuthState
service, should live in their own service, and we might add this new property to it. That would make sense, but it is an overkill.
Another overkill is to treat this property as part of a state. The places it is set and retrieved are already clear calls, and live around other state managed elements. This should be straightforward.
I choose a simple solution. This property needs only a getter and a setter, to save in localStorage
and retrieve from it. And it does that in combination with AuthState
. So I am going to add a public getter and a public setter for the redirectUrl
in AuthState
.
// services/auth.state service
// update to create a new property getter and setter
get redirectUrl(): string {
return localStorage.getItem('redirectUrl');
}
set redirectUrl(value: string) {
localStorage.setItem('redirectUrl', value);
}
Save URL snapshot
The obvious location to save a redirect URL is in the AuthGuard
since it knows the route the user is trying to access last.
// services/auth.guard
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
// save snapshot url
this.authState.redirectUrl = state.url;
return this.secure(route);
}
canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
this.authState.redirectUrl = state.url;
return this.secure(route);
}
CanMatch (CanLoad) URL segments
If we implement CanMatch
(or the deprecated CanLoad
) method, there is no RouterStateSnapshot
provided, rather UrlSegments[]
. To build the path from it, we reduce:
// canMatch implementation if needed
canMatch(route: Route, segments: UrlSegment[]): Observable<boolean> {
// create the current route from segments
const fullPath = segments.reduce((path, currentSegment) => {
return `${path}/${currentSegment.path}`;
}, '');
this.authState.redirectUrl = fullPath;
return this.secure(route);
}
Redirect
To use it, there are a couple of places that come to mind: immediately after login, and in loginResolve
.
// components/public/login
// inject authState
this.authService.Login(_user.username, _user.password)
.subscribe(
{
next: result => {
// placing urls in configuration is a good practice, leaving it to you
this.router.navigateByUrl(this.authState.redirectUrl || '/private/dashboard');
}
}
);
// in services/login.resolve
// LoginResolve class
if (user) {
this.router.navigateByUrl(this.authState.redirectUrl || '/private/dashboard');
}
There aren't enough routes in our StackBlitz project, you're gonna have to take my word for it. Notice that when a user logs out intentionally by clicking on the logout button, you should choose how you want to treat that depending on your project requirements. Some projects you want to maintain the redirect URL. But in most cases, you want the user to be relogged into their dashboard. So after logout click, let's remove the redirectUrl
// app.comonent
export class AppComponent {
Logout() {
//...
// also remove redirect
this.authState.redirectUrl = null;
}
}
Account service extra details
A recurring scenario in apps that connect to authentication servers, is the need to call the local server for extra account details. The AccountService
is a separate service that is built on top of the AuthService
with the purpose of getting extra details about the user from our server (as opposed to the authentication server). Depending on the type of project we're serving, there are a lot of different solutions. The one key element that I keep reminding myself of is:
In any of the injected services in the Http interceptor, no Http requests can be made in the constructor.
I am not going to dive into the different types of projects, but requirements are generally around one of the following:
1. Extra details can be saved
If extra information is of user details, like name, position, and title, it can be saved in localStorage
. This is treated like the AuthState
. What I would do is enrich the payload
property of the IAuthState
, then pipe the Login
request to a second request to add the missing information:
// in components/public/login
this.authService.Login(_user.username, _user.password).pipe(
// pipe a second call to get user extra account details
switchMap(result => this.accountService.GetAccount(result)),
catchError(error => this.toast.HandleUiError(error)),
) // ...
Then update payload
and save the session as we normally do:
// hypothetical account.service
GetAccount(auth: IAuthInfo): Observable<IAccount> {
//...
return this.http.get('/account/user').pipe(
map(response => {
// map to a new model
const resUser: IAccount = Account.NewInstance(<any>response);
// assign the payload, you might want to be picky about what to assign
const _auth = {...auth, payload: resUser};
// then save session again
this.authState.SaveSession(_auth);
return resUser;
})
);
}
2. Extra details can sit stale for the duration of the session
Information you want to notify the user about, but you do not want to bother them about changes. A simple example is whether this user is a new visitor across all devices.
In such cases, we want to get the information when the user logs in, and try to get it every time the user launches the application. Not too early (wait for authentication), and not too late (before other modules and routing occurs). But we only need to try once. That can be accomplished in application root, after checking AuthState
: when it becomes available, we make the call and set Account state. This is by far the most logical and safest way.
// account.service
// with one property, whether user is new across devices
export interface IAccount {
id: string;
newUser?: boolean;
}
@Injectable({ providedIn: 'root' })
export class AccountService {
constructor(private _http: HttpClient) {}
GetAccount(): Observable<IAccount> {
return this._http.get('/account').pipe(
map((response) => {
return Account.NewInstance(response);
})
);
}
// also a SaveAccount to set newUser flag to false
// would leave that to you
}
I am assuming by now you know how to set your own RxJS based state, and I will not dive into it too much. Here is what the application root would look like
// app.component
export class AppComponent {
constructor(
private authState: AuthState,
private accountService: AccountService
) {
// in here, tap into authstate to watch when it becomes available,
// and request more info from account
// do it just once throughout a session
this.authState.stateItem$
.pipe(
first((state) => state !== null),
switchMap((state) => this.accountService.GetAccount())
)
.subscribe({
next: (response) => {
console.log('received', response);
// here you should set account state if you have it
// then you can use it elsewhere
// this.accountState.SetState(response);
},
});
}
Testing. Routing. Logging out. And in again. Refreshing. I can see that the account service is called once per session, and immediately after login. When use logs out however, the state is not invalidated. Now the choice is yours. You can keep it like that for non sensitive information (there is nothing saved in localStorage
, it's just a JS state object). Or you can filter
instead of first
in application root, to make sure it gets called every time the AuthState
changes.
Authorization: user roles in Auth guards
Let's fix another use case where the auth guard is dependent on user role. To make this easy we need to let this all sink in, and wait till next episode. 😴
Thank you for reading this far, I hope you learned something today. Anything?