Created
October 25, 2021 12:48
-
-
Save hidegh/8dcac31ce0825248030bea1611a1abc6 to your computer and use it in GitHub Desktop.
authService - WorkFlowWise (wfw-ngx-adal - with acquireToken patch) and custom JWT.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Injectable } from "@angular/core"; | |
import { Router } from "@angular/router"; | |
import { JwtHelperService } from '@auth0/angular-jwt'; | |
import { BehaviorSubject, from, Observable, of } from "rxjs"; | |
import { distinctUntilChanged, take, tap } from "rxjs/operators"; | |
import { RoleConsts } from "src/app/_core/security/roles-consts"; | |
import { CurrentUserService } from "src/app/_services/current-user/current-user.service"; | |
import { IUserProfile } from "src/app/_services/current-user/i.user.profile"; | |
import { environment } from 'src/environments/environment'; | |
import { AdalService } from "wfw-ngx-adal/dist/core"; | |
export enum AuthTypeEnum { | |
NoAuth = 0, | |
Regular = 1, | |
Jwt = 2 | |
} | |
export interface IAuthService { | |
login(redirectPath?: string): Promise<void>; | |
logout(redirectPath?: string): Promise<void>; | |
handleCallback(redirectPath?: string): Promise<void>; | |
loggedIn: boolean; | |
/** Tries to log in silently with existing (cached) credentials (both ADAL and JWT login supported) */ | |
tryLoginSilently(redirectPath?: string): Promise<boolean>; | |
/** Tries to log in with JWT only, bypasssing the "standard" adal - allowing access for unregistered employees */ | |
loginWithToken(token: string, redirectPath?: string): Promise<void> | |
} | |
export interface ISecurityService { | |
hasClaim(type: any, value: any): boolean; | |
isInRole(roleName: string): boolean; | |
} | |
/** | |
* Originally updated to: import { AdalService } from "wfw-ngx-adal/dist/core"; | |
* | |
* Consider official Microsoft library: | |
* https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-angular | |
* - https://youtu.be/TkCKqeYjpv0?t=1002 | |
* - https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/samples/msal-angular-v2-samples/angular12-sample-app | |
* | |
* Docs: | |
* - https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-js-sso | |
*/ | |
@Injectable({ | |
providedIn: 'root' | |
}) | |
export class AuthService implements IAuthService, ISecurityService { | |
public static readonly accessTokenKey = "access_token"; | |
public static readonly accessTypeKey = "access_type"; | |
public static minimumRequiredTokenLifetimeSeconds = 15 /* mins */ * 60; | |
public get authType(): AuthTypeEnum { return (<any>AuthTypeEnum)[localStorage.getItem(AuthService.accessTypeKey) || ""] || AuthTypeEnum.NoAuth; } | |
public get token(): string { return localStorage.getItem(AuthService.accessTokenKey) || ""; } | |
protected _loggedIn: boolean = false; | |
protected _userProfile: IUserProfile = {} as IUserProfile; | |
public get loggedIn(): boolean { return this._loggedIn; } | |
public get userProfile(): IUserProfile { return this._userProfile; } | |
/** | |
* @description | |
* Helper Subject (observable) for the loggedInStatusChanged$ | |
* Aft. change to BehaviorSubject: also a way to access logged-in status! | |
*/ | |
public loggedIn$ = new BehaviorSubject<boolean>(false); | |
/** | |
* @description | |
* This observable will emit when loggedIn status changes. | |
* Initially won't emit anything, until a check via login/ssoLogin/logout/isAuthenticated is done (which initiates this trigger). | |
*/ | |
public loggedInStatusChanged$: Observable<boolean> = this.loggedIn$.pipe(distinctUntilChanged()); | |
/** | |
* @description | |
* Helper method to emit new values into the protected loggedInStatusChangedSubject$. | |
* If the loggedInStatusChangedSubject$'s value changes, then the public loggedInStatusChanged$ will emit as well. | |
* @param loggedIn authentication result | |
*/ | |
protected emitLoggedInStatus(loggedIn: boolean) { | |
// NOTE: must set value here, as there's no guarantee that the loggedInStatusChanged$ will be subscribed! | |
this._loggedIn = loggedIn; | |
this.loggedIn$.next(loggedIn); | |
} | |
protected jwtHelperService = new JwtHelperService({ | |
tokenGetter: (request: any) => localStorage.getItem(AuthService.accessTokenKey) | |
}); | |
constructor( | |
protected router: Router, | |
protected adal: AdalService, | |
protected currentUserService: CurrentUserService, | |
) { | |
const secret: adal.Config = { | |
tenant: environment.adal!.tenant!, | |
clientId: environment.adal!.clientId!, | |
redirectUri: window.location.origin + environment.adal!.redirectUri!, | |
navigateToLoginRequestUrl: environment.adal!.navigateToLoginRequestUrl!, | |
cacheLocation: environment.adal!.cacheLocation! | |
}; | |
// NOTE: it seems that the post-logout-redirect-uri works on prod, but not on the localhost! | |
if (environment.adal!.postLogoutRedirectUri!) | |
secret.postLogoutRedirectUri = window.location.origin + environment.adal!.postLogoutRedirectUri! | |
this.adal.init(secret); | |
this.adal.handleWindowCallback(); | |
} | |
protected isAuthenticated(): boolean { | |
if (this.authType == AuthTypeEnum.Regular) return !!this.adal.userInfo?.isAuthenticated; | |
else if (this.authType == AuthTypeEnum.Jwt) return this.jwtHelperService.isTokenExpired(/* undefined, AuthService.minimumRequiredTokenLifetimeSeconds */) === false; | |
return false; | |
} | |
public async login(redirectPath?: string): Promise<void> { | |
const silentLoginResult = await this.tryLoginSilently(redirectPath); | |
if (!silentLoginResult) { | |
// do regular login (will do redirect to callback) | |
console.log('auth.login'); | |
this.adal.login(); | |
} | |
return Promise.resolve(undefined); | |
} | |
public async tryLoginSilently(redirectPath?: string): Promise<boolean> { | |
if (this.authType == AuthTypeEnum.Regular) { | |
// ADAL | |
if (this.isAuthenticated()) { | |
console.log('auth.login (silent)'); | |
// NOTE: validity is checked by the adal.acquireToken call itself! | |
const token = await this.acquireToken(); | |
// set up user session | |
await this.setupUserSessionAndEmitTrue(token, AuthTypeEnum.Regular, redirectPath); | |
// success (logged in silently via persisted token data) | |
return of(true).toPromise(); | |
} | |
} else if (this.authType == AuthTypeEnum.Jwt) { | |
// JWT | |
const token = this.token; | |
const hasValidToken = token && !this.jwtHelperService.isTokenExpired(token/*, AuthService.minimumRequiredTokenLifetimeSeconds*/); | |
if (hasValidToken) { | |
console.log('auth.login (silent)'); | |
// set up user session | |
await this.setupUserSessionAndEmitTrue(token, AuthTypeEnum.Jwt, redirectPath); | |
// success (logged in silently via persisted token data) | |
return of(true).toPromise(); | |
} | |
} | |
// no token (or token expired), need to do login | |
return of(false).toPromise(); | |
} | |
public async loginWithToken(token: string, redirectPath?: string): Promise<void> { | |
await this.setupUserSessionAndEmitTrue(token, AuthTypeEnum.Jwt, redirectPath); | |
return Promise.resolve(undefined); | |
} | |
public async logout(redirectPath?: string): Promise<void> { | |
console.log('auth.logout'); | |
// store for later usage as the teardown clears this value | |
const loggedInAuthType = this.authType; | |
// clear all local... | |
await this.tearDownUserSessionAndEmitFalse(redirectPath); | |
// logout (cleanup) via adal | |
if (loggedInAuthType == AuthTypeEnum.Regular) | |
this.adal.logOut(); | |
return Promise.resolve(undefined); | |
} | |
public async handleCallback(redirectPath?: string): Promise<void> { | |
console.log('auth.handleCallback'); | |
// execute any callback related code... | |
// ... | |
// NOTE: for the this.isAuthenticated() to work, we must signalize that we're doing the regular auth. | |
localStorage.setItem(AuthService.accessTypeKey, AuthTypeEnum[AuthTypeEnum.Regular]); | |
if (this.isAuthenticated()) { | |
const token = await this.acquireToken(); | |
await this.setupUserSessionAndEmitTrue(token, AuthTypeEnum.Regular, redirectPath); | |
} else { | |
await this.tearDownUserSessionAndEmitFalse(redirectPath); | |
} | |
return Promise.resolve(undefined); | |
} | |
protected async acquireToken(): Promise<string> { | |
let token = ""; | |
try { | |
return from( | |
this.adal | |
.acquireToken(this.adal.config.clientId)) | |
.pipe( | |
take(1), | |
tap((result: any) => { token = result.toString(); }) | |
) | |
.toPromise(); | |
} | |
catch (e) { | |
// Ignore the one teardown error that is cause the adal.acquireToken uses an extra return s statement... | |
// ...usually the token is already fetched, just some promise/async finish up code results in the error! | |
if (e.toString().startsWith('Error: unrecognized teardown')) | |
return of(token).toPromise(); | |
// ...otherwise re-throw | |
throw e; | |
} | |
} | |
protected setupUserSessionAndEmitTrue(token: string, authType: AuthTypeEnum = AuthTypeEnum.Regular, redirectPath?: string): Promise<void> { | |
const fn = async () => { | |
// setup user session & emit logged in status TRUE | |
console.info('auth.setupUserSessionAndEmitTrue'); | |
// set cache | |
localStorage.setItem(AuthService.accessTokenKey, token); | |
localStorage.setItem(AuthService.accessTypeKey, AuthTypeEnum[authType]); | |
// get user data (the API itself will decide if a new "login audit event" should be created) | |
const userData = await this.currentUserService.getCurrentUserProfile().toPromise(); | |
this._userProfile = userData; | |
// emit | |
this.emitLoggedInStatus(true); | |
// redirect | |
if (redirectPath) this.router.navigateByUrl(redirectPath); | |
}; | |
return fn(); | |
} | |
protected tearDownUserSessionAndEmitFalse(redirectPath?: string): Promise<void> { | |
// setup user session & emit logged in status FALSE | |
console.info('auth.tearDownUserSessionAndEmitFalse'); | |
// clear cache | |
delete localStorage[AuthService.accessTokenKey]; | |
delete localStorage[AuthService.accessTypeKey]; | |
this._userProfile = {} as IUserProfile; | |
// emit | |
this.emitLoggedInStatus(false); | |
// redirect | |
if (redirectPath) this.router.navigateByUrl(redirectPath); | |
return Promise.resolve(undefined); | |
} | |
// | |
// Security support from here... | |
// | |
public hasClaim(type: any, value: any): boolean { | |
// NOTE: case sensitive! | |
return !!this._userProfile?.claims?.some(claim => claim.type == type && claim.value == value); | |
} | |
public getClaims(type: any): any[] { | |
return this._userProfile?.claims?.filter(claim => claim.type == type); | |
} | |
public isInRole(roleName: string): boolean { | |
return this.hasClaim(RoleConsts.$RoleClaimType, roleName); | |
} | |
// | |
// Logging | |
// | |
public getLogDetails(): any { | |
return { | |
dotNetUserId: this._userProfile?.dotNetUserId, | |
email: this._userProfile?.email, | |
phone: this._userProfile?.phone, | |
claims: this._userProfile?.claims?.filter(claim => claim.type == RoleConsts.$RoleClaimType), | |
}; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment