AAE-39851 replace coreAuth with provideBffAuth to call BFF

This commit is contained in:
alep85
2025-12-05 17:09:04 +01:00
parent cc053a76ae
commit 60796c68b5
12 changed files with 1172 additions and 5 deletions
+3 -1
View File
@@ -20,6 +20,7 @@ import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot } fr
import { AuthenticationService } from '../services/authentication.service';
import { AuthGuardService } from './auth-guard.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 => {
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 jwtHelperService = inject(JwtHelperService);
const authGuardBaseService = inject(AuthGuardService);
@@ -59,3 +60,4 @@ export const AuthGuard: CanActivateFn = async (_: ActivatedRouteSnapshot, state:
return authGuardBaseService.redirectToUrl(state.url);
};
export const AuthGuard: CanActivateFn = BffAuthGuard;
+31 -2
View File
@@ -16,18 +16,22 @@
*/
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 { AuthModuleConfig, AUTH_MODULE_CONFIG } from './auth-config';
import { authConfigFactory, AuthConfigService } from './auth-config.service';
import { AuthService } from './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 { StorageService } from '../../common/services/storage.service';
import { provideRouter } from '@angular/router';
import { AUTH_ROUTES } from './auth.routes';
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', {
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 */
@NgModule({
providers: [...provideCoreAuth()]
+3
View File
@@ -31,6 +31,9 @@ export * from './services/jwt-helper.service';
export * from './services/oauth2.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/process-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 { AppConfigService } from '../../app-config/app-config.service';
export interface IUserAccessService {
fetchUserAccess(): void;
hasGlobalAccess(rolesToCheck: string[]): boolean;
hasApplicationAccess(appName: string, rolesToCheck: string[]): boolean;
}
@Injectable({
providedIn: 'root'
})
export class UserAccessService {
export class UserAccessService implements IUserAccessService {
private globalAccess: string[];
private applicationAccess: ApplicationAccessModel[];
constructor(private jwtHelperService: JwtHelperService, private appConfigService: AppConfigService) {}
constructor(
private jwtHelperService: JwtHelperService,
private appConfigService: AppConfigService
) {}
fetchUserAccess() {
if (this.hasRolesInRealmAccess()) {