diff --git a/libs/angular-auth/.eslintrc.json b/libs/angular-auth/.eslintrc.json new file mode 100644 index 00000000..d4c0a6b0 --- /dev/null +++ b/libs/angular-auth/.eslintrc.json @@ -0,0 +1,46 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "ocx", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "ocx", + "style": "kebab-case" + } + ], + "no-restricted-syntax": [ + "off", + { + "selector": "CallExpression[callee.object.name=\"console\"][callee.property.name=/^(debug|info|time|timeEnd|trace)$/]" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/libs/angular-auth/README.md b/libs/angular-auth/README.md new file mode 100644 index 00000000..a8dbc1b1 --- /dev/null +++ b/libs/angular-auth/README.md @@ -0,0 +1,7 @@ +# angular-auth + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test angular-auth` to execute the unit tests. diff --git a/libs/angular-auth/jest.config.ts b/libs/angular-auth/jest.config.ts new file mode 100644 index 00000000..41adcdb2 --- /dev/null +++ b/libs/angular-auth/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'angular-auth', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../coverage/libs/angular-auth', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +} diff --git a/libs/angular-auth/ng-package.json b/libs/angular-auth/ng-package.json new file mode 100644 index 00000000..1f3ba465 --- /dev/null +++ b/libs/angular-auth/ng-package.json @@ -0,0 +1,9 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/libs/angular-auth", + "lib": { + "entryFile": "src/index.ts" + }, + "assets": ["CHANGELOG.md", "./assets/**"] + } + \ No newline at end of file diff --git a/libs/angular-auth/package.json b/libs/angular-auth/package.json new file mode 100644 index 00000000..222d762e --- /dev/null +++ b/libs/angular-auth/package.json @@ -0,0 +1,16 @@ +{ + "name": "@onecx/angular-auth", + "version": "4.13.2", + "peerDependencies": { + "@angular/common": ">=15.2.7", + "@angular/core": ">=15.2.7", + "@onecx/angular-integration-interface": "^4", + "@onecx/integration-interface": "^4", + "keycloak-angular": "^13.0.0", + "keycloak-js": "^18.0.0", + "rxjs": "~7.8.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/libs/angular-auth/project.json b/libs/angular-auth/project.json new file mode 100644 index 00000000..9de04aff --- /dev/null +++ b/libs/angular-auth/project.json @@ -0,0 +1,63 @@ +{ + "name": "angular-auth", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/angular-auth/src", + "prefix": "ocx", + "tags": [], + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/angular:package", + "outputs": [ + "{workspaceRoot}/dist/{projectRoot}" + ], + "options": { + "project": "libs/angular-auth/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "libs/angular-auth/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "libs/angular-auth/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": [ + "{workspaceRoot}/coverage/{projectRoot}" + ], + "options": { + "jestConfig": "libs/angular-auth/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "lintFilePatterns": [ + "libs/angular-auth/**/*.ts", + "libs/angular-auth/**/*.html", + "libs/angular-auth/package.json" + ] + } + }, + "release": { + "executor": "nx-release:build-update-publish", + "options": { + "libName": "portal-integration-angular" + } + } + } +} \ No newline at end of file diff --git a/libs/angular-auth/src/index.ts b/libs/angular-auth/src/index.ts new file mode 100644 index 00000000..b28b260a --- /dev/null +++ b/libs/angular-auth/src/index.ts @@ -0,0 +1 @@ +export * from './lib/angular-auth.module' diff --git a/libs/angular-auth/src/lib/angular-auth.module.ts b/libs/angular-auth/src/lib/angular-auth.module.ts new file mode 100644 index 00000000..b17c87f6 --- /dev/null +++ b/libs/angular-auth/src/lib/angular-auth.module.ts @@ -0,0 +1,33 @@ +import { APP_INITIALIZER, NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { HTTP_INTERCEPTORS } from '@angular/common/http' +import { ConfigurationService } from '@onecx/angular-integration-interface' +import { TokenInterceptor } from './token.interceptor' +import { AuthService } from './auth.service' +import { AuthServiceWrapper } from './auth-service-wrapper' +import { KeycloakAuthService } from './auth_services/keycloak-auth.service' +import { KeycloakService } from 'keycloak-angular' + +function appInitializer(configService: ConfigurationService, authService: AuthService) { + return async () => { + await configService.isInitialized + await authService.init() + } +} + +@NgModule({ + imports: [CommonModule], + providers: [ + AuthServiceWrapper, + { provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true }, + { + provide: APP_INITIALIZER, + useFactory: appInitializer, + deps: [ConfigurationService, AuthServiceWrapper], + multi: true, + }, + KeycloakAuthService, + KeycloakService, + ], +}) +export class AngularAuthModule {} diff --git a/libs/angular-auth/src/lib/auth-service-wrapper.ts b/libs/angular-auth/src/lib/auth-service-wrapper.ts new file mode 100644 index 00000000..151b225e --- /dev/null +++ b/libs/angular-auth/src/lib/auth-service-wrapper.ts @@ -0,0 +1,51 @@ +import { filter } from 'rxjs/internal/operators/filter' +import { AuthService } from './auth.service' +import { EventsTopic } from '@onecx/integration-interface' +import { AppStateService, CONFIG_KEY, ConfigurationService } from '@onecx/angular-integration-interface' +import { Injectable, Injector } from '@angular/core' +import { KeycloakAuthService } from './auth_services/keycloak-auth.service' +@Injectable() +export class AuthServiceWrapper { + private eventsTopic$ = new EventsTopic() + private authService: AuthService | undefined + + constructor( + private configService: ConfigurationService, + private appStateService: AppStateService, + private injector: Injector + ) { + this.eventsTopic$ + .pipe(filter((e) => e.type === 'authentication#logoutButtonClicked')) + .subscribe(() => this.authService?.logout()) + } + async init(): Promise { + await this.configService.isInitialized + + this.initializeAuthService() + const initResult = this.getInitResult() + return initResult + } + async getInitResult(): Promise { + const initResult = await this.authService?.init() + + if (initResult) { + await this.appStateService.isAuthenticated$.publish() + } + return initResult + } + getHeaderValues(): Record { + return this.authService?.getHeaderValues() ?? {} + } + + initializeAuthService(): void { + const serviceTypeConfig = this.configService.getProperty(CONFIG_KEY.AUTH_SERVICE) ?? 'keycloak' + switch (serviceTypeConfig) { + case 'keycloak': + this.authService = this.injector.get(KeycloakAuthService) + break + // TODO: Extend the other cases in the future + default: + throw new Error('Configured AuthService not found') + } + } +} diff --git a/libs/angular-auth/src/lib/auth.service.ts b/libs/angular-auth/src/lib/auth.service.ts new file mode 100644 index 00000000..c1ea0008 --- /dev/null +++ b/libs/angular-auth/src/lib/auth.service.ts @@ -0,0 +1,7 @@ +export interface AuthService { + init(): Promise + + getHeaderValues(): Record + + logout(): void +} diff --git a/libs/angular-auth/src/lib/auth_services/keycloak-auth.service.ts b/libs/angular-auth/src/lib/auth_services/keycloak-auth.service.ts new file mode 100644 index 00000000..252f2355 --- /dev/null +++ b/libs/angular-auth/src/lib/auth_services/keycloak-auth.service.ts @@ -0,0 +1,161 @@ +import { Injectable } from '@angular/core' +import { ConfigurationService, CONFIG_KEY } from '@onecx/angular-integration-interface' +import { KeycloakEventType, KeycloakOptions, KeycloakService } from 'keycloak-angular' +import { KeycloakConfig } from 'keycloak-js' +import { AuthService } from '../auth.service' + +const KC_REFRESH_TOKEN_LS = 'onecx_kc_refreshToken' +const KC_ID_TOKEN_LS = 'onecx_kc_idToken' +const KC_TOKEN_LS = 'onecx_kc_token' + +@Injectable() +export class KeycloakAuthService implements AuthService { + constructor(private keycloakService: KeycloakService, private configService: ConfigurationService) {} + + public async init(): Promise { + console.time('KeycloakAuthService') + let token = localStorage.getItem(KC_TOKEN_LS) + let idToken = localStorage.getItem(KC_ID_TOKEN_LS) + let refreshToken = localStorage.getItem(KC_REFRESH_TOKEN_LS) + if (token && refreshToken) { + const parsedToken = JSON.parse(atob(refreshToken.split('.')[1])) + if (parsedToken.exp * 1000 < new Date().getTime()) { + token = null + refreshToken = null + idToken = null + this.clearKCStateFromLocalstorage() + } + } + + this.setupEventListener() + + let kcConfig: KeycloakConfig | string = this.getValidKCConfig() + if (!kcConfig.clientId || !kcConfig.realm || !kcConfig.url) { + kcConfig = './assets/keycloak.json' + } + + const enableSilentSSOCheck = this.configService.getProperty(CONFIG_KEY.KEYCLOAK_ENABLE_SILENT_SSO) === 'true' + + const kcOptions: KeycloakOptions = { + loadUserProfileAtStartUp: false, + config: kcConfig, + initOptions: { + onLoad: 'check-sso', + checkLoginIframe: false, + silentCheckSsoRedirectUri: enableSilentSSOCheck ? this.getSilentSSOUrl() : undefined, + idToken: idToken || undefined, + refreshToken: refreshToken || undefined, + token: token || undefined, + }, + enableBearerInterceptor: false, + bearerExcludedUrls: ['/assets'], + } + + return this.keycloakService + .init(kcOptions) + .catch((err) => { + console.log(`Keycloak err: ${err}, try force login`) + return this.keycloakService.login() + }) + .then((loginOk) => { + if (loginOk) { + return this.keycloakService.getToken() + } else { + return this.keycloakService.login().then(() => 'login') + } + }) + .then(() => { + console.timeEnd('KeycloakAuthService') + return true + }) + .catch((err) => { + console.log(`KC ERROR ${err} as json ${JSON.stringify(err)}`) + throw err + }) + } + + private getValidKCConfig(): KeycloakConfig { + const clientId = this.configService.getProperty(CONFIG_KEY.KEYCLOAK_CLIENT_ID) + if (!clientId) { + throw new Error('Invalid KC config, missing clientId') + } + const realm = this.configService.getProperty(CONFIG_KEY.KEYCLOAK_REALM) + if (!realm) { + throw new Error('Invalid KC config, missing realm') + } + return { + url: this.configService.getProperty(CONFIG_KEY.KEYCLOAK_URL), + clientId, + realm, + } + } + + private setupEventListener() { + this.keycloakService.keycloakEvents$.subscribe((ke) => { + if (this.keycloakService.getKeycloakInstance().token) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + localStorage.setItem(KC_TOKEN_LS, this.keycloakService.getKeycloakInstance().token!) + } else { + localStorage.removeItem(KC_TOKEN_LS) + } + if (this.keycloakService.getKeycloakInstance().idToken) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + localStorage.setItem(KC_ID_TOKEN_LS, this.keycloakService.getKeycloakInstance().idToken!) + } else { + localStorage.removeItem(KC_ID_TOKEN_LS) + } + if (this.keycloakService.getKeycloakInstance().refreshToken) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + localStorage.setItem(KC_REFRESH_TOKEN_LS, this.keycloakService.getKeycloakInstance().refreshToken!) + } else { + localStorage.removeItem(KC_REFRESH_TOKEN_LS) + } + if (ke.type === KeycloakEventType.OnAuthLogout) { + console.log('SSO logout nav to root') + this.clearKCStateFromLocalstorage() + this.keycloakService.login() + } + }) + } + + private clearKCStateFromLocalstorage() { + localStorage.removeItem(KC_ID_TOKEN_LS) + localStorage.removeItem(KC_TOKEN_LS) + localStorage.removeItem(KC_REFRESH_TOKEN_LS) + } + + private getSilentSSOUrl() { + let currentBase = document.getElementsByTagName('base')[0].href + if (currentBase === '/') { + currentBase = '' + } + return `${currentBase}/assets/silent-check-sso.html` + } + + getIdToken(): string | null { + return this.keycloakService.getKeycloakInstance().idToken ?? null + } + getAccessToken(): string | null { + return this.keycloakService.getKeycloakInstance().token ?? null + } + + logout(): void { + this.keycloakService.logout() + } + + getAuthProviderName(): string { + return 'keycloak-auth' + } + + hasRole(_role: string): boolean { + return false + } + + getUserRoles(): string[] { + return [] + } + + getHeaderValues(): Record { + return { 'apm-principal-token': this.getIdToken() ?? '', Authorization: `Bearer${this.getAccessToken()}` } + } +} diff --git a/libs/angular-auth/src/lib/token.interceptor.ts b/libs/angular-auth/src/lib/token.interceptor.ts new file mode 100644 index 00000000..500786e7 --- /dev/null +++ b/libs/angular-auth/src/lib/token.interceptor.ts @@ -0,0 +1,27 @@ +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { Observable } from 'rxjs' +import { AuthServiceWrapper } from './auth-service-wrapper' + +const WHITELIST = ['assets'] + +@Injectable() +export class TokenInterceptor implements HttpInterceptor { + constructor(private authService: AuthServiceWrapper) {} + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + const skip = WHITELIST.some((str) => request.url.includes(str)) + if (skip) { + return next.handle(request) + } + const headerValues = this.authService.getHeaderValues() + let headers = request.headers + for (const header in headerValues) { + headers = headers.set(header, headerValues[header]) + } + const authenticatedReq: HttpRequest = request.clone({ + headers: headers, + }) + return next.handle(authenticatedReq) + } +} diff --git a/libs/angular-auth/src/test-setup.ts b/libs/angular-auth/src/test-setup.ts new file mode 100644 index 00000000..a5e675ac --- /dev/null +++ b/libs/angular-auth/src/test-setup.ts @@ -0,0 +1,8 @@ +// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment +globalThis.ngJest = { + testEnvironmentOptions: { + errorOnUnknownElements: true, + errorOnUnknownProperties: true, + }, +} +import 'jest-preset-angular/setup-jest' diff --git a/libs/angular-auth/tsconfig.json b/libs/angular-auth/tsconfig.json new file mode 100644 index 00000000..cd40c79f --- /dev/null +++ b/libs/angular-auth/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "es2022", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.lib.prod.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json", + "angularCompilerOptions": { + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/angular-auth/tsconfig.lib.json b/libs/angular-auth/tsconfig.lib.json new file mode 100644 index 00000000..f9363c1f --- /dev/null +++ b/libs/angular-auth/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [], + "target": "es2022" + }, + "exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/libs/angular-auth/tsconfig.lib.prod.json b/libs/angular-auth/tsconfig.lib.prod.json new file mode 100644 index 00000000..9a60db8a --- /dev/null +++ b/libs/angular-auth/tsconfig.lib.prod.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false, + "target": "es2022", + "useDefineForClassFields": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/libs/angular-auth/tsconfig.spec.json b/libs/angular-auth/tsconfig.spec.json new file mode 100644 index 00000000..004f70dc --- /dev/null +++ b/libs/angular-auth/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "target": "es2022", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/angular-integration-interface/src/lib/model/config-key.model.ts b/libs/angular-integration-interface/src/lib/model/config-key.model.ts index beef9d41..0146be2c 100644 --- a/libs/angular-integration-interface/src/lib/model/config-key.model.ts +++ b/libs/angular-integration-interface/src/lib/model/config-key.model.ts @@ -22,4 +22,6 @@ export enum CONFIG_KEY { ONECX_PORTAL_HELP_DISABLED = 'ONECX_PORTAL_HELP_DISABLED', APP_VERSION = 'APP_VERSION', IS_SHELL = 'IS_SHELL', + AUTH_SERVICE = 'AUTH_SERVICE', + AUTH_SERVICE_CUSTOM_URL = 'AUTH_SERVICE_CUSTOM_URL', } diff --git a/tsconfig.base.json b/tsconfig.base.json index bc8c2af7..17be2d0c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -18,16 +18,17 @@ "@onecx/accelerator": ["libs/accelerator/src/index.ts"], "@onecx/angular-accelerator": ["libs/angular-accelerator/src/index.ts"], "@onecx/angular-accelerator/testing": ["libs/angular-accelerator/testing/index.ts"], + "@onecx/angular-auth": ["libs/angular-auth/src/index.ts"], "@onecx/angular-integration-interface": ["libs/angular-integration-interface/src/index.ts"], "@onecx/angular-integration-interface/mocks": ["libs/angular-integration-interface/mocks/index.ts"], - "@onecx/angular-testing": ["libs/angular-testing/src/index.ts"], "@onecx/angular-remote-components": ["libs/angular-remote-components/src/index.ts"], "@onecx/angular-standalone-shell": ["libs/angular-standalone-shell/src/index.ts"], + "@onecx/angular-testing": ["libs/angular-testing/src/index.ts"], "@onecx/integration-interface": ["libs/integration-interface/src/index.ts"], "@onecx/keycloak-auth": ["libs/keycloak-auth/src/index.ts"], "@onecx/portal-integration-angular": ["libs/portal-integration-angular/src/index.ts"], - "@onecx/portal-integration-angular/testing": ["libs/portal-integration-angular/testing/index.ts"], "@onecx/portal-integration-angular/mocks": ["libs/portal-integration-angular/mocks/index.ts"], + "@onecx/portal-integration-angular/testing": ["libs/portal-integration-angular/testing/index.ts"], "@onecx/portal-layout-styles": ["libs/portal-layout-styles/src/index.ts"], "@onecx/shell-core": ["libs/shell-core/src/index.ts"] },