Now, let's build a local authentication service that will enable us to demonstrate a robust login form, caching, and conditional navigation concepts based on authentication status and a user's role:
- Start by installing a JWT decoding library, and for faking authentication, a JWT encoding library:
$ npm install jwt-decode fake-jwt-sign
$ npm install -D @types/jwt-decode
- Define your imports for auth.service.ts:
src/app/auth/auth.service.ts
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { sign } from 'fake-jwt-sign' // For fakeAuthProvider only
import * as decode from 'jwt-decode'
import { BehaviorSubject, Observable, of, throwError as observableThrowError } from 'rxjs'
import { catchError, map } from 'rxjs/operators'
import { environment } from '../../environments/environment'
import { Role } from './role.enum'
...
- Implement an IAuthStatus interface to store decoded user information, a helper interface, and the secure by-default defaultAuthStatus:
src/app/auth/auth.service.ts
...
export interface IAuthStatus {
isAuthenticated: boolean
userRole: Role
userId: string
}
interface IServerAuthResponse {
accessToken: string
}
const defaultAuthStatus = { isAuthenticated: false, userRole: Role.None, userId: null }
...
IAuthUser is an interface that represents the shape of a typical JWT that you may receive from your authentication service. It contains minimal information about the user and its role, so it can be attached to the header of server calls and optionally cached in localStorage to remember the user's login state. In the preceding implementation, we're assuming the default role of a Manager.
- Define the AuthService class with a BehaviorSubject to anchor the current authStatus of the user and configure an authProvider that can process an email and a password and return an IServerAuthResponse in the constructor:
src/app/auth/auth.service.ts
...
@Injectable({
providedIn: 'root'
})
export class AuthService {
private readonly authProvider: (
email: string,
password: string
) => Observable<IServerAuthResponse>
authStatus = new BehaviorSubject<IAuthStatus>(defaultAuthStatus)
constructor(private httpClient: HttpClient) {
// Fake login function to simulate roles
this.authProvider = this.fakeAuthProvider
// Example of a real login call to server-side
// this.authProvider = this.exampleAuthProvider
}
...
Note that fakeAuthProvider is configured to be the authProvider for this service. A real auth provider may look like the following code, where users' email and password are sent to a POST endpoint, which verifies their information, creating and returning a JWT for our app to consume:
example
private exampleAuthProvider(
email: string,
password: string
): Observable<IServerAuthResponse> {
return this.httpClient.post<IServerAuthResponse>(`${environment.baseUrl}/v1/login`, {
email: email,
password: password,
})
}
It is pretty straightforward, since the hard work is done on the server side. This call can also be made to a third party.
- Implement a fakeAuthProvider that simulates the authentication process, including creating a fake JWT on the fly:
src/app/auth/auth.service.ts
...
private fakeAuthProvider(
email: string,
password: string
): Observable<IServerAuthResponse> {
if (!email.toLowerCase().endsWith('@test.com')) {
return observableThrowError('Failed to login! Email needs to end with @test.com.')
}
const authStatus = {
isAuthenticated: true,
userId: 'e4d1bc2ab25c',
userRole: email.toLowerCase().includes('cashier')
? Role.Cashier
: email.toLowerCase().includes('clerk')
? Role.Clerk
: email.toLowerCase().includes('manager') ? Role.Manager : Role.None,
} as IAuthStatus
const authResponse = {
accessToken: sign(authStatus, 'secret', {
expiresIn: '1h',
algorithm: 'none',
}),
} as IServerAuthResponse
return of(authResponse)
}
...
The fakeAuthProvider implements what would otherwise be a server-side method right in the service, so you can conveniently experiment the code while fine-tuning your auth workflow. It creates and signs a JWT, with the temporary fake-jwt-sign library so that we can also demonstrate how to handle a properly-formed JWT.
- Before we move on, implement a transformError function to handle mixed HttpErrorResponse and string errors in an observable stream under common/common.ts:
src/app/common/common.ts
import { HttpErrorResponse } from '@angular/common/http'
import { throwError } from 'rxjs'
export function transformError(error: HttpErrorResponse | string) {
let errorMessage = 'An unknown error has occurred'
if (typeof error === 'string') {
errorMessage = error
} else if (error.error instanceof ErrorEvent) {
errorMessage = `Error! ${error.error.message}`
} else if (error.status) {
errorMessage = `Request failed with ${error.status} ${error.statusText}`
}
return throwError(errorMessage)
}
- Implement the login function that will be called from LoginComponent, shown in the next section
- Add import { transformError } from '../common/common'
- Also implement a corresponding logout function, which may be called by the Logout button in the top toolbar, a failed login attempt, or if an unauthorized access attempt is detected by a router auth guard as the user is navigating the app, which is a topic covered later in the chapter:
src/app/auth/auth.service.ts
...
login(email: string, password: string): Observable<IAuthStatus> {
this.logout()
const loginResponse = this.authProvider(email, password).pipe(
map(value => {
return decode(value.accessToken) as IAuthStatus
}),
catchError(transformError)
)
loginResponse.subscribe(
res => {
this.authStatus.next(res)
},
err => {
this.logout()
return observableThrowError(err)
}
)
return loginResponse
}
logout() {
this.authStatus.next(defaultAuthStatus)
}
}
The login method encapsulates the correct order of operations by calling the logout method, the authProvider with the email and password information, and throwing errors when necessary.
The login method adheres to the Open/Closed principle, from SOLID design, by being open to extension by our ability to externally supply different auth providers to it, but it remains closed to modification, since the variance in functionality is encapsulated with the auth provider.
In the next section, we will implement the LoginComponent so that users can enter their username and password information and attempt a login.