diff --git a/docs/en/UI/Angular/Authorization.md b/docs/en/UI/Angular/Authorization.md index 448a83e0cd..2e4a9af343 100644 --- a/docs/en/UI/Angular/Authorization.md +++ b/docs/en/UI/Angular/Authorization.md @@ -1,9 +1,9 @@ ## Authorization in Angular UI -OAuth is preconfigured in Angular application templates. So, when you start a project using the CLI (or Suite, for that matter), authorization already works. ABP Angular UI packages are using [angular-oauth2-oidc library](https://github.com/manfredsteyer/angular-oauth2-oidc#logging-in) for managing OAuth in the Angular client. +OAuth is preconfigured in Angular application templates. So, when you start a project using the CLI (or Suite, for that matter), authorization already works. ABP Angular UI packages are using [angular-oauth2-oidc library](https://github.com/manfredsteyer/angular-oauth2-oidc#logging-in) for managing OAuth in the Angular client. You can find **OAuth configuration** in the _environment.ts_ files. -### Authorization Code Flow +### Authorization Code Flow ```js import { Config } from '@abp/ng.core'; @@ -29,7 +29,6 @@ export const environment = { This configuration results in an [OAuth authorization code flow with PKCE](https://tools.ietf.org/html/rfc7636). According to this flow, the user is redirected to an external login page which is built with MVC. So, if you need **to customize the login page**, please follow [this community article](https://community.abp.io/articles/how-to-customize-the-login-page-for-mvc-razor-page-applications-9a40f3cd). - ### Resource Owner Password Flow If you have used the [Angular UI account module](./Account-Module) in your project, you can switch to the resource owner password flow by changing the OAuth configuration in the _environment.ts_ files as shown below: @@ -52,3 +51,128 @@ export const environment = { ``` According to this flow, the user is redirected to the login page in the account module. + +### Error Filtering + +In [AuthFlowStrategy](https://github.com/abpframework/abp/blob/21e70fd66154d4064d03b1a438f20a2e4318715e/npm/ng-packs/packages/oauth/src/lib/strategies/auth-flow-strategy.ts#L24) class, there is a method called `listenToOauthErrors` that listens to `OAuthErrorEvent` errors. This method clears the localStorage for OAuth keys. However, in certain cases, we might want to skip this process. To achieve this, we can use the `AuthErrorFilterService`. +The `AuthErrorFilterService` is an abstract service that needs to be replaced with a custom implementation + +> By default, this service is replaced in the `@abp/ng.oauth` package + +**Usage** + +1.Create an auth-filter.provider + +```js +import { APP_INITIALIZER, inject } from '@angular/core'; +import { AuthErrorFilter, AuthErrorEvent, AuthErrorFilterService } from '@abp/ng.core'; +import { eCustomersAuthFilterNames } from '../enums'; + +export const CUSTOMERS_AUTH_FILTER_PROVIDER = [ + { provide: APP_INITIALIZER, useFactory: configureAuthFilter, multi: true }, +]; + +type Reason = object & { error: { grant_type: string | undefined } }; + +function configureAuthFilter() { + const errorFilterService = inject( + AuthErrorFilterService, AuthErrorEvent>, + ); + const filter: AuthErrorFilter = { + id: eCustomersAuthFilterNames.LinkedUser, + executable: true, + execute: (event: AuthErrorEvent) => { + const { reason } = event; + const { + error: { grant_type }, + } = (reason || {}); + + return !!grant_type && grant_type === eCustomersAuthFilterNames.LinkedUser; + }, + }; + + return () => errorFilterService.add(filter); +} +``` + +- `AuthErrorFilter:` is a model for filter object and it have 3 properties + + - `id:` a unique key in the list for the filter object + - `executable:` a status for the filter object. If it's false then it won't work, yet it'll stay in the list + - `execute:` a function that stores the skip logic + + 2.Add to the FeatureConfigModule + +```js +import { ModuleWithProviders, NgModule } from "@angular/core"; +import { CUSTOMERS_AUTH_FILTER_PROVIDER } from "./providers/auth-filter.provider"; + +@NgModule() +export class CustomersConfigModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: CustomersConfigModule, + providers: [CUSTOMERS_AUTH_FILTER_PROVIDER], + }; + } +} +``` + +Now it'll skip the clearing of OAuth storage keys for `LinkedUser` grant_type if any `OAuthErrorEvent` occurs + +**Replace with custom implementation** + +- Use the `AbstractAuthErrorFilter` class for signs of process. + +**Example** + +`my-auth-error-filter.service.ts` + +```js +import { Injectable, signal } from '@angular/core'; +import { MyAuthErrorEvent } from 'angular-my-auth-oidc'; +import { AbstractAuthErrorFilter, AuthErrorFilter } from '@abp/ng.core'; + +@Injectable({ providedIn: 'root' }) +export class OAuthErrorFilterService extends AbstractAuthErrorFilter< + AuthErrorFilter, + MyAuthErrorEvent +> { + protected readonly _filters = signal>>([]); + readonly filters = this._filters.asReadonly(); + + get(id: string): AuthErrorFilter { + return this._filters().find(({ id: _id }) => _id === id); + } + + add(filter: AuthErrorFilter): void { + this._filters.update(items => [...items, filter]); + } + + patch(item: Partial>): void { + const _item = this.filters().find(({ id }) => id === item.id); + if (!_item) { + return; + } + + Object.assign(_item, item); + } + + remove(id: string): void { + const item = this.filters().find(({ id: _id }) => _id === id); + if (!item) { + return; + } + + this._filters.update(items => items.filter(({ id: _id }) => _id !== id)); + } + + run(event: MyAuthErrorEvent): boolean { + return this.filters() + .filter(({ executable }) => !!executable) + .map(({ execute }) => execute(event)) + .some(item => item); + } +} + +``` diff --git a/npm/ng-packs/packages/core/src/lib/abstracts/auth-error-filter.ts b/npm/ng-packs/packages/core/src/lib/abstracts/auth-error-filter.ts new file mode 100644 index 0000000000..f7d964b37f --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/abstracts/auth-error-filter.ts @@ -0,0 +1,40 @@ +import { AuthErrorEvent, AuthErrorFilter } from '../models'; + +export abstract class AbstractAuthErrorFilter { + abstract get(id: string): T; + abstract add(filter: T): void; + abstract patch(item: Partial): void; + abstract remove(id: string): void; + abstract run(event: E): boolean; +} + +export class AuthErrorFilterService< + T = AuthErrorFilter, + E = AuthErrorEvent, +> extends AbstractAuthErrorFilter { + private warningMessage() { + console.error('You should add @abp/ng-oauth packages or create your own auth packages.'); + } + + get(id: string): T { + this.warningMessage(); + throw new Error('not implemented'); + } + + add(filter: T): void { + this.warningMessage(); + } + + patch(item: Partial): void { + this.warningMessage(); + } + + remove(id: string): void { + this.warningMessage(); + } + + run(event: E): boolean { + this.warningMessage(); + throw new Error('not implemented'); + } +} diff --git a/npm/ng-packs/packages/core/src/lib/abstracts/auth.service.ts b/npm/ng-packs/packages/core/src/lib/abstracts/auth.service.ts index 3c6d62fae6..8527232d5f 100644 --- a/npm/ng-packs/packages/core/src/lib/abstracts/auth.service.ts +++ b/npm/ng-packs/packages/core/src/lib/abstracts/auth.service.ts @@ -33,9 +33,8 @@ export class AuthService implements IAuthService { navigateToLogin(queryParams?: Params): void {} - get isInternalAuth() { + get isInternalAuth(): boolean { throw new Error('not implemented'); - return false; } get isAuthenticated(): boolean { diff --git a/npm/ng-packs/packages/core/src/lib/abstracts/index.ts b/npm/ng-packs/packages/core/src/lib/abstracts/index.ts index f9b4794de0..97f3c524f4 100644 --- a/npm/ng-packs/packages/core/src/lib/abstracts/index.ts +++ b/npm/ng-packs/packages/core/src/lib/abstracts/index.ts @@ -3,3 +3,4 @@ export * from './ng-model.component'; export * from './auth.guard'; export * from './auth.service'; export * from './auth-response.model'; +export * from './auth-error-filter'; diff --git a/npm/ng-packs/packages/core/src/lib/core.module.ts b/npm/ng-packs/packages/core/src/lib/core.module.ts index d39912b496..5eb6c82f0e 100644 --- a/npm/ng-packs/packages/core/src/lib/core.module.ts +++ b/npm/ng-packs/packages/core/src/lib/core.module.ts @@ -187,6 +187,7 @@ export class CoreModule { provide: OTHERS_GROUP, useValue: options.othersGroup || 'AbpUi::OthersGroup', }, + AuthErrorFilterService, IncludeLocalizationResourcesProvider, { provide: DYNAMIC_LAYOUTS_TOKEN, diff --git a/npm/ng-packs/packages/core/src/lib/models/auth-events.ts b/npm/ng-packs/packages/core/src/lib/models/auth-events.ts new file mode 100644 index 0000000000..4b9e49eb0d --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/models/auth-events.ts @@ -0,0 +1,54 @@ +export type EventType = + | 'discovery_document_loaded' + | 'jwks_load_error' + | 'invalid_nonce_in_state' + | 'discovery_document_load_error' + | 'discovery_document_validation_error' + | 'user_profile_loaded' + | 'user_profile_load_error' + | 'token_received' + | 'token_error' + | 'code_error' + | 'token_refreshed' + | 'token_refresh_error' + | 'silent_refresh_error' + | 'silently_refreshed' + | 'silent_refresh_timeout' + | 'token_validation_error' + | 'token_expires' + | 'session_changed' + | 'session_error' + | 'session_terminated' + | 'session_unchanged' + | 'logout' + | 'popup_closed' + | 'popup_blocked' + | 'token_revoke_error'; + +export abstract class AuthEvent { + constructor(public readonly type: EventType) { + this.type = type; + } +} + +export class AuthSuccessEvent extends AuthEvent { + constructor(public readonly type: EventType, public readonly info?: any) { + super(type); + } +} + +export class AuthInfoEvent extends AuthEvent { + constructor(public readonly type: EventType, public readonly info?: any) { + super(type); + } +} + +export class AuthErrorEvent extends AuthEvent { + constructor( + public readonly type: EventType, + public readonly reason: object, + public readonly params?: object, + ) { + super(type); + } +} diff --git a/npm/ng-packs/packages/core/src/lib/models/auth.ts b/npm/ng-packs/packages/core/src/lib/models/auth.ts index dcaf6e7f11..23818878f4 100644 --- a/npm/ng-packs/packages/core/src/lib/models/auth.ts +++ b/npm/ng-packs/packages/core/src/lib/models/auth.ts @@ -1,5 +1,6 @@ -import { UnaryFunction } from 'rxjs'; import { Injector } from '@angular/core'; +import { UnaryFunction } from 'rxjs'; +import { AuthErrorEvent } from './auth-events'; export interface LoginParams { username: string; @@ -13,7 +14,13 @@ export type PipeToLoginFn = ( injector: Injector, ) => UnaryFunction; /** - * @deprecated The interface should not be used anymore. + * @deprecated The interface should not be used anymore. */ export type SetTokenResponseToStorageFn = (tokenRes: T) => void; export type CheckAuthenticationStateFn = (injector: Injector) => void; + +export interface AuthErrorFilter { + id: string; + executable: boolean; + execute: (event: T) => boolean; +} diff --git a/npm/ng-packs/packages/core/src/lib/models/index.ts b/npm/ng-packs/packages/core/src/lib/models/index.ts index 0549dd0128..c343ee2440 100644 --- a/npm/ng-packs/packages/core/src/lib/models/index.ts +++ b/npm/ng-packs/packages/core/src/lib/models/index.ts @@ -7,3 +7,4 @@ export * from './rest'; export * from './session'; export * from './utility'; export * from './auth'; +export * from './auth-events'; diff --git a/npm/ng-packs/packages/oauth/src/lib/oauth.module.ts b/npm/ng-packs/packages/oauth/src/lib/oauth.module.ts index 3ee3741d04..2adac2a23a 100644 --- a/npm/ng-packs/packages/oauth/src/lib/oauth.module.ts +++ b/npm/ng-packs/packages/oauth/src/lib/oauth.module.ts @@ -1,9 +1,11 @@ import { APP_INITIALIZER, ModuleWithProviders, NgModule, Provider } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { OAuthModule, OAuthStorage } from 'angular-oauth2-oidc'; import { AbpLocalStorageService, ApiInterceptor, + AuthErrorFilterService, AuthGuard, authGuard, AuthService, @@ -11,9 +13,8 @@ import { noop, PIPE_TO_LOGIN_FN_KEY, } from '@abp/ng.core'; -import { AbpOAuthService } from './services'; +import { AbpOAuthService, OAuthErrorFilterService } from './services'; import { OAuthConfigurationHandler } from './handlers/oauth-configuration.handler'; -import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { OAuthApiInterceptor } from './interceptors/api.interceptor'; import { AbpOAuthGuard, abpOAuthGuard } from './guards/oauth.guard'; import { NavigateToManageProfileProvider } from './providers'; @@ -65,6 +66,7 @@ export class AbpOAuthModule { }, OAuthModule.forRoot().providers as Provider[], { provide: OAuthStorage, useClass: AbpLocalStorageService }, + { provide: AuthErrorFilterService, useExisting: OAuthErrorFilterService }, ], }; } diff --git a/npm/ng-packs/packages/oauth/src/lib/services/index.ts b/npm/ng-packs/packages/oauth/src/lib/services/index.ts index ddc54916a3..375177f3b5 100644 --- a/npm/ng-packs/packages/oauth/src/lib/services/index.ts +++ b/npm/ng-packs/packages/oauth/src/lib/services/index.ts @@ -1 +1,2 @@ export * from './oauth.service'; +export * from './oauth-error-filter.service'; diff --git a/npm/ng-packs/packages/oauth/src/lib/services/oauth-error-filter.service.ts b/npm/ng-packs/packages/oauth/src/lib/services/oauth-error-filter.service.ts new file mode 100644 index 0000000000..1c5fafb86e --- /dev/null +++ b/npm/ng-packs/packages/oauth/src/lib/services/oauth-error-filter.service.ts @@ -0,0 +1,45 @@ +import { Injectable, signal } from '@angular/core'; +import { OAuthErrorEvent } from 'angular-oauth2-oidc'; +import { AbstractAuthErrorFilter, AuthErrorFilter } from '@abp/ng.core'; + +@Injectable({ providedIn: 'root' }) +export class OAuthErrorFilterService extends AbstractAuthErrorFilter< + AuthErrorFilter, + OAuthErrorEvent +> { + protected readonly _filters = signal>>([]); + readonly filters = this._filters.asReadonly(); + + get(id: string): AuthErrorFilter { + return this._filters().find(({ id: _id }) => _id === id); + } + + add(filter: AuthErrorFilter): void { + this._filters.update(items => [...items, filter]); + } + + patch(item: Partial>): void { + const _item = this.filters().find(({ id }) => id === item.id); + if (!_item) { + return; + } + + Object.assign(_item, item); + } + + remove(id: string): void { + const item = this.filters().find(({ id: _id }) => _id === id); + if (!item) { + return; + } + + this._filters.update(items => items.filter(({ id: _id }) => _id !== id)); + } + + run(event: OAuthErrorEvent): boolean { + return this.filters() + .filter(({ executable }) => !!executable) + .map(({ execute }) => execute(event)) + .some(item => item); + } +} diff --git a/npm/ng-packs/packages/oauth/src/lib/services/oauth.service.ts b/npm/ng-packs/packages/oauth/src/lib/services/oauth.service.ts index 8b10d39bf2..2e615c95a5 100644 --- a/npm/ng-packs/packages/oauth/src/lib/services/oauth.service.ts +++ b/npm/ng-packs/packages/oauth/src/lib/services/oauth.service.ts @@ -44,10 +44,10 @@ export class AbpOAuthService implements IAuthService { } logout(queryParams?: Params): Observable { - - if(!this.strategy){ - return EMPTY + if (!this.strategy) { + return EMPTY; } + return this.strategy.logout(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 index b47eeb3ac7..e5189966bd 100644 --- 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 @@ -1,13 +1,16 @@ import { Injector } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; import { Params, Router } from '@angular/router'; + +import { Observable, of } from 'rxjs'; +import { filter, map, switchMap, take, tap } from 'rxjs/operators'; import { AuthConfig, OAuthErrorEvent, OAuthService as OAuthService2, OAuthStorage, } from 'angular-oauth2-oidc'; -import { Observable, of } from 'rxjs'; -import { filter, map, switchMap, take, tap } from 'rxjs/operators'; + import { AbpLocalStorageService, ConfigStateService, @@ -17,9 +20,10 @@ import { SessionStateService, TENANT_KEY, } from '@abp/ng.core'; + import { clearOAuthStorage } from '../utils/clear-o-auth-storage'; import { oAuthStorage } from '../utils/oauth-storage'; -import { HttpErrorResponse } from '@angular/common/http'; +import { OAuthErrorFilterService } from '../services'; export abstract class AuthFlowStrategy { abstract readonly isInternalAuth: boolean; @@ -34,6 +38,8 @@ export abstract class AuthFlowStrategy { protected tenantKey: string; protected router: Router; + protected readonly oAuthErrorFilterService: OAuthErrorFilterService; + abstract checkIfInternalAuth(queryParams?: Params): boolean; abstract navigateToLogin(queryParams?: Params): void; abstract logout(queryParams?: Params): Observable; @@ -54,6 +60,7 @@ export abstract class AuthFlowStrategy { this.oAuthConfig = this.environment.getEnvironment().oAuthConfig || {}; this.tenantKey = injector.get(TENANT_KEY); this.router = injector.get(Router); + this.oAuthErrorFilterService = injector.get(OAuthErrorFilterService); this.listenToOauthErrors(); } @@ -97,6 +104,7 @@ export abstract class AuthFlowStrategy { if (redirect_uri && redirect_uri !== '/') { return redirect_uri; } + return '/'; }), switchMap(redirectUri => @@ -118,7 +126,12 @@ export abstract class AuthFlowStrategy { this.oAuthService.events .pipe( filter(event => event instanceof OAuthErrorEvent), - tap(() => clearOAuthStorage()), + tap((err: OAuthErrorEvent) => { + const shouldSkip = this.oAuthErrorFilterService.run(err); + if (!shouldSkip) { + clearOAuthStorage(); + } + }), switchMap(() => this.configState.refreshAppState()), ) .subscribe();