diff --git a/lib/core/src/lib/auth/guard/auth-guard.ts b/lib/core/src/lib/auth/guard/auth-guard.ts index 0a396bc083..33ceb77b03 100644 --- a/lib/core/src/lib/auth/guard/auth-guard.ts +++ b/lib/core/src/lib/auth/guard/auth-guard.ts @@ -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 => { +export const LegacyAuthGuard: CanActivateFn = async (_: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise => { 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; diff --git a/lib/core/src/lib/auth/oidc/auth.module.ts b/lib/core/src/lib/auth/oidc/auth.module.ts index 9d767c60ad..6f1a3760e3 100644 --- a/lib/core/src/lib/auth/oidc/auth.module.ts +++ b/lib/core/src/lib/auth/oidc/auth.module.ts @@ -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('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()] diff --git a/lib/core/src/lib/auth/public-api.ts b/lib/core/src/lib/auth/public-api.ts index c8ab3a3e2e..2379032d6a 100644 --- a/lib/core/src/lib/auth/public-api.ts +++ b/lib/core/src/lib/auth/public-api.ts @@ -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'; diff --git a/lib/core/src/lib/auth/services/bff/bff-auth-error.interceptor.spec.ts b/lib/core/src/lib/auth/services/bff/bff-auth-error.interceptor.spec.ts new file mode 100644 index 0000000000..6a190b347a --- /dev/null +++ b/lib/core/src/lib/auth/services/bff/bff-auth-error.interceptor.spec.ts @@ -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(); + }); +}); diff --git a/lib/core/src/lib/auth/services/bff/bff-auth-error.interceptor.ts b/lib/core/src/lib/auth/services/bff/bff-auth-error.interceptor.ts new file mode 100644 index 0000000000..9a4128b0e1 --- /dev/null +++ b/lib/core/src/lib/auth/services/bff/bff-auth-error.interceptor.ts @@ -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); + }) + ); diff --git a/lib/core/src/lib/auth/services/bff/bff-auth.guard.spec.ts b/lib/core/src/lib/auth/services/bff/bff-auth.guard.spec.ts new file mode 100644 index 0000000000..87454ccff2 --- /dev/null +++ b/lib/core/src/lib/auth/services/bff/bff-auth.guard.spec.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + await firstValueFrom(result$); + + expect(bffAuthService.login).toHaveBeenCalledWith(''); + }); +}); diff --git a/lib/core/src/lib/auth/services/bff/bff-auth.guard.ts b/lib/core/src/lib/auth/services/bff/bff-auth.guard.ts new file mode 100644 index 0000000000..23b8db66ff --- /dev/null +++ b/lib/core/src/lib/auth/services/bff/bff-auth.guard.ts @@ -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; +} diff --git a/lib/core/src/lib/auth/services/bff/bff-auth.service.spec.ts b/lib/core/src/lib/auth/services/bff/bff-auth.service.spec.ts new file mode 100644 index 0000000000..4ff2687543 --- /dev/null +++ b/lib/core/src/lib/auth/services/bff/bff-auth.service.spec.ts @@ -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(); + }); + }); +}); diff --git a/lib/core/src/lib/auth/services/bff/bff-auth.service.ts b/lib/core/src/lib/auth/services/bff/bff-auth.service.ts new file mode 100644 index 0000000000..09c051f458 --- /dev/null +++ b/lib/core/src/lib/auth/services/bff/bff-auth.service.ts @@ -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; + +/* eslint-disable no-console */ +@Injectable() +export class BffAuthService implements AuthenticationServiceInterface { + isAuthenticated: boolean = false; + username: string = ''; + + userInfo: BffUserResponse; + + onError = new BehaviorSubject(null); + onLogin = new BehaviorSubject(false); + onLogout = new BehaviorSubject(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 { + return new BehaviorSubject(headersArg ?? new HttpHeaders()).asObservable(); + } + reset(): void { + return; + } + + getUser(): Observable { + const protocol = window.location.protocol; + const host = window.location.host; + console.log('[BffAuthService] getUser from ', `${protocol}//${host}/bff/user`); + return this.http.get(`${protocol}//${host}/bff/user`); + } + + login(currentUrl?: string): Promise | 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 { + 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() + }); + } +} diff --git a/lib/core/src/lib/auth/services/bff/bff-user-access.service.spec.ts b/lib/core/src/lib/auth/services/bff/bff-user-access.service.spec.ts new file mode 100644 index 0000000000..83af7ff65c --- /dev/null +++ b/lib/core/src/lib/auth/services/bff/bff-user-access.service.spec.ts @@ -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; + + /* 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; + }); + + 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); + }); + }); + }); +}); diff --git a/lib/core/src/lib/auth/services/bff/bff-user-access.service.ts b/lib/core/src/lib/auth/services/bff/bff-user-access.service.ts new file mode 100644 index 0000000000..8ddd7f361b --- /dev/null +++ b/lib/core/src/lib/auth/services/bff/bff-user-access.service.ts @@ -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)); + } +} diff --git a/lib/core/src/lib/auth/services/user-access.service.ts b/lib/core/src/lib/auth/services/user-access.service.ts index 6dc56ba438..0a5986500b 100644 --- a/lib/core/src/lib/auth/services/user-access.service.ts +++ b/lib/core/src/lib/auth/services/user-access.service.ts @@ -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()) {