Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(oidc): adding native OIDC module (WIP)
this module will have no 3rd party dependencies and leverage NGXS
- Loading branch information
Showing
29 changed files
with
1,052 additions
and
0 deletions.
There are no files selected for viewing
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
# OIDC | ||
|
||
|
||
> Redirect behaviour after `login()` and `logout()` | ||
1. if `postLoginRedirectUri` is provided then `login()` call without `args`, will redirect to this URL after successful login. | ||
2. if `postLogoutRedirectUri` is provided then `logout()` call without `args`, will redirect to this URL after successful logout. | ||
3. if both of the above urls are configured, but if `redirectUri` is provided, will redirect to this URL after successful login and logout. | ||
4. if non of the above urls are configured, will redirect to `currentUrl` after successful login and logout. | ||
5. always `args` in `login()` and `logout()` take priority. | ||
|
||
> if the above URLs are relative URLs, then, the `OIDC Provider` may prepend `baseUrl` configured in `OIDC Provider`. | ||
|
||
### Flows | ||
1. [Silent Refresh](https://www.scottbrady91.com/OpenID-Connect/Silent-Refresh-Refreshing-Access-Tokens-when-using-the-Implicit-Flow | ||
) for Implicit Flow | ||
2. Auto Refresh for Code Flow | ||
|
||
|
||
TODO | ||
https://github.com/damienbod/angular-auth-oidc-client |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
module.exports = { | ||
name: 'oidc', | ||
preset: '../../jest.config.js', | ||
coverageDirectory: '../../coverage/libs/oidc', | ||
}; |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
{ | ||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json", | ||
"dest": "../../dist/libs/oidc", | ||
"whitelistedNonPeerDependencies": ["keycloak-js"], | ||
"lib": { | ||
"entryFile": "src/index.ts", | ||
"umdModuleIds": { | ||
"keycloak-js": "keycloak-js" | ||
} | ||
} | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
{ | ||
"name": "@ngx-starter-kit/oidc", | ||
"version": "0.0.1", | ||
"peerDependencies": { | ||
"@angular/common": ">=6.0.0 <8.0.0", | ||
"@angular/core": ">=6.0.0 <8.0.0", | ||
"@ngxs/store": ">=3.3.0", | ||
"@ngxs/router-plugin": ">=3.3.0", | ||
"@ngxs/storage-plugin": ">=3.3.0", | ||
"@ngxs-labs/immer-adapter": ">=1.1.0" | ||
}, | ||
"dependencies": { | ||
"keycloak-js": ">=4.8.3 <5.0.0" | ||
} | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
export * from './lib/types'; | ||
export * from './lib/oidc.module'; | ||
export * from './lib/services/auth.service'; | ||
export * from './lib/state/auth.state'; | ||
export * from './lib/state/auth.actions'; | ||
export * from './lib/guards/base-auth.guard'; | ||
export * from './lib/guards/auth.guard'; |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import { Injectable } from '@angular/core'; | ||
import { ActivatedRouteSnapshot, RouterStateSnapshot, Router, UrlTree } from '@angular/router'; | ||
import { MatSnackBar } from '@angular/material'; | ||
import { environment } from '@env/environment'; | ||
import { BaseAuthGuard } from './base-auth.guard'; | ||
import { AuthService } from '../services/auth.service'; | ||
|
||
@Injectable() | ||
export class AuthGuard extends BaseAuthGuard { | ||
constructor(private snack: MatSnackBar, protected router: Router, protected authService: AuthService) { | ||
super(router, authService); | ||
} | ||
|
||
async isAccessAllowed(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean | UrlTree> { | ||
if (!this.authenticated) { | ||
await this.authService.login({ redirectUri: environment.baseUrl.slice(0, -1) + state.url }); // Let Provider add baseUrl? | ||
return; | ||
} | ||
|
||
const requiredRoles = route.data.roles; | ||
if (!requiredRoles || requiredRoles.length === 0) { | ||
return true; | ||
} else { | ||
if (!this.roles || this.roles.length === 0) { | ||
return false; | ||
} | ||
let granted = false; | ||
for (const requiredRole of requiredRoles) { | ||
if (this.roles.indexOf(requiredRole) > -1) { | ||
granted = true; | ||
break; | ||
} | ||
} | ||
if (!granted) { | ||
this.snack.open('You are not Admin. Please login with ngxadmin : ngxadmin', 'OK', { duration: 5000 }); | ||
} | ||
return granted; | ||
} | ||
} | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; | ||
import { AuthService } from '../services/auth.service'; | ||
|
||
export abstract class BaseAuthGuard implements CanActivate { | ||
protected authenticated: boolean; | ||
protected roles: string[]; | ||
|
||
protected constructor(protected router: Router, protected authService: AuthService) {} | ||
|
||
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean | UrlTree> { | ||
try { | ||
this.authenticated = await this.authService.isLoggedIn(); | ||
this.roles = await this.authService.getUserRoles(true); | ||
|
||
return await this.isAccessAllowed(route, state); | ||
} catch (error) { | ||
throw new Error('An error happened during access validation. Details:' + error); | ||
} | ||
} | ||
|
||
abstract isAccessAllowed(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean | UrlTree>; | ||
} |
50 changes: 50 additions & 0 deletions
50
libs/oidc/src/lib/interceptors/default-resource.interceptor.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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import { Inject, Injectable, Optional } from '@angular/core'; | ||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; | ||
import { from, Observable } from 'rxjs'; | ||
import { catchError, mergeMap } from 'rxjs/operators'; | ||
import { ResourceErrorHandler } from './resource-error-handler'; | ||
|
||
import { AuthService } from '../services/auth.service'; | ||
import { OidcResourceInterceptorConfig } from '../types'; | ||
|
||
@Injectable() | ||
export class DefaultResourceInterceptor implements HttpInterceptor { | ||
bypass: boolean; | ||
bearerPrefix: string; | ||
authorizationHeaderName: string; | ||
constructor( | ||
private authService: AuthService, | ||
private errorHandler: ResourceErrorHandler, | ||
@Optional() private resourceInterceptorConfig: OidcResourceInterceptorConfig, | ||
) { | ||
this.bypass = !this.authService || !this.resourceInterceptorConfig || !this.resourceInterceptorConfig.allowedUrls; | ||
this.bearerPrefix = resourceInterceptorConfig.bearerPrefix.trim().concat(' '); | ||
this.authorizationHeaderName = resourceInterceptorConfig.authorizationHeaderName; | ||
} | ||
|
||
private checkUrl(url: string): boolean { | ||
const found = this.resourceInterceptorConfig.allowedUrls.find(u => url.startsWith(u)); | ||
return !!found; | ||
} | ||
|
||
public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { | ||
if (this.bypass) { | ||
// TODO: do we need this check? | ||
return next.handle(req); | ||
} | ||
|
||
if (!this.checkUrl(req.url.toLowerCase())) { | ||
return next.handle(req); | ||
} | ||
|
||
return from(this.authService.getToken()).pipe( | ||
mergeMap(accessToken => { | ||
if (accessToken) { | ||
const headers = req.headers.set(this.authorizationHeaderName, this.bearerPrefix + accessToken); | ||
req = req.clone({ headers }); | ||
} | ||
return next.handle(req).pipe(catchError(err => this.errorHandler.handleError(err))); | ||
}), | ||
); | ||
} | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
import { HttpResponse } from '@angular/common/http'; | ||
import { Observable, throwError } from 'rxjs'; | ||
|
||
export abstract class ResourceErrorHandler { | ||
abstract handleError(err: HttpResponse<any>): Observable<any>; | ||
} | ||
|
||
export class NoopResourceErrorHandler implements ResourceErrorHandler { | ||
handleError(err: HttpResponse<any>): Observable<any> { | ||
return throwError(err); | ||
} | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import { Store } from '@ngxs/store'; | ||
import { LoginSuccess } from './state/auth.actions'; | ||
import { AuthorizationErrorResponse } from './types'; | ||
import { AuthService } from '@ngx-starter-kit/oidc'; | ||
|
||
export function initializeAuth(authService: AuthService, store: Store) { | ||
// TODO only check if not logged in. use Store and localStorage? | ||
// authService.setStorage(sessionStorage); | ||
return async () => { | ||
let authenticated = false; | ||
try { | ||
authenticated = await authService.init(); | ||
} catch (err /*: AuthorizationErrorResponse */) { | ||
console.log(`Error Code: ${err.error}, Error Description: ${err.error_description}`); | ||
} | ||
|
||
if (authenticated) { | ||
const profile = await authService.loadUserProfile(); | ||
store.dispatch(new LoginSuccess(profile)); | ||
} | ||
return authenticated; | ||
}; | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
import { APP_INITIALIZER, ModuleWithProviders, NgModule } from '@angular/core'; | ||
import { CommonModule } from '@angular/common'; | ||
import { HTTP_INTERCEPTORS } from '@angular/common/http'; | ||
import { NgxsModule, Store } from '@ngxs/store'; | ||
|
||
import { initializeAuth } from './oidc.init'; | ||
import { AuthGuard } from './guards/auth.guard'; | ||
import { AuthState } from './state/auth.state'; | ||
import { AuthHandler } from './state/auth.handler'; | ||
import { AuthService } from './services/auth.service'; | ||
import { PingService } from './services/ping.service'; | ||
import { KeycloakService } from './services/keycloak.service'; | ||
import { GenericService } from './services/generic.service'; | ||
import { DefaultResourceInterceptor } from './interceptors/default-resource.interceptor'; | ||
import { NoopResourceErrorHandler, ResourceErrorHandler } from './interceptors/resource-error-handler'; | ||
import { | ||
OidcInitConfig, | ||
OidcModuleConfig, | ||
OidcProvider, | ||
OidcProviderConfig, | ||
OidcResourceInterceptorConfig, | ||
} from './types'; | ||
|
||
function getProvider(providerConfig) { | ||
if (providerConfig) { | ||
switch (providerConfig.provider) { | ||
case OidcProvider.Ping: | ||
return PingService; | ||
case OidcProvider.Keycloak: | ||
return KeycloakService; | ||
default: | ||
return GenericService; | ||
} | ||
} else { | ||
return GenericService; | ||
} | ||
} | ||
@NgModule({ | ||
imports: [CommonModule, NgxsModule.forFeature([AuthState])], | ||
}) | ||
export class OidcModule { | ||
static forRoot(config: OidcModuleConfig): ModuleWithProviders { | ||
return { | ||
ngModule: OidcModule, | ||
providers: [ | ||
{ provide: OidcModuleConfig, useValue: Object.assign(new OidcModuleConfig(), config) }, | ||
{ provide: OidcInitConfig, useValue: Object.assign(new OidcInitConfig(), config.initConfig) }, | ||
{ provide: OidcProviderConfig, useValue: Object.assign(new OidcProviderConfig(), config.providerConfig) }, | ||
{ | ||
provide: OidcResourceInterceptorConfig, | ||
useValue: Object.assign(new OidcResourceInterceptorConfig(), config.resourceInterceptorConfig), | ||
}, | ||
{ | ||
provide: AuthService, | ||
useClass: getProvider(config.providerConfig), | ||
}, | ||
{ provide: ResourceErrorHandler, useClass: NoopResourceErrorHandler }, | ||
config.resourceInterceptorConfig && config.resourceInterceptorConfig.allowedUrls | ||
? { | ||
provide: HTTP_INTERCEPTORS, | ||
useClass: DefaultResourceInterceptor, | ||
multi: true, | ||
} | ||
: [], | ||
AuthGuard, | ||
{ provide: APP_INITIALIZER, useFactory: initializeAuth, deps: [AuthService, Store], multi: true }, | ||
], | ||
}; | ||
} | ||
// HINT: AuthHandler is injected here to initialize it as Module Run Block | ||
constructor(authHandler: AuthHandler) {} | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
import { TestBed } from '@angular/core/testing'; | ||
|
||
import { AuthService } from './auth.service'; | ||
|
||
describe('AuthService', () => { | ||
beforeEach(() => TestBed.configureTestingModule({})); | ||
|
||
it('should be created', () => { | ||
const service: AuthService = TestBed.get(AuthService); | ||
expect(service).toBeTruthy(); | ||
}); | ||
}); |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
import { Injectable } from '@angular/core'; | ||
import { OidcModuleConfig, OidcInitConfig, OidcLoginOptions, OidcUserProfile } from '../types'; | ||
|
||
@Injectable() | ||
export abstract class AuthService { | ||
protected userProfile: OidcUserProfile; | ||
protected readonly silentRefresh: boolean; | ||
protected readonly loadUserProfileAtStartUp: boolean; | ||
protected readonly postLoginRedirectUri: string; | ||
protected readonly postLogoutRedirectUri: string; | ||
|
||
protected constructor(protected moduleConfig: OidcModuleConfig) { | ||
const { loadUserProfileAtStartUp = true, postLoginRedirectUri, postLogoutRedirectUri, initConfig } = moduleConfig; | ||
this.loadUserProfileAtStartUp = loadUserProfileAtStartUp; | ||
this.postLoginRedirectUri = postLoginRedirectUri; | ||
this.postLogoutRedirectUri = postLogoutRedirectUri; | ||
this.silentRefresh = initConfig ? initConfig.flow === 'implicit' : false; | ||
} | ||
|
||
abstract init(options?: OidcInitConfig): Promise<boolean>; | ||
abstract login(options?: OidcLoginOptions): Promise<void>; | ||
abstract logout(redirectUri?: string): Promise<void>; | ||
abstract register(options?: OidcLoginOptions); | ||
abstract getAccountManagementUrl(); | ||
abstract isUserInRole(role: string, resource?: string): boolean; | ||
abstract getUserRoles(allRoles?: boolean): string[]; | ||
abstract isLoggedIn(): Promise<boolean>; | ||
abstract isTokenExpired(minValidity?: number): boolean; | ||
abstract updateToken(minValidity?: number): Promise<boolean>; | ||
abstract loadUserProfile(forceReload?: boolean): Promise<OidcUserProfile>; | ||
abstract getToken(): Promise<string>; | ||
abstract getUsername(): string; | ||
abstract clearToken(); | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
import { TestBed } from '@angular/core/testing'; | ||
|
||
import { GenericService } from './generic.service'; | ||
|
||
describe('GenericService', () => { | ||
beforeEach(() => TestBed.configureTestingModule({})); | ||
|
||
it('should be created', () => { | ||
const service: GenericService = TestBed.get(GenericService); | ||
expect(service).toBeTruthy(); | ||
}); | ||
}); |
Oops, something went wrong.