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:
- GitHub: Serendipity the open source Customer Engagement Platform
- GitHub: The REST API for the Serendipity Customer Engagement Platform
References:
- Okta blog: Is the OAuth 2.0 Implicit Flow Dead?
- GitHub: A demonstration of the OAuth PKCE flow in plain JavaScript
- Scott Brady's blog: SPA Authentication using OpenID Connect, Angular CLI and oidc-client
- Scott Brady's blog: Migrating oidc-client-js to use the OpenID Connect Authorization Code Flow and PKCE
- GitHub: oidc-client-js
- OWSAP: HTML5 Securty Cheat Sheet - Local Storage
Additional References:
- GitHub: Material Design Theme for Keycloak
- GitHub: Alfresco Keycloak Theme