diff --git a/npm/ng-packs/packages/oauth/.eslintrc.json b/npm/ng-packs/packages/oauth/.eslintrc.json new file mode 100644 index 0000000000..9c51584494 --- /dev/null +++ b/npm/ng-packs/packages/oauth/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "plugin:@nrwl/nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "abp", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "abp", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nrwl/nx/angular-template"], + "rules": {} + } + ] +} diff --git a/npm/ng-packs/packages/oauth/README.md b/npm/ng-packs/packages/oauth/README.md new file mode 100644 index 0000000000..e42bbefb70 --- /dev/null +++ b/npm/ng-packs/packages/oauth/README.md @@ -0,0 +1,7 @@ +# oauth + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test oauth` to execute the unit tests. diff --git a/npm/ng-packs/packages/oauth/jest.config.ts b/npm/ng-packs/packages/oauth/jest.config.ts new file mode 100644 index 0000000000..4dd4b3eaf8 --- /dev/null +++ b/npm/ng-packs/packages/oauth/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'oauth', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + }, + coverageDirectory: '../../coverage/packages/oauth', + transform: { + '^.+\\.(ts|mjs|js|html)$': 'jest-preset-angular', + }, + 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/npm/ng-packs/packages/oauth/ng-package.json b/npm/ng-packs/packages/oauth/ng-package.json new file mode 100644 index 0000000000..2e78d8fbe5 --- /dev/null +++ b/npm/ng-packs/packages/oauth/ng-package.json @@ -0,0 +1,12 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/packages/core", + "lib": { + "entryFile": "src/public-api.ts" + }, + "allowedNonPeerDependencies": [ + "@abp/utils", + "@abp/ng.core", + "angular-oauth2-oidc" + ] +} diff --git a/npm/ng-packs/packages/oauth/package.json b/npm/ng-packs/packages/oauth/package.json new file mode 100644 index 0000000000..9f127e7701 --- /dev/null +++ b/npm/ng-packs/packages/oauth/package.json @@ -0,0 +1,21 @@ +{ + "name": "@abp/ng.core", + "version": "7.0.0-rc.4", + "homepage": "https://abp.io", + "repository": { + "type": "git", + "url": "https://github.com/abpframework/abp.git" + }, + "dependencies": { + "@abp/utils": "~7.0.0-rc.4", + "@abp/ng.core": "~7.0.0-rc.4", + "angular-oauth2-oidc": "^15.0.1", + "just-clone": "^6.1.1", + "just-compare": "^1.4.0", + "ts-toolbelt": "6.15.4", + "tslib": "^2.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/npm/ng-packs/packages/oauth/src/lib/oauth.module.ts b/npm/ng-packs/packages/oauth/src/lib/oauth.module.ts new file mode 100644 index 0000000000..471e4e3d78 --- /dev/null +++ b/npm/ng-packs/packages/oauth/src/lib/oauth.module.ts @@ -0,0 +1,21 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { OAuthModule as OAuthModule2, OAuthService, OAuthStorage } from 'angular-oauth2-oidc'; +import { TimeoutLimitedOAuthService } from '@abp/ng.core'; +import { storageFactory } from './utils/storage.factory'; + +@NgModule({ + imports: [CommonModule, OAuthModule2], +}) +export class OAuthModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: OAuthModule, + providers: [ + OAuthModule2.forRoot().providers, + { provide: OAuthStorage, useFactory: storageFactory }, + { provide: OAuthService, useClass: TimeoutLimitedOAuthService }, + ], + }; + } +} diff --git a/npm/ng-packs/packages/oauth/src/lib/strategies/auth-code-flow-strategy.ts b/npm/ng-packs/packages/oauth/src/lib/strategies/auth-code-flow-strategy.ts new file mode 100644 index 0000000000..946d1295ca --- /dev/null +++ b/npm/ng-packs/packages/oauth/src/lib/strategies/auth-code-flow-strategy.ts @@ -0,0 +1,39 @@ +import { noop } from '@abp/ng.core'; +import { Params } from '@angular/router'; +import { from, of } from 'rxjs'; +import { AuthFlowStrategy } from './auth-flow-strategy'; + +export class AuthCodeFlowStrategy extends AuthFlowStrategy { + readonly isInternalAuth = false; + + async init() { + return super + .init() + .then(() => this.oAuthService.tryLogin().catch(noop)) + .then(() => this.oAuthService.setupAutomaticSilentRefresh({}, 'access_token')); + } + + navigateToLogin(queryParams?: Params) { + this.oAuthService.initCodeFlow('', this.getCultureParams(queryParams)); + } + + checkIfInternalAuth(queryParams?: Params) { + this.oAuthService.initCodeFlow('', this.getCultureParams(queryParams)); + return false; + } + + logout(queryParams?: Params) { + return from(this.oAuthService.revokeTokenAndLogout(this.getCultureParams(queryParams))); + } + + login(queryParams?: Params) { + this.oAuthService.initCodeFlow('', this.getCultureParams(queryParams)); + return of(null); + } + + private getCultureParams(queryParams?: Params) { + const lang = this.sessionState.getLanguage(); + const culture = { culture: lang, 'ui-culture': lang }; + return { ...(lang && culture), ...queryParams }; + } +} diff --git a/npm/ng-packs/packages/oauth/src/lib/strategies/auth-flow-strategy.ts b/npm/ng-packs/packages/oauth/src/lib/strategies/auth-flow-strategy.ts new file mode 100644 index 0000000000..f74a223fbd --- /dev/null +++ b/npm/ng-packs/packages/oauth/src/lib/strategies/auth-flow-strategy.ts @@ -0,0 +1,103 @@ +import { Injector } from '@angular/core'; +import { Params } from '@angular/router'; +import { AuthConfig, OAuthErrorEvent, OAuthService, OAuthStorage } from 'angular-oauth2-oidc'; +import { Observable, of } from 'rxjs'; +import { filter, switchMap, tap } from 'rxjs/operators'; +import { LoginParams } from '../models/auth'; +import { + ConfigStateService, + EnvironmentService, + HttpErrorReporterService, + SessionStateService, + TENANT_KEY, +} from '@abp/ng.core'; +import { oAuthStorage } from './oauth-storage'; +import { clearOAuthStorage } from './clear-o-auth-storage'; + +export abstract class AuthFlowStrategy { + abstract readonly isInternalAuth: boolean; + + protected httpErrorReporter: HttpErrorReporterService; + protected environment: EnvironmentService; + protected configState: ConfigStateService; + protected oAuthService: OAuthService; + protected oAuthConfig: AuthConfig; + protected sessionState: SessionStateService; + protected tenantKey: string; + + abstract checkIfInternalAuth(queryParams?: Params): boolean; + + abstract navigateToLogin(queryParams?: Params): void; + + abstract logout(queryParams?: Params): Observable; + + abstract login(params?: LoginParams | Params): Observable; + + private catchError = err => { + this.httpErrorReporter.reportError(err); + return of(null); + }; + + constructor(protected injector: Injector) { + this.httpErrorReporter = injector.get(HttpErrorReporterService); + this.environment = injector.get(EnvironmentService); + this.configState = injector.get(ConfigStateService); + this.oAuthService = injector.get(OAuthService); + this.sessionState = injector.get(SessionStateService); + this.oAuthConfig = this.environment.getEnvironment().oAuthConfig; + this.tenantKey = injector.get(TENANT_KEY); + + this.listenToOauthErrors(); + } + + async init(): Promise { + const shouldClear = shouldStorageClear( + this.environment.getEnvironment().oAuthConfig.clientId, + oAuthStorage, + ); + if (shouldClear) clearOAuthStorage(oAuthStorage); + + this.oAuthService.configure(this.oAuthConfig); + + this.oAuthService.events + .pipe(filter(event => event.type === 'token_refresh_error')) + .subscribe(() => this.navigateToLogin()); + + return this.oAuthService + .loadDiscoveryDocument() + .then(() => { + if (this.oAuthService.hasValidAccessToken() || !this.oAuthService.getRefreshToken()) { + return Promise.resolve(); + } + + return this.refreshToken(); + }) + .catch(this.catchError); + } + + protected refreshToken() { + return this.oAuthService.refreshToken().catch(() => clearOAuthStorage()); + } + + protected listenToOauthErrors() { + this.oAuthService.events + .pipe( + filter(event => event instanceof OAuthErrorEvent), + tap(() => clearOAuthStorage()), + switchMap(() => this.configState.refreshAppState()), + ) + .subscribe(); + } +} + +function shouldStorageClear(clientId: string, storage: OAuthStorage): boolean { + const key = 'abpOAuthClientId'; + if (!storage.getItem(key)) { + storage.setItem(key, clientId); + return false; + } + + const shouldClear = storage.getItem(key) !== clientId; + if (shouldClear) storage.setItem(key, clientId); + return shouldClear; +} diff --git a/npm/ng-packs/packages/oauth/src/lib/strategies/auth-password-flow-strategy.ts b/npm/ng-packs/packages/oauth/src/lib/strategies/auth-password-flow-strategy.ts new file mode 100644 index 0000000000..703042956d --- /dev/null +++ b/npm/ng-packs/packages/oauth/src/lib/strategies/auth-password-flow-strategy.ts @@ -0,0 +1,101 @@ +import { filter, switchMap, tap } from 'rxjs/operators'; +import { OAuthInfoEvent } from 'angular-oauth2-oidc'; +import { Params, Router } from '@angular/router'; +import { from, Observable, pipe } from 'rxjs'; +import { HttpHeaders } from '@angular/common/http'; +import { AuthFlowStrategy } from './auth-flow-strategy'; +import { removeRememberMe, setRememberMe } from './auth-utils'; +import { LoginParams } from '../models'; +import { clearOAuthStorage } from './clear-o-auth-storage'; + +function getCookieValueByName(name: string) { + const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); + return match ? match[2] : ''; +} + +export class AuthPasswordFlowStrategy extends AuthFlowStrategy { + readonly isInternalAuth = true; + private cookieKey = 'rememberMe'; + private storageKey = 'passwordFlow'; + + private listenToTokenExpiration() { + this.oAuthService.events + .pipe( + filter( + event => + event instanceof OAuthInfoEvent && + event.type === 'token_expires' && + event.info === 'access_token', + ), + ) + .subscribe(() => { + if (this.oAuthService.getRefreshToken()) { + this.refreshToken(); + } else { + this.oAuthService.logOut(); + removeRememberMe(); + this.configState.refreshAppState().subscribe(); + } + }); + } + + async init() { + if (!getCookieValueByName(this.cookieKey) && localStorage.getItem(this.storageKey)) { + this.oAuthService.logOut(); + } + + return super.init().then(() => this.listenToTokenExpiration()); + } + + navigateToLogin(queryParams?: Params) { + const router = this.injector.get(Router); + return router.navigate(['/account/login'], { queryParams }); + } + + checkIfInternalAuth() { + return true; + } + + login(params: LoginParams): Observable { + const tenant = this.sessionState.getTenant(); + + return from( + this.oAuthService.fetchTokenUsingPasswordFlow( + params.username, + params.password, + new HttpHeaders({ ...(tenant && tenant.id && { [this.tenantKey]: tenant.id }) }), + ), + ).pipe(this.pipeToLogin(params)); + } + + pipeToLogin(params: Pick) { + const router = this.injector.get(Router); + + return pipe( + switchMap(() => this.configState.refreshAppState()), + tap(() => { + setRememberMe(params.rememberMe); + if (params.redirectUrl) router.navigate([params.redirectUrl]); + }), + ); + } + + logout(queryParams?: Params) { + const router = this.injector.get(Router); + + return from(this.oAuthService.revokeTokenAndLogout(queryParams)).pipe( + switchMap(() => this.configState.refreshAppState()), + tap(() => { + router.navigateByUrl('/'); + removeRememberMe(); + }), + ); + } + + protected refreshToken() { + return this.oAuthService.refreshToken().catch(() => { + clearOAuthStorage(); + removeRememberMe(); + }); + } +} diff --git a/npm/ng-packs/packages/oauth/src/lib/strategies/index.ts b/npm/ng-packs/packages/oauth/src/lib/strategies/index.ts new file mode 100644 index 0000000000..6def1d4dce --- /dev/null +++ b/npm/ng-packs/packages/oauth/src/lib/strategies/index.ts @@ -0,0 +1,3 @@ +export * from './auth-flow-strategy'; +export * from './auth-code-flow-strategy'; +export * from './auth-password-flow-strategy'; diff --git a/npm/ng-packs/packages/oauth/src/public-api.ts b/npm/ng-packs/packages/oauth/src/public-api.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/npm/ng-packs/packages/oauth/src/test-setup.ts b/npm/ng-packs/packages/oauth/src/test-setup.ts new file mode 100644 index 0000000000..1100b3e8a6 --- /dev/null +++ b/npm/ng-packs/packages/oauth/src/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/npm/ng-packs/packages/oauth/tsconfig.json b/npm/ng-packs/packages/oauth/tsconfig.json new file mode 100644 index 0000000000..2f1f4daffb --- /dev/null +++ b/npm/ng-packs/packages/oauth/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "target": "es2020" + } +} diff --git a/npm/ng-packs/packages/oauth/tsconfig.lib.json b/npm/ng-packs/packages/oauth/tsconfig.lib.json new file mode 100644 index 0000000000..58b88ce56c --- /dev/null +++ b/npm/ng-packs/packages/oauth/tsconfig.lib.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "target": "ES2022", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [], + "lib": ["dom", "es2018"], + "useDefineForClassFields": false + }, + "exclude": ["src/test-setup.ts", "**/*.spec.ts", "jest.config.ts"], + "include": ["**/*.ts"] +} diff --git a/npm/ng-packs/packages/oauth/tsconfig.lib.prod.json b/npm/ng-packs/packages/oauth/tsconfig.lib.prod.json new file mode 100644 index 0000000000..0e06848ce5 --- /dev/null +++ b/npm/ng-packs/packages/oauth/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/npm/ng-packs/packages/oauth/tsconfig.spec.json b/npm/ng-packs/packages/oauth/tsconfig.spec.json new file mode 100644 index 0000000000..be72f24e9b --- /dev/null +++ b/npm/ng-packs/packages/oauth/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"], + "esModuleInterop": true + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.ts"] +}