mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
AAE-39851 replace coreAuth with provideBffAuth to call BFF
This commit is contained in:
@@ -20,6 +20,7 @@ import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot } fr
|
|||||||
import { AuthenticationService } from '../services/authentication.service';
|
import { AuthenticationService } from '../services/authentication.service';
|
||||||
import { AuthGuardService } from './auth-guard.service';
|
import { AuthGuardService } from './auth-guard.service';
|
||||||
import { JwtHelperService } from '../services/jwt-helper.service';
|
import { JwtHelperService } from '../services/jwt-helper.service';
|
||||||
|
import { BffAuthGuard } from '../services/bff/bff-auth.guard';
|
||||||
|
|
||||||
const ticketChangeRedirect = (event: StorageEvent, authGuardBaseService: AuthGuardService, url: string): void => {
|
const ticketChangeRedirect = (event: StorageEvent, authGuardBaseService: AuthGuardService, url: string): void => {
|
||||||
if (event.newValue) {
|
if (event.newValue) {
|
||||||
@@ -45,7 +46,7 @@ const ticketChangeHandler = (event: StorageEvent, authGuardBaseService: AuthGuar
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AuthGuard: CanActivateFn = async (_: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> => {
|
export const LegacyAuthGuard: CanActivateFn = async (_: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> => {
|
||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
const jwtHelperService = inject(JwtHelperService);
|
const jwtHelperService = inject(JwtHelperService);
|
||||||
const authGuardBaseService = inject(AuthGuardService);
|
const authGuardBaseService = inject(AuthGuardService);
|
||||||
@@ -59,3 +60,4 @@ export const AuthGuard: CanActivateFn = async (_: ActivatedRouteSnapshot, state:
|
|||||||
|
|
||||||
return authGuardBaseService.redirectToUrl(state.url);
|
return authGuardBaseService.redirectToUrl(state.url);
|
||||||
};
|
};
|
||||||
|
export const AuthGuard: CanActivateFn = BffAuthGuard;
|
||||||
|
|||||||
@@ -16,18 +16,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { inject, ModuleWithProviders, NgModule, InjectionToken, provideAppInitializer, EnvironmentProviders, Provider } from '@angular/core';
|
import { inject, ModuleWithProviders, NgModule, InjectionToken, provideAppInitializer, EnvironmentProviders, Provider } from '@angular/core';
|
||||||
import { AUTH_CONFIG, OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc';
|
import { AUTH_CONFIG, OAuthService, OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc';
|
||||||
import { AuthenticationService } from '../services/authentication.service';
|
import { AuthenticationService } from '../services/authentication.service';
|
||||||
import { AuthModuleConfig, AUTH_MODULE_CONFIG } from './auth-config';
|
import { AuthModuleConfig, AUTH_MODULE_CONFIG } from './auth-config';
|
||||||
import { authConfigFactory, AuthConfigService } from './auth-config.service';
|
import { authConfigFactory, AuthConfigService } from './auth-config.service';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RedirectAuthService } from './redirect-auth.service';
|
import { RedirectAuthService } from './redirect-auth.service';
|
||||||
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi, withXsrfConfiguration } from '@angular/common/http';
|
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptors, withInterceptorsFromDi, withXsrfConfiguration } from '@angular/common/http';
|
||||||
import { TokenInterceptor } from './token.interceptor';
|
import { TokenInterceptor } from './token.interceptor';
|
||||||
import { StorageService } from '../../common/services/storage.service';
|
import { StorageService } from '../../common/services/storage.service';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { AUTH_ROUTES } from './auth.routes';
|
import { AUTH_ROUTES } from './auth.routes';
|
||||||
import { Authentication, AuthenticationInterceptor } from '@alfresco/adf-core/auth';
|
import { Authentication, AuthenticationInterceptor } from '@alfresco/adf-core/auth';
|
||||||
|
import { BffAuthService } from '../services/bff/bff-auth.service';
|
||||||
|
import { UserAccessService } from '../services/user-access.service';
|
||||||
|
import { BffUserAccessService } from '../services/bff/bff-user-access.service';
|
||||||
|
import { bffAuthErrorInterceptor } from '../services/bff/bff-auth-error.interceptor';
|
||||||
|
|
||||||
export const JWT_STORAGE_SERVICE = new InjectionToken<OAuthStorage>('JWT_STORAGE_SERVICE', {
|
export const JWT_STORAGE_SERVICE = new InjectionToken<OAuthStorage>('JWT_STORAGE_SERVICE', {
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -73,6 +77,31 @@ export function provideCoreAuth(config: AuthModuleConfig = { useHash: false }):
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the necessary Angular providers for BFF (Backend For Frontend) authentication.
|
||||||
|
*
|
||||||
|
* This function returns an array of providers required to set up authentication using the BffAuthService.
|
||||||
|
* It includes HTTP client, router configuration with BFF-specific routes, and maps authentication services
|
||||||
|
* to the BffAuthService implementation.
|
||||||
|
*
|
||||||
|
* @returns An array of Angular providers for BFF authentication.
|
||||||
|
*/
|
||||||
|
export function provideBffAuth(): (Provider | EnvironmentProviders)[] {
|
||||||
|
return [
|
||||||
|
provideHttpClient(
|
||||||
|
withXsrfConfiguration({ cookieName: 'CSRF-TOKEN', headerName: 'X-CSRF-TOKEN' }),
|
||||||
|
withInterceptors([bffAuthErrorInterceptor])
|
||||||
|
),
|
||||||
|
BffAuthService,
|
||||||
|
{ provide: UserAccessService, useClass: BffUserAccessService },
|
||||||
|
{ provide: OAuthStorage, useFactory: () => ({ getItem: () => null, setItem: () => null, removeItem: () => null }) },
|
||||||
|
{ provide: OAuthService, useFactory: () => ({}) },
|
||||||
|
{ provide: AUTH_MODULE_CONFIG, useFactory: () => ({ useHash: false, preventClearHashAfterLogin: true }) },
|
||||||
|
{ provide: AuthService, useExisting: BffAuthService },
|
||||||
|
{ provide: AuthenticationService, useExisting: BffAuthService }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/** @deprecated use `provideCoreAuth()` provider api instead */
|
/** @deprecated use `provideCoreAuth()` provider api instead */
|
||||||
@NgModule({
|
@NgModule({
|
||||||
providers: [...provideCoreAuth()]
|
providers: [...provideCoreAuth()]
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export * from './services/jwt-helper.service';
|
|||||||
export * from './services/oauth2.service';
|
export * from './services/oauth2.service';
|
||||||
export * from './services/user-access.service';
|
export * from './services/user-access.service';
|
||||||
|
|
||||||
|
export * from './services/bff/bff-auth.service';
|
||||||
|
export * from './services/bff/bff-auth.guard';
|
||||||
|
|
||||||
export * from './basic-auth/basic-alfresco-auth.service';
|
export * from './basic-auth/basic-alfresco-auth.service';
|
||||||
export * from './basic-auth/process-auth';
|
export * from './basic-auth/process-auth';
|
||||||
export * from './basic-auth/content-auth';
|
export * from './basic-auth/content-auth';
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HttpErrorResponse, HttpRequest } from '@angular/common/http';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { EMPTY, throwError, firstValueFrom } from 'rxjs';
|
||||||
|
import { bffAuthErrorInterceptor } from './bff-auth-error.interceptor';
|
||||||
|
|
||||||
|
describe('bffAuthErrorInterceptor', () => {
|
||||||
|
it('should return EMPTY when 401 error occurs on /bff/ URL', async () => {
|
||||||
|
const req = new HttpRequest('GET', '/bff/resource');
|
||||||
|
const httpError = new HttpErrorResponse({ status: 401, url: '/bff/resource' });
|
||||||
|
const next = () => throwError(() => httpError);
|
||||||
|
|
||||||
|
const result$ = TestBed.runInInjectionContext(() => bffAuthErrorInterceptor(req, next));
|
||||||
|
|
||||||
|
const result = await firstValueFrom(result$, { defaultValue: null });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rethrow error when 401 on non-/bff/ URL', async () => {
|
||||||
|
const req = new HttpRequest('GET', '/api/resource');
|
||||||
|
const httpError = new HttpErrorResponse({ status: 401, url: '/api/resource' });
|
||||||
|
const next = () => throwError(() => httpError);
|
||||||
|
|
||||||
|
const result$ = TestBed.runInInjectionContext(() => bffAuthErrorInterceptor(req, next));
|
||||||
|
|
||||||
|
await expectAsync(firstValueFrom(result$)).toBeRejectedWith(httpError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rethrow error when status is not 401 even on /bff/ URL', async () => {
|
||||||
|
const req = new HttpRequest('GET', '/bff/resource');
|
||||||
|
const httpError = new HttpErrorResponse({ status: 500, url: '/bff/resource' });
|
||||||
|
const next = () => throwError(() => httpError);
|
||||||
|
|
||||||
|
const result$ = TestBed.runInInjectionContext(() => bffAuthErrorInterceptor(req, next));
|
||||||
|
|
||||||
|
await expectAsync(firstValueFrom(result$)).toBeRejectedWith(httpError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass through successful response without intercepting', async () => {
|
||||||
|
const req = new HttpRequest('GET', '/bff/resource');
|
||||||
|
const next = () => EMPTY;
|
||||||
|
|
||||||
|
const result$ = TestBed.runInInjectionContext(() => bffAuthErrorInterceptor(req, next));
|
||||||
|
|
||||||
|
const result = await firstValueFrom(result$, { defaultValue: null });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { EMPTY, throwError } from 'rxjs';
|
||||||
|
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
export const bffAuthErrorInterceptor: HttpInterceptorFn = (req, next) =>
|
||||||
|
next(req).pipe(
|
||||||
|
catchError((err: HttpErrorResponse) => {
|
||||||
|
console.log('%c[bffAuthErrorInterceptor] err: ', 'color: red;', err);
|
||||||
|
if (err.status === 401 && req.url.includes('/bff/')) {
|
||||||
|
const returnUrl = window.location.pathname + window.location.search;
|
||||||
|
window.location.href = `/bff/login?returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return throwError(() => err);
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
|
||||||
|
import { firstValueFrom, Observable, of, throwError } from 'rxjs';
|
||||||
|
import { BffAuthGuard } from './bff-auth.guard';
|
||||||
|
import { BffAuthService, BffUserResponse } from './bff-auth.service';
|
||||||
|
|
||||||
|
describe('BffAuthGuard', () => {
|
||||||
|
let bffAuthService: jasmine.SpyObj<BffAuthService>;
|
||||||
|
let mockRoute: ActivatedRouteSnapshot;
|
||||||
|
let mockState: RouterStateSnapshot;
|
||||||
|
|
||||||
|
const mockAuthenticatedUser: BffUserResponse = {
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: {
|
||||||
|
sub: 'user123',
|
||||||
|
email: 'user@example.com',
|
||||||
|
hxp_account: 'account123',
|
||||||
|
name: 'Test User',
|
||||||
|
email_verified: true,
|
||||||
|
preferred_username: 'testuser',
|
||||||
|
given_name: 'Test',
|
||||||
|
family_name: 'User',
|
||||||
|
roles: ['ROLE_USER'],
|
||||||
|
appKey: 'app123'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUnauthenticatedUser: BffUserResponse = {
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: {
|
||||||
|
sub: '',
|
||||||
|
email: '',
|
||||||
|
hxp_account: '',
|
||||||
|
name: '',
|
||||||
|
email_verified: false,
|
||||||
|
preferred_username: '',
|
||||||
|
given_name: '',
|
||||||
|
family_name: '',
|
||||||
|
roles: [],
|
||||||
|
appKey: ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bffAuthService = jasmine.createSpyObj('BffAuthService', ['getUser', 'login']);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [{ provide: BffAuthService, useValue: bffAuthService }]
|
||||||
|
});
|
||||||
|
|
||||||
|
mockRoute = {} as ActivatedRouteSnapshot;
|
||||||
|
mockState = { url: '/protected-route' } as RouterStateSnapshot;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow navigation when user is authenticated', async () => {
|
||||||
|
bffAuthService.getUser.and.returnValue(of(mockAuthenticatedUser));
|
||||||
|
|
||||||
|
const result$ = TestBed.runInInjectionContext(() => BffAuthGuard(mockRoute, mockState)) as Observable<boolean>;
|
||||||
|
const result = await firstValueFrom(result$);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(bffAuthService.getUser).toHaveBeenCalled();
|
||||||
|
expect(bffAuthService.login).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny navigation and call login when user is not authenticated', async () => {
|
||||||
|
bffAuthService.getUser.and.returnValue(of(mockUnauthenticatedUser));
|
||||||
|
|
||||||
|
const result$ = TestBed.runInInjectionContext(() => BffAuthGuard(mockRoute, mockState)) as Observable<boolean>;
|
||||||
|
const result = await firstValueFrom(result$);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(bffAuthService.getUser).toHaveBeenCalled();
|
||||||
|
expect(bffAuthService.login).toHaveBeenCalledWith('/protected-route');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny navigation and call login when getUser throws an error', async () => {
|
||||||
|
const error = new Error('Network error');
|
||||||
|
bffAuthService.getUser.and.returnValue(throwError(() => error));
|
||||||
|
|
||||||
|
const result$ = TestBed.runInInjectionContext(() => BffAuthGuard(mockRoute, mockState)) as Observable<boolean>;
|
||||||
|
const result = await firstValueFrom(result$);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(bffAuthService.getUser).toHaveBeenCalled();
|
||||||
|
expect(bffAuthService.login).toHaveBeenCalledWith('/protected-route');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass correct state URL to login method', async () => {
|
||||||
|
const customState = { url: '/custom/path?query=123' } as RouterStateSnapshot;
|
||||||
|
bffAuthService.getUser.and.returnValue(of(mockUnauthenticatedUser));
|
||||||
|
|
||||||
|
const result$ = TestBed.runInInjectionContext(() => BffAuthGuard(mockRoute, customState)) as Observable<boolean>;
|
||||||
|
await firstValueFrom(result$);
|
||||||
|
|
||||||
|
expect(bffAuthService.login).toHaveBeenCalledWith('/custom/path?query=123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty state URL', async () => {
|
||||||
|
const emptyState = { url: '' } as RouterStateSnapshot;
|
||||||
|
bffAuthService.getUser.and.returnValue(of(mockUnauthenticatedUser));
|
||||||
|
|
||||||
|
const result$ = TestBed.runInInjectionContext(() => BffAuthGuard(mockRoute, emptyState)) as Observable<boolean>;
|
||||||
|
await firstValueFrom(result$);
|
||||||
|
|
||||||
|
expect(bffAuthService.login).toHaveBeenCalledWith('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CanActivateFn } from '@angular/router';
|
||||||
|
import { catchError, map, of } from 'rxjs';
|
||||||
|
import { BffAuthService, BffUserResponse } from './bff-auth.service';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
/**
|
||||||
|
* Guard function for route activation that checks user authentication via BffAuthService.
|
||||||
|
* If the user is unauthenticated, it handles redirection and triggers login.
|
||||||
|
*
|
||||||
|
* @param _route - The activated route snapshot (unused).
|
||||||
|
* @param state - The router state snapshot containing the target URL.
|
||||||
|
* @returns An Observable emitting `true` if the user is authenticated, or `false` otherwise.
|
||||||
|
*/
|
||||||
|
export const BffAuthGuard: CanActivateFn = (_route, state) => {
|
||||||
|
const auth = inject(BffAuthService);
|
||||||
|
|
||||||
|
return auth.getUser().pipe(
|
||||||
|
map((userResponse) => handleAuthRedirectIfUnauthenticated(userResponse, state.url, auth)),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('[BffAuthGuard] error: ', error);
|
||||||
|
console.error('[BffAuthGuard] state.url: ', state.url);
|
||||||
|
auth.login(state.url);
|
||||||
|
return of(false);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles authentication redirect if the user is unauthenticated.
|
||||||
|
*
|
||||||
|
* @param userResponse The response object containing authentication status.
|
||||||
|
* @param url The URL to redirect to after authentication.
|
||||||
|
* @param auth The BffAuthService instance used for authentication actions.
|
||||||
|
* @returns True if the user is authenticated, otherwise false.
|
||||||
|
*/
|
||||||
|
function handleAuthRedirectIfUnauthenticated(userResponse: BffUserResponse, url: string, auth: BffAuthService): boolean {
|
||||||
|
console.log('%c[BffAuthGuard] userResponse.isAuthenticated: ', 'color: orange;', userResponse.isAuthenticated);
|
||||||
|
if (userResponse.isAuthenticated) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
console.log('%c[BffAuthGuard] not authenticated, redirect to bff/login, state.url: ', 'color: orange;', url);
|
||||||
|
auth.login(url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { BffAuthService, BffUserResponse } from './bff-auth.service';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { provideHttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
|
|
||||||
|
describe('BffAuthService', () => {
|
||||||
|
let service: BffAuthService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
const mockAuthenticatedUser: BffUserResponse = {
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: {
|
||||||
|
sub: 'user-123',
|
||||||
|
email: 'test@example.com',
|
||||||
|
hxp_account: 'account-456',
|
||||||
|
name: 'Test User',
|
||||||
|
email_verified: true,
|
||||||
|
preferred_username: 'testuser',
|
||||||
|
given_name: 'Test',
|
||||||
|
family_name: 'User',
|
||||||
|
roles: ['admin', 'user'],
|
||||||
|
appKey: 'app-key-789'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUnauthenticatedUser: BffUserResponse = {
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: {
|
||||||
|
sub: '',
|
||||||
|
email: '',
|
||||||
|
hxp_account: '',
|
||||||
|
name: '',
|
||||||
|
email_verified: false,
|
||||||
|
preferred_username: '',
|
||||||
|
given_name: '',
|
||||||
|
family_name: '',
|
||||||
|
roles: [],
|
||||||
|
appKey: ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [BffAuthService, provideHttpClient(), provideHttpClientTesting()]
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUser', () => {
|
||||||
|
it('should return authenticated user when user is logged in', async () => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockAuthenticatedUser));
|
||||||
|
|
||||||
|
const resultPromise = firstValueFrom(service.getUser());
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((reqObj) => reqObj.url.includes('/bff/user'));
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush(mockAuthenticatedUser);
|
||||||
|
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
expect(result).toEqual(mockAuthenticatedUser);
|
||||||
|
expect(result.isAuthenticated).toBe(true);
|
||||||
|
expect(result.user.email).toBe('test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return unauthenticated user when user is not logged in', async () => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockUnauthenticatedUser));
|
||||||
|
|
||||||
|
const resultPromise = firstValueFrom(service.getUser());
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((reqObj) => reqObj.url.includes('/bff/user'));
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush(mockUnauthenticatedUser);
|
||||||
|
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
expect(result).toEqual(mockUnauthenticatedUser);
|
||||||
|
expect(result.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle error when getUser fails', async () => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = firstValueFrom(service.getUser());
|
||||||
|
const req = httpMock.expectOne((req) => req.url.includes('/bff/user'));
|
||||||
|
req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' });
|
||||||
|
await result;
|
||||||
|
fail('Should have thrown an error');
|
||||||
|
} catch (error: any) {
|
||||||
|
expect(error.status).toBe(401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use correct URL format with protocol and host', async () => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockAuthenticatedUser));
|
||||||
|
|
||||||
|
service.getUser().subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(req.request.url).toMatch(/^https?:\/\/.+\/bff\/user$/);
|
||||||
|
req.flush(mockAuthenticatedUser);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('should redirect to /bff/login when no returnUrl is provided', () => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockAuthenticatedUser));
|
||||||
|
|
||||||
|
service.login();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redirect to /bff/login when returnUrl is root', () => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockAuthenticatedUser));
|
||||||
|
|
||||||
|
service.login('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redirect to /bff/login with returnUrl when currentUrl is provided', () => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockAuthenticatedUser));
|
||||||
|
|
||||||
|
service.login('/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should properly encode returnUrl parameter', () => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockAuthenticatedUser));
|
||||||
|
|
||||||
|
service.login('/path?query=value&other=data');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('should call /bff/logout and redirect to default location on success', async () => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockAuthenticatedUser));
|
||||||
|
|
||||||
|
service.logout();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((req) => req.url.includes('/bff/logout'));
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
expect(req.request.body).toEqual({});
|
||||||
|
req.flush({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redirect to custom redirectTo location when provided', async () => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockAuthenticatedUser));
|
||||||
|
|
||||||
|
service.logout();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((req) => req.url.includes('/bff/logout'));
|
||||||
|
req.flush({ redirectTo: '/custom-logout' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reload page on logout error', async () => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockAuthenticatedUser));
|
||||||
|
|
||||||
|
service.logout();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((req) => req.url.includes('/bff/logout'));
|
||||||
|
req.flush('Server Error', { status: 500, statusText: 'Server Error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should set isAuthenticated to true when user is authenticated', (done) => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockAuthenticatedUser));
|
||||||
|
|
||||||
|
service.onLogin.subscribe((isLoggedIn) => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
expect(service.isAuthenticated).toBe(true);
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not emit onLogin when user is not authenticated', (done) => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockUnauthenticatedUser));
|
||||||
|
|
||||||
|
let loginEmitted = false;
|
||||||
|
service.onLogin.subscribe((isLoggedIn) => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
loginEmitted = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
expect(loginEmitted).toBe(false);
|
||||||
|
expect(service.isAuthenticated).toBe(false);
|
||||||
|
done();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should populate userInfo from getUser response', (done) => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockAuthenticatedUser));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
expect(service.userInfo).toEqual(mockAuthenticatedUser);
|
||||||
|
expect(service.userInfo.user.email).toBe('test@example.com');
|
||||||
|
done();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interface methods', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
service = TestBed.inject(BffAuthService);
|
||||||
|
|
||||||
|
const bffUserRequestMatchers = httpMock.match((req) => req.url.includes('/bff/user'));
|
||||||
|
expect(bffUserRequestMatchers.length).toBe(2);
|
||||||
|
bffUserRequestMatchers.forEach((req) => req.flush(mockAuthenticatedUser));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string for getToken', () => {
|
||||||
|
expect(service.getToken()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return isAuthenticated for isLoggedIn', () => {
|
||||||
|
service.isAuthenticated = true;
|
||||||
|
expect(service.isLoggedIn()).toBe(true);
|
||||||
|
|
||||||
|
service.isAuthenticated = false;
|
||||||
|
expect(service.isLoggedIn()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for isOauth', () => {
|
||||||
|
expect(service.isOauth()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for isECMProvider', () => {
|
||||||
|
expect(service.isECMProvider()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for isBPMProvider', () => {
|
||||||
|
expect(service.isBPMProvider()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for isALLProvider', () => {
|
||||||
|
expect(service.isALLProvider()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string for getUsername', () => {
|
||||||
|
expect(service.getUsername()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return header unchanged for getAuthHeaders', () => {
|
||||||
|
const headers = new HttpHeaders({ 'Content-Type': 'application/json' });
|
||||||
|
const result = service.getAuthHeaders('/test', headers);
|
||||||
|
expect(result).toBe(headers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return headers as observable for addTokenToHeader', async () => {
|
||||||
|
const headers = new HttpHeaders({ 'Content-Type': 'application/json' });
|
||||||
|
const result = await firstValueFrom(service.addTokenToHeader('/test', headers));
|
||||||
|
expect(result).toBe(headers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle addTokenToHeader with no headers argument', async () => {
|
||||||
|
const result = await firstValueFrom(service.addTokenToHeader('/test'));
|
||||||
|
expect(result).toBeInstanceOf(HttpHeaders);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have reset method that does nothing', () => {
|
||||||
|
expect(() => service.reset()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject, filter, Observable } from 'rxjs';
|
||||||
|
import { AuthenticationServiceInterface } from '../../interfaces/authentication-service.interface';
|
||||||
|
import { EventEmitter } from 'eventemitter3';
|
||||||
|
|
||||||
|
export interface BffUserInfo {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
hxp_account: string;
|
||||||
|
name: string;
|
||||||
|
email_verified: boolean;
|
||||||
|
preferred_username: string;
|
||||||
|
given_name: string;
|
||||||
|
family_name: string;
|
||||||
|
roles: string[];
|
||||||
|
appKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BffUserResponse {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
user: BffUserInfo;
|
||||||
|
}
|
||||||
|
type EventEmitterInstance = InstanceType<typeof EventEmitter>;
|
||||||
|
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
@Injectable()
|
||||||
|
export class BffAuthService implements AuthenticationServiceInterface {
|
||||||
|
isAuthenticated: boolean = false;
|
||||||
|
username: string = '';
|
||||||
|
|
||||||
|
userInfo: BffUserResponse;
|
||||||
|
|
||||||
|
onError = new BehaviorSubject<any>(null);
|
||||||
|
onLogin = new BehaviorSubject<boolean>(false);
|
||||||
|
onLogout = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
|
on: EventEmitterInstance['on'];
|
||||||
|
off: EventEmitterInstance['off'];
|
||||||
|
once: EventEmitterInstance['once'];
|
||||||
|
emit: EventEmitterInstance['emit'];
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {
|
||||||
|
this.getUser()
|
||||||
|
.pipe(filter((user) => user.isAuthenticated))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
this.onLogin.next(this.isAuthenticated);
|
||||||
|
});
|
||||||
|
this.getUser().subscribe((userResponse) => {
|
||||||
|
console.log('[BffAuthService] userResponse: ', userResponse);
|
||||||
|
this.userInfo = userResponse;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(): string {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
isLoggedIn(): boolean {
|
||||||
|
return this.isAuthenticated;
|
||||||
|
}
|
||||||
|
isOauth(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
isECMProvider(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
isBPMProvider(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
isALLProvider(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
getUsername(): string {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
getAuthHeaders(_requestUrl: string, header: HttpHeaders): HttpHeaders {
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
addTokenToHeader(_requestUrl: string, headersArg?: HttpHeaders): Observable<HttpHeaders> {
|
||||||
|
return new BehaviorSubject(headersArg ?? new HttpHeaders()).asObservable();
|
||||||
|
}
|
||||||
|
reset(): void {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUser(): Observable<BffUserResponse> {
|
||||||
|
const protocol = window.location.protocol;
|
||||||
|
const host = window.location.host;
|
||||||
|
console.log('[BffAuthService] getUser from ', `${protocol}//${host}/bff/user`);
|
||||||
|
return this.http.get<BffUserResponse>(`${protocol}//${host}/bff/user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
login(currentUrl?: string): Promise<void> | void {
|
||||||
|
const protocol = window.location.protocol;
|
||||||
|
const host = window.location.host;
|
||||||
|
let url: string;
|
||||||
|
if (!currentUrl || currentUrl === '/') {
|
||||||
|
url = `${protocol}//${host}/bff/login`;
|
||||||
|
} else {
|
||||||
|
url = `${protocol}//${host}/bff/login?returnUrl=${encodeURIComponent(currentUrl ?? '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('url: ', url);
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): Promise<void> | void {
|
||||||
|
const protocol = window.location.protocol;
|
||||||
|
const host = window.location.host;
|
||||||
|
this.http.post<{ redirectTo?: string }>(`${protocol}//${host}/bff/logout`, {}).subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
console.log('[BffAuthService] logout: ', res);
|
||||||
|
const target = res.redirectTo || '/';
|
||||||
|
window.location.href = target;
|
||||||
|
},
|
||||||
|
error: () => window.location.reload()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { BffAuthService, BffUserInfo } from './bff-auth.service';
|
||||||
|
import { BffUserAccessService } from './bff-user-access.service';
|
||||||
|
|
||||||
|
describe('BffUserAccessService', () => {
|
||||||
|
let service: BffUserAccessService;
|
||||||
|
let bffAuthServiceSpy: jasmine.SpyObj<BffAuthService>;
|
||||||
|
|
||||||
|
/* eslint-disable-next-line */
|
||||||
|
function createMockUserInfo(appKey: string, roles: string[]): BffUserInfo {
|
||||||
|
return {
|
||||||
|
appKey,
|
||||||
|
roles,
|
||||||
|
sub: '',
|
||||||
|
email: '',
|
||||||
|
hxp_account: '',
|
||||||
|
name: '',
|
||||||
|
email_verified: false,
|
||||||
|
preferred_username: '',
|
||||||
|
given_name: '',
|
||||||
|
family_name: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const spy = jasmine.createSpyObj('BffAuthService', ['getUser', 'login', 'logout']);
|
||||||
|
spy.userInfo = {
|
||||||
|
authenticated: true,
|
||||||
|
user: createMockUserInfo('test-app', ['ROLE_USER', 'ROLE_ADMIN'])
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [BffUserAccessService, { provide: BffAuthService, useValue: spy }]
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(BffUserAccessService);
|
||||||
|
bffAuthServiceSpy = TestBed.inject(BffAuthService) as jasmine.SpyObj<BffAuthService>;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the service', () => {
|
||||||
|
expect(service).toBeInstanceOf(BffUserAccessService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchUserAccess', () => {
|
||||||
|
it('should set applicationAccess when user has valid appKey and roles', () => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', ['ROLE_USER', 'ROLE_ADMIN']);
|
||||||
|
|
||||||
|
service.fetchUserAccess();
|
||||||
|
|
||||||
|
expect(service.hasGlobalAccess(['ROLE_USER'])).toBe(true);
|
||||||
|
expect(service.hasApplicationAccess('my-app', ['ROLE_USER'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set applicationAccess to null when appKey is empty', () => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('', ['ROLE_USER']);
|
||||||
|
|
||||||
|
service.fetchUserAccess();
|
||||||
|
|
||||||
|
expect(service.hasGlobalAccess(['ROLE_USER'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set applicationAccess to null when roles array is empty', () => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', []);
|
||||||
|
|
||||||
|
service.fetchUserAccess();
|
||||||
|
|
||||||
|
expect(service.hasGlobalAccess(['ROLE_USER'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set applicationAccess to null when roles is undefined', () => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', undefined as unknown as string[]);
|
||||||
|
|
||||||
|
service.fetchUserAccess();
|
||||||
|
|
||||||
|
expect(service.hasGlobalAccess(['ROLE_USER'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple calls to fetchUserAccess', () => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('app1', ['ROLE1']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
expect(service.hasApplicationAccess('app1', ['ROLE1'])).toBe(true);
|
||||||
|
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('app2', ['ROLE2']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
expect(service.hasApplicationAccess('app2', ['ROLE2'])).toBe(true);
|
||||||
|
expect(service.hasApplicationAccess('app1', ['ROLE1'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasGlobalAccess', () => {
|
||||||
|
describe('when rolesToCheck is empty or not provided', () => {
|
||||||
|
it('should return true when rolesToCheck is empty array', () => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', ['ROLE_USER']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
|
||||||
|
expect(service.hasGlobalAccess([])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when rolesToCheck is null', () => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', ['ROLE_USER']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
|
||||||
|
expect(service.hasGlobalAccess(null as unknown as string[])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when rolesToCheck is undefined', () => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', ['ROLE_USER']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
|
||||||
|
expect(service.hasGlobalAccess(undefined as unknown as string[])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when user has required roles', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', ['ROLE_USER', 'ROLE_ADMIN', 'ROLE_VIEWER']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when user has one of the required roles', () => {
|
||||||
|
expect(service.hasGlobalAccess(['ROLE_USER'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when user has all of the required roles', () => {
|
||||||
|
expect(service.hasGlobalAccess(['ROLE_USER', 'ROLE_ADMIN'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when user has at least one of multiple required roles', () => {
|
||||||
|
expect(service.hasGlobalAccess(['ROLE_NONEXISTENT', 'ROLE_ADMIN'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-sensitive when checking roles', () => {
|
||||||
|
expect(service.hasGlobalAccess(['role_user'])).toBe(false);
|
||||||
|
expect(service.hasGlobalAccess(['ROLE_USER'])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when user lacks required roles', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', ['ROLE_USER']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when user has none of the required roles', () => {
|
||||||
|
expect(service.hasGlobalAccess(['ROLE_ADMIN', 'ROLE_SUPERUSER'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when applicationAccess is not set', () => {
|
||||||
|
it('should return false when applicationAccess is null (empty appKey)', () => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('', ['ROLE_USER']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
|
||||||
|
expect(service.hasGlobalAccess(['ROLE_USER'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when applicationAccess is null (empty roles)', () => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', []);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
|
||||||
|
expect(service.hasGlobalAccess(['ROLE_USER'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when fetchUserAccess has not been called', () => {
|
||||||
|
expect(service.hasGlobalAccess(['ROLE_USER'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasApplicationAccess', () => {
|
||||||
|
describe('when appName is invalid', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', ['ROLE_USER']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when appName is empty string', () => {
|
||||||
|
expect(service.hasApplicationAccess('', ['ROLE_USER'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when appName is null', () => {
|
||||||
|
expect(service.hasApplicationAccess(null as unknown as string, ['ROLE_USER'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when appName is undefined', () => {
|
||||||
|
expect(service.hasApplicationAccess(undefined as unknown as string, ['ROLE_USER'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when rolesToCheck is empty or not provided', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', ['ROLE_USER']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when rolesToCheck is empty array', () => {
|
||||||
|
expect(service.hasApplicationAccess('my-app', [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when rolesToCheck is null', () => {
|
||||||
|
expect(service.hasApplicationAccess('my-app', null as unknown as string[])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when rolesToCheck is undefined', () => {
|
||||||
|
expect(service.hasApplicationAccess('my-app', undefined as unknown as string[])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when app name matches and user has roles', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', ['ROLE_USER', 'ROLE_ADMIN']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when user has the required role', () => {
|
||||||
|
expect(service.hasApplicationAccess('my-app', ['ROLE_USER'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when user has at least one of the required roles', () => {
|
||||||
|
expect(service.hasApplicationAccess('my-app', ['ROLE_NONEXISTENT', 'ROLE_ADMIN'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-sensitive when checking app name', () => {
|
||||||
|
expect(service.hasApplicationAccess('MY-APP', ['ROLE_USER'])).toBe(false);
|
||||||
|
expect(service.hasApplicationAccess('my-app', ['ROLE_USER'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-sensitive when checking roles', () => {
|
||||||
|
expect(service.hasApplicationAccess('my-app', ['role_user'])).toBe(false);
|
||||||
|
expect(service.hasApplicationAccess('my-app', ['ROLE_USER'])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when app name does not match', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', ['ROLE_USER']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false even if user has the required roles', () => {
|
||||||
|
expect(service.hasApplicationAccess('different-app', ['ROLE_USER'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when user lacks required roles', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', ['ROLE_USER']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when user has none of the required roles', () => {
|
||||||
|
expect(service.hasApplicationAccess('my-app', ['ROLE_ADMIN', 'ROLE_SUPERUSER'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when applicationAccess is not set', () => {
|
||||||
|
it('should return false when applicationAccess is null (empty appKey)', () => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('', ['ROLE_USER']);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
|
||||||
|
expect(service.hasApplicationAccess('my-app', ['ROLE_USER'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when applicationAccess is null (empty roles)', () => {
|
||||||
|
bffAuthServiceSpy.userInfo.user = createMockUserInfo('my-app', []);
|
||||||
|
service.fetchUserAccess();
|
||||||
|
|
||||||
|
expect(service.hasApplicationAccess('my-app', ['ROLE_USER'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when fetchUserAccess has not been called', () => {
|
||||||
|
expect(service.hasApplicationAccess('my-app', ['ROLE_USER'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { IUserAccessService } from '../user-access.service';
|
||||||
|
import { ApplicationAccessModel } from '../../models/application-access.model';
|
||||||
|
import { BffAuthService } from './bff-auth.service';
|
||||||
|
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
@Injectable()
|
||||||
|
export class BffUserAccessService implements IUserAccessService {
|
||||||
|
private applicationAccess: ApplicationAccessModel;
|
||||||
|
private bffAuthService = inject(BffAuthService);
|
||||||
|
|
||||||
|
fetchUserAccess() {
|
||||||
|
console.log('[BffUserAccessService] fetchUserAccess');
|
||||||
|
const { user } = this.bffAuthService.userInfo;
|
||||||
|
if (user.appKey && user.roles?.length > 0) {
|
||||||
|
this.applicationAccess = {
|
||||||
|
name: user.appKey,
|
||||||
|
roles: user.roles
|
||||||
|
};
|
||||||
|
console.log('✅[BffUserAccessService] fetchUserAccess applicationAccess: ', this.applicationAccess);
|
||||||
|
} else {
|
||||||
|
this.applicationAccess = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasGlobalAccess(rolesToCheck: string[]): boolean {
|
||||||
|
if (!rolesToCheck?.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const hasAccess = this.applicationAccess?.roles?.some((role) => rolesToCheck.includes(role)) ?? false;
|
||||||
|
console.log('[BffUserAccessService] hasGlobalAccess() hasAccess: ', hasAccess);
|
||||||
|
return hasAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasApplicationAccess(appName: string, rolesToCheck: string[]): boolean {
|
||||||
|
if (!appName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!rolesToCheck?.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const { name, roles } = this.applicationAccess || {};
|
||||||
|
if (name !== appName || !roles?.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return roles.some((role) => rolesToCheck.includes(role));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,14 +20,23 @@ import { JwtHelperService } from './jwt-helper.service';
|
|||||||
import { ApplicationAccessModel } from '../models/application-access.model';
|
import { ApplicationAccessModel } from '../models/application-access.model';
|
||||||
import { AppConfigService } from '../../app-config/app-config.service';
|
import { AppConfigService } from '../../app-config/app-config.service';
|
||||||
|
|
||||||
|
export interface IUserAccessService {
|
||||||
|
fetchUserAccess(): void;
|
||||||
|
hasGlobalAccess(rolesToCheck: string[]): boolean;
|
||||||
|
hasApplicationAccess(appName: string, rolesToCheck: string[]): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class UserAccessService {
|
export class UserAccessService implements IUserAccessService {
|
||||||
private globalAccess: string[];
|
private globalAccess: string[];
|
||||||
private applicationAccess: ApplicationAccessModel[];
|
private applicationAccess: ApplicationAccessModel[];
|
||||||
|
|
||||||
constructor(private jwtHelperService: JwtHelperService, private appConfigService: AppConfigService) {}
|
constructor(
|
||||||
|
private jwtHelperService: JwtHelperService,
|
||||||
|
private appConfigService: AppConfigService
|
||||||
|
) {}
|
||||||
|
|
||||||
fetchUserAccess() {
|
fetchUserAccess() {
|
||||||
if (this.hasRolesInRealmAccess()) {
|
if (this.hasRolesInRealmAccess()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user