In theory it appears pretty easy to build the browser side of an application using OpenId Connect. Actually implementing it in a real world application has usually proven to be more difficult than that.
I would like to show you how to solve some of the real world problems beside the basic setup. First let me describe what I want to build:
- a single page app with Angular
- routing via the hash part of the url
- a forced login via OIDC
I will write about the following problems within building that app:
- What library to choose?
- Where and when to initialise that library?
- Angular and hash based routing
- How to handle errors?
- Some smaller miscellaneous problems
A complete demo application accompanies this article and can be found here. The master branch contains the project with all the methods of this article applied. But there are also before and after tags for the code stages in most of the sections in this article.
To run the application you need to
- start a keycloak docker container via the command
docker run -p 8080:8080 gitreelike/angular-with-oidc-keycloak
- clone the following repo
git clone https://github.com/gitreelike/angular-oidc.git
- from the directory client in the repository run
npm start
The application will the be available on http://localhost:4200 . You can always login as the user test with the password test. In case you want to login to the keycloak admin ui you can use admin/admin.
What library to choose?
Because of OpenId’s complexity we want to have it as much covered by a library as possible.
Because we are using Angular it would be really nice to have a lib that integrates well into it. Usually I recommend having a close look at libraries provided by the authentication provider that is used. But integration with Angular appears to be sparse. There are more or less two OIDC libraries for Angular that are authentication provider agnostic:
- https://www.npmjs.com/package/angular-oauth2-oidc
- https://www.npmjs.com/package/angular-auth-oidc-client
The second one has some strange quirks. E.g. you have to be subscribed to observables at a certain point in time or else you will miss important events and objects. That did not feel right and led to convoluted code. The other lib worked comparatively well. Code-wise I am not a fan of both libraries but angular-oauth2-oidc has not disappointed me with missing features, strange usage or bugs so far. So we will be using it here.
Where and when to initialise that library?
Git tag after: one
This sounds like a strange question. Just do what the libraries documentation tells you right? Well yes but sometimes not. Let me explain a few possible ways to do this:
- Do all OIDC work in main.ts before Angular gets initialised.
- In the constructor or ngOnInit of your AppComponent.
- In ngDoBootstrap of your AppModule.
- will do the job but you won’t get many of the benefits of using Angular for this code. This also will not work with Angular specific libraries.
- Sounds good at first but there are drawbacks. You may already want to do authenticated requests to your backend at this point in time e.g. This is problematic also with deep links.
- By using Angular services like the HttpClient, your logic gets executed before nearly all of your application, deep links work well. To be honest I haven’t encountered any downside to this in real life so far.
So how does 3. look like? We will have an AuthService in an AuthModule that does all the magic. It will be called from the AppModule’s ngDoBootstrap method.
@NgModule({ declarations: [ AppComponent ], imports: [ AuthModule, BrowserModule, AppRoutingModule, HttpClientModule ], providers: [], entryComponents: [AppComponent] }) export class AppModule implements DoBootstrap { constructor(private authService: AuthService) { } ngDoBootstrap(app: ApplicationRef): void { this.authService.bootstrapAuthService() .then(() => { app.bootstrap(AppComponent); }) .catch(error => { console.error(`[ngDoBootstrap] Problem while authService.bootstrapAuthService(): ${JSON.stringify(error)}`, error); }); } }
The first change to the AppModule generated by Angular CLI is using the entryComponents property instead of the bootstrap property of the @NgModule annotation. This tells Angular that we will do the bootstrap ourselves and which components might be used to enter the application. The second change is to have the AppModule implement the DoBootstrap interface. In the ngDoBootstrap method we call our service which initializes the angular-oauth2-oidc library. If it succeeds it bootstraps the application with the AppComponent, if not we have to do some error handling (see later).
AuthService.bootstrapAuthService() mainly does what the library documentation tells us (we’re using the implicit flow here).
@Injectable() export class AuthService { constructor(private oauthService: OAuthService) {} async bootstrapAuthService(): Promise { const authConfig: AuthConfig = await this.buildAuthConfig(); this.oauthService.configure(authConfig); this.oauthService.tokenValidationHandler = new JwksValidationHandler(); await this.oauthService.loadDiscoveryDocument(); await this.oauthService.tryLoginImplicitFlow(); if (!this.oauthService.hasValidAccessToken()) { this.oauthService.initImplicitFlow(); } else { this.oauthService.setupAutomaticSilentRefresh(); } } private async buildAuthConfig(): Promise { const currentLocation = window.location.origin + window.location.pathname; return new AuthConfig({ issuer: 'http://localhost:8080/auth/realms/example', clientId: 'angular-with-oidc', scope: 'openid profile', redirectUri: currentLocation, silentRefreshRedirectUri: `${currentLocation}${slashIfNeeded}silent-refresh.html` }); } }
Starting from here you should be able to use the libraries’ OAuthService to retrieve access tokes, id tokens, maybe build a logout button and be happy and done – right? Well yes… that is until you deploy this and people start telling you:
- every 15 minutes the application resets to the AppComponent
- deep links don’t work properly (at least if the route contains parameters)
- missing error feedback
- probably some more quirks
Actually you’re not done at all. The first two are due to url hash problems and are covered in the next section and error handling in the one after that.
Angular and hash based routing
Git tag before: one
Git tag after: two
OpenId makes a lot of use of the hash part of our browser location and so does Angular hash based routing which we chose to use because we don’t do Angular server side rendering:
@NgModule({ imports: [RouterModule.forRoot(routes, { useHash: true })], exports: [RouterModule] })
As expected those two will collide sooner or later. Our library documentation does actually cover that as follows:
@NgModule({ imports: [RouterModule.forRoot(routes, { useHash: true, initialNavigation: false })], exports: [RouterModule] })
and
private async buildAuthConfig(): Promise { const currentLocation = window.location.origin + window.location.pathname; return new AuthConfig({ issuer: 'http://localhost:8080/auth/realms/example', clientId: 'angular-with-oidc', scope: 'openid profile', redirectUri: currentLocation, silentRefreshRedirectUri: `${currentLocation}${slashIfNeeded}silent-refresh.html`, clearHashAfterLogin: false }); }
Now deep links don’t work at all anymore (1), also the open id stuff in location.hash is left over (2) and last but not least no app at all on the screen (3)? Let us take care of all 3 problems:
@Injectable() export class AuthService { constructor(private oauthService: OAuthService, private router: Router) {} async bootstrapAuthService(): Promise { const authConfig: AuthConfig = await this.buildAuthConfig(); this.oauthService.configure(authConfig); this.oauthService.tokenValidationHandler = new JwksValidationHandler(); await this.oauthService.loadDiscoveryDocument(); await this.oauthService.tryLoginImplicitFlow( /////////////////// // solution for (1) and (2) /////////////////// { onTokenReceived: info => { window.location.hash = info.state; }, customHashFragment: isAngularRouteHash() ? '' : window.location.hash } ); if (!this.oauthService.hasValidAccessToken()) { /////////////////// // solution for (1) and (2) /////////////////// const state = isAngularRouteHash() ? window.location.hash : ''; this.oauthService.initImplicitFlow(state); } else { this.oauthService.setupAutomaticSilentRefresh(); /////////////////// // solution for (3) /////////////////// this.router.initialNavigation(); } } } function isAngularRouteHash(): boolean { const hash = window.location.hash; return hash.startsWith('#/') || hash.startsWith('#%2F'); }
How to handle errors?
Git tag before: two
Git tag after: three
You hopefully have not seen any errors so far in this example. But let me assure you, they will happen. The error handling of OIDC sadly does not make a difference between technical error (like a wrong client-id) and business workflow situations (like a user refusing consent to a scope).
But our main problem is that we may not have bootstrapped the angular app yet when we catch an error. So what do we do?
We can simply add a component that displays the error and bootstrap with that component. To do that we first need to save the error so that the component can later retrieve and display it:
@Injectable() export class AuthService { private errorDuringBootstrap: any = undefined; constructor(private oauthService: OAuthService, private router: Router) {} async bootstrapAuthService(): Promise { try { // snip } catch (e) { this.errorDuringBootstrap = e; throw e; } } public get bootstrapError(): any { return this.errorDuringBootstrap; } }
Then build a component that displays that error:
Component class:
@Component({ selector: 'app-root', templateUrl: './error.component.html', styleUrls: ['./error.component.css'] }) export class ErrorComponent implements OnInit { error: any = undefined; constructor(private authService: AuthService) { } ngOnInit() { if(this.authService.bootstrapError) { this.error = JSON.stringify(this.authService.bootstrapError, undefined, 4); } } }
Html (sorry the source code plugin doesn’t like Angular’s Html Templates):
<div *ngIf="error"> <h1>There was an error during login:</h1> <textarea style="width: 500px; height: 500px;" [value]="error"></textarea> </div> <div *ngIf="!error">No login errors.</div>
Did you notice the selector ‘app-root’? That is a sneaky hack to keep this app simple. Depending on your application this will likely look different in your app.
The last thing to do is to bootstrap the app with the new component in the error case:
@NgModule({ declarations: // snip imports: // snip providers: // snip entryComponents: [AppComponent, ErrorComponent] }) export class AppModule implements DoBootstrap { constructor(private authService: AuthService) { } ngDoBootstrap(app: ApplicationRef): void { this.authService.bootstrapAuthService() .then(() => { app.bootstrap(AppComponent); }) .catch(error => { console.error(`[ngDoBootstrap] Problem while authService.bootstrapAuthService(): ${JSON.stringify(error)}`, error); app.bootstrap(ErrorComponent) }); } }
Some smaller miscellaneous problems
Git tag before: three
Git tag after: four
This app is actually usable now. There is still some smaller stuff left here that I will point out but not go into detail of every single issue:
- The OIDC configuration is hard coded into our app. This is bad and likely fails if you’ve got multiple stages.
- oauthService.initImplicitFlow() ultimately redirects the user to the authentication provider but does so asynchronously and the app still loads in the background which can lead to error messages shortly showing up.
- Switching users is not possible – we may need a logout button.
Taking care of 1. seems simple at first. But if you use an http interceptor to add an authentication token, the interceptor may also intercept your request for the OIDC configuration – at which point you don’t have the token yet. You will have to either filter out this request in your interceptor or create your own HttpClient without interceptors:
@Injectable() export class AuthService { private http: HttpClient; constructor(private oauthService: OAuthService, private router: Router, httpBackend: HttpBackend) { this.http = new HttpClient(httpBackend); } private loadFrontendConfig(): Observable { return this.http.get(OIDC_CONFIG_URL) } }
Neither of these solutions feels right. I personally think that Angular should support multiple interceptor chains. This is especially important when calling multiple APIs. You may not want to expose the same token to each API. Comment if you’ve got ideas on this.
Issue 2 may not be an issue for you. But if your app starts requesting private resources right away it probably will be. Since we know that oauthService.initImplicitFlow() will eventually navigate to the authentication provider. I think that while the following solution is hacky there are not that many other options:
this.oauthService.initImplicitFlow(state); // Stop the boot process of the angular app as the user will be redirected to the auth provider by the above statement. await new Promise(() => {});
Issue 3 I will leave this up to you. You will have to think about local logout vs. single logout at the authentication provider and in the latter case also about a post logout redirect url. This is actually more complicated than “just a logout button”.
Summary
As you’ve seen there is more to setting up OIDC in a frontend application than you might think.
Thank you for reading this article. I hope it has or will prove itself helpful to you. Feel free to comment on this post or email me via immanuel.sims (at) akquinet.de.
Side note: While some of the code shown here might be better off as a part of the angular-oauth2-oidc library, other code from here probably doesn’t belong there. I have not made my mind up about what belongs where yet.