Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/en/framework/ui/angular/http-error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const appConfig: ApplicationConfig = {

- `ErrorScreenErrorCodes` the error codes that you can pass to `skipHandledErrorCodes` and `forWhichErrors`.
- `skipHandledErrorCodes` the error codes those you don't want to handle.
- `signInWithoutToken` when a request returns 401 and the user has no access token, starts the sign-in and comes back to the current page. By default (`false`) the user is signed out.
- `errorScreen` the screen that you want to show when a route error occurs.
- `component` component that you want to show.
- `forWhichErrors` same as `ErrorScreenErrorCodes`
Expand Down
16 changes: 16 additions & 0 deletions docs/en/framework/ui/angular/oauth-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ export const appConfig: ApplicationConfig = {

It also registers the OAuth configuration initializer and the providers from `angular-oauth2-oidc`.

## Token Storage

`MemoryTokenStorageService` keeps the access token and the ID token in memory and shares them between the tabs of the application with a `SharedWorker`. A new tab waits for these tokens (up to 3 seconds) before it initializes the authentication. `nonce`, `PKCE_verifier` and `session_state` are kept in `sessionStorage`, so tabs that sign in at the same time don't overwrite each other's values. The refresh token is kept in `localStorage`.

The `SharedWorker` is created from a `data:` URL. If the application uses a Content Security Policy, allow it with `worker-src data:`. When the worker can't start, each tab keeps its tokens on its own.

An application started with server-side rendering uses cookie storage instead. If you register your own `OAuthStorage` provider (for example `localStorage`), it replaces this storage.

## Signing In Again

Use `authGuard` on the routes that need a signed-in user. The guard starts the sign-in with the current route, so the user comes back to the same page. The default 401 handler of `@abp/ng.theme.shared` does the same when the access token has expired. When there is no access token, it signs the user out by default; set `signInWithoutToken` in [`withHttpErrorConfig`](./http-error-handling.md) to start the sign-in instead (for example when an anonymous page shouldn't call APIs that need a signed-in user). If you replace these, pass the `returnUrl` to `AuthService.navigateToLogin` yourself.

When refreshing the tokens fails because the authorization server can't be reached or returns a server error, the tokens are kept, since they can still be valid. When the server rejects the refresh token, the tokens are removed and the sign-in starts.

With the authorization code flow, `AuthService.navigateToLogin` and `AuthService.login` share one redirect to the login page, until the page leaves. If you open the login page in your own way (a custom `openUri` in the OAuth configuration, a custom `initCodeFlow` or your own strategy), every request is passed to it.

## API Interceptor

For non-external requests, the OAuth API interceptor adds the `X-Requested-With` header and, when the corresponding values are available, an `Authorization` bearer token, `Accept-Language` and the configured tenant header. Existing authorization, language and tenant headers are preserved. Requests marked with the `IS_EXTERNAL_REQUEST` HTTP context token are sent without those ABP headers. The interceptor also integrates every request with the HTTP wait service.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,18 @@ export class ConfigStateService {
}

private initUpdateStream() {
// A new refresh also cancels the localization request of the previous one, so an older
// configuration can't arrive after a newer request started.
this.updateSubject
.pipe(
switchMap(() =>
this.abpConfigService.get({
includeLocalizationResources: !!this.includeLocalizationResources,
}),
this.abpConfigService
.get({
includeLocalizationResources: !!this.includeLocalizationResources,
})
.pipe(switchMap(appState => this.getLocalizationAndCombineWithAppState(appState))),
),
)
.pipe(switchMap(appState => this.getLocalizationAndCombineWithAppState(appState)))
.subscribe(res => this.store.set(res));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest';
import { of } from 'rxjs';
import { of, Subject } from 'rxjs';
import { AbpApplicationConfigurationService } from '../proxy/volo/abp/asp-net-core/mvc/application-configurations/abp-application-configuration.service';
import {
ApplicationConfigurationDto,
Expand Down Expand Up @@ -355,3 +355,47 @@ describe('ConfigStateService', () => {
});
});
});

describe('ConfigStateService refresh', () => {
const configs: Subject<any>[] = [];
const localizations: Subject<any>[] = [];

const createService = createServiceFactory({
service: ConfigStateService,
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: CORE_OPTIONS, useValue: { skipGetAppConfiguration: true } },
{
provide: AbpApplicationConfigurationService,
useValue: { get: () => configs[configs.push(new Subject()) - 1] },
},
{
provide: AbpApplicationLocalizationService,
useValue: { get: () => localizations[localizations.push(new Subject()) - 1] },
},
IncludeLocalizationResourcesProvider,
],
});

it('should not publish an older configuration after a newer refresh started', () => {
const configState = createService().service;
const results: string[] = [];
configState
.createOnUpdateStream(state => state.currentUser?.userName)
.subscribe(userName => results.push(userName));
const withUserName = (userName: string) => ({
...CONFIG_STATE_DATA,
currentUser: { ...CONFIG_STATE_DATA.currentUser, userName },
});

configState.refreshAppState();
configs[0].next(withUserName('old'));
configState.refreshAppState();
localizations[0].next(APPLICATION_LOCALIZATION_DATA);
configs[1].next(withUserName('new'));
localizations[1].next(APPLICATION_LOCALIZATION_DATA);

expect(results).toEqual(['new']);
});
});
Loading
Loading