In a previous post, I wrote about wanting to add support for Single Sign On to Serendipity and the steps I followed to launch and configure Keycloak.

There are several certified OpenID Connect (OIDC) implementations that provide OIDC and OAuth 2.0 protocol support for browser-based applications.

In this post, I thought I'd take a look at oidc-client.

Install oidc-client

I installed oidc-client using npm:

npm install -P oidc-client

Note: Serendipity's Auth library provides an Authentication interface and includes a placeholder AuthService and AuthGuard.

Create an OIDC Auth Library

I used the Angular CLI to generate the scaffolding for a new library:

ng generate library auth-oidc --prefix=auth

Create an OIDC Auth Service

I generated the scaffolding for a new service:

ng generate service services/auth/auth --project=auth-oidc

I updated the OIDC Auth service as follows:

import { Inject, Injectable } from '@angular/core';
import { Router } from '@angular/router';

import { BehaviorSubject } from 'rxjs';

import { UserManager, UserManagerSettings, User } from 'oidc-client';

import { OidcConfig } from '../../models/models';
import { OidcConfigService } from '../config.service';

import { Auth } from 'auth';

import { LoggerService } from 'utils';

@Injectable({
  providedIn: 'root'
})
export class OidcAuthService extends Auth {

  private authState$ = new BehaviorSubject(false);

  private authService: UserManager;
  private user: User = null;

  constructor(@Inject(OidcConfigService) private config: OidcConfig,
              private router: Router,
              private logger: LoggerService) {

    super();

    const oidcConfig: UserManagerSettings = {
      authority: this.config.oidc.issuer,
      client_id: this.config.oidc.clientId,
      redirect_uri: this.config.oidc.redirectUri,
      post_logout_redirect_uri: this.config.oidc.postLogoutRedirectUri,
      response_type: this.config.oidc.responseType,
      scope: this.config.oidc.scope,
      filterProtocolClaims: this.config.oidc.filterProtocolClaims,
      loadUserInfo: this.config.oidc.loadUserInfo
    };

    this.authService = new UserManager(oidcConfig);

    this._isAuthenticated().then(state => {

      this.authState$.next(state);

      this.authState$.subscribe((authenticated: boolean) => {

        this.authenticated = authenticated;
        this.accessToken = '';

        if (this.authenticated) {
          this.setAccessToken();
        }

      });

    });

  }

  public isAuthenticated(): boolean {
    return this.authenticated;
  }

  public getAccessToken(): string {
    return this.accessToken;
  }

  public getIdToken(): string {
    return this.idToken;
  }

  private setAccessToken() {
    this.accessToken = this.user.access_token;
  }

  public async loginWithRedirect(): Promise<void> {
    return this.authService.signinRedirect();
  }

  public async handleRedirectCallback(): Promise<void> {

    this.user = await this.authService.signinRedirectCallback();

    this.authenticated = await this._isAuthenticated();

    this.authState$.next(this.authenticated);

    this.router.navigate(['/']);
  }

  public logout(returnUrl: string) {

    this.authState$.next(false);

    this.authService.signoutRedirect();
  }

  //
  // Private methods
  //

  private async _isAuthenticated(): Promise<boolean> {
    return this.user !== null && !this.user.expired;
  }

}

oidc-client provides a UserManager class that we can use to manage Authentication (AuthN) and Authorization (AuthZ) tasks. In the OIDC Auth service's constructor we create a new UserManager and provide a configuration object:

    ...
    
    const oidcConfig: UserManagerSettings = {
      authority: this.config.oidc.issuer,
      client_id: this.config.oidc.clientId,
      redirect_uri: this.config.oidc.redirectUri,
      post_logout_redirect_uri: this.config.oidc.postLogoutRedirectUri,
      response_type: this.config.oidc.responseType,
      scope: this.config.oidc.scope,
      filterProtocolClaims: this.config.oidc.filterProtocolClaims,
      loadUserInfo: this.config.oidc.loadUserInfo
    };

    this.authService = new UserManager(oidcConfig);

Note: The configuration object is derived from the Angular environment:

  oidc: {
    clientId: 'serendipity-pwa',
    filterProtocolClaims: true,
    issuer: 'http://localhost:10001/auth/realms/development',
    loadUserInfo: true,
    postLogoutRedirectUri: 'http://localhost:4200/',
    redirectUri: 'http://localhost:4200/authorization-code/callback',
    responseType: 'code',
    scope: 'openid profile address email phone offline_access'
  }

These are the settings we configured in the previous post.

Create an OIDC Auth Guard

I generated the scaffolding for a new guard:

ng generate guard guards/auth/auth --project=auth-oidc

I updated the OIDC Auth guard as follows:

...

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

  constructor(private router: Router,
              private authService: AuthService) {}

  canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {

    if (this.authService.isAuthenticated()) {
      return true;
    }

    this.router.navigate(['/login'], { queryParams: { returnUrl: state.url }});

    return false;
  }

}

If the user isn't logged in then they will be routed to the component associated with the /login path.

Note: Serendipity's Auth library provides an Authentication interface and includes a placeholder AuthService and AuthGuard. The AuthService and AuthGuard implementation's should be replaced using Angular's DI system, for example:

import { HTTP_INTERCEPTORS } from '@angular/common/http';

import { AuthGuard, AuthService } from 'auth';

import { OidcAuthGuard } from './guards/auth/auth.guard';
import { OidcAuthService } from './services/auth/auth.service';
import { AuthInterceptor } from './http-interceptors/auth-interceptor';

export const authProviders = [
  {
    provide: AuthGuard,
    useClass: OidcAuthGuard
  },
  {
    provide: AuthService,
    useClass: OidcAuthService
  },
  {
    provide: HTTP_INTERCEPTORS,
    useClass: AuthInterceptor,
    multi: true
  }
];

Create a Login Redirect Component

I used the Angular CLI to generate the scaffolding for a new component:

ng generate component components/login-redirect --project=auth-oidc

I updated the Login Redirect component as follows:

...

@Component({
  template: ``,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class LoginRedirectComponent implements OnInit {

  constructor(private authService: AuthService,
              private logger: LoggerService) {
  }

  ngOnInit() {
    this.authService.loginWithRedirect();
  }

}

The Login Redirect component is associated with the /login path (see below).

Create an Authorization Code Callback Component

I generated the scaffolding for a new component:

ng generate component components/authorization-code-callback --project=auth-oidc

I updated the Authorization Code Callback component as follows:

...

@Component({
  template: ``,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AuthorizationCodeCallbackComponent implements OnInit {

  constructor(private authService: AuthService,
              private logger: LoggerService) {
  }

  ngOnInit() {
    this.authService.handleRedirectCallback();
  }

}

The Authorization Code Callback component is associated with the /authorization-code/callback path (see below).

Update the OIDC Auth Library's Routing Module

I updated the OIDC Auth Library's Routing module as follows:

...

const routes: Routes = [
  {
    path: 'login',
    component: LoginRedirectComponent
  },
  {
    path: 'authorization-code/callback',
    component: AuthorizationCodeCallbackComponent
  }
];

@NgModule({
  imports: [ RouterModule.forChild(routes)],
  exports: [ RouterModule ]
})
export class LibRoutingModule {}

Update Serendipity's App Module

I updated Seredipity's App module as follows:

...

// import { LocalAuthModule, authProviders } from 'auth-local';
import { OidcAuthModule, authProviders } from 'auth-oidc';

@NgModule({
  imports: [
    BrowserModule,
    // LocalAuthModule,
    OidcAuthModule.forRoot(environment),
    CoreModule,
    AppRoutingModule
  ],
  declarations: [ AppComponent ],
  providers: [
    loggerProviders,
    authProviders,
    angularMaterialProviders,
    {
      provide: ErrorHandler,
      useClass: GlobalErrorHandler
    },
    {
      provide: HTTP_INTERCEPTORS,
      useClass: HttpErrorInterceptor,
      multi: true
    }
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule {}

If a user tries to navigate to a path protected by the OIDC Auth Guard they will be redirected to Keycloak:

After the user logs in they will be directed back to the application:

User Registration

Before a user can login they need to have an account. To enable user registration click 'Realm Settings' in the sidemenu and then click on the 'Login' tab:

Check 'User registration' and 'Email as username'. Uncheck 'Verify email' (as we haven't configured Keycloak's email settings) and then click the 'Save' button.

When enabled, the login page has a registration link:

User's can click on the link to create a new account:

Default Roles

New users will be assigned the 'Guest' role:

What's Next

In the next post, I'll take a look at Keycloak's support for OAuth 2.0 scopes

Source Code:
References:
Additional References: