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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/core/src/lib/auth/guard/auth-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -59,3 +60,4 @@ export const AuthGuard: CanActivateFn = async (_: ActivatedRouteSnapshot, state:

return authGuardBaseService.redirectToUrl(state.url);
};
export const AuthGuard: CanActivateFn = BffAuthGuard;
33 changes: 31 additions & 2 deletions lib/core/src/lib/auth/oidc/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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()]
Expand Down
3 changes: 3 additions & 0 deletions lib/core/src/lib/auth/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*!
* @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';
import { BffUrlBuilder } from './bff-url-builder.service';
import { DOCUMENT } from '@angular/common';

describe('bffAuthErrorInterceptor', () => {
let mockDocument: any;
let mockBffUrlBuilder: jasmine.SpyObj<BffUrlBuilder>;

beforeEach(() => {
mockDocument = {
location: {
href: ''
}
};

mockBffUrlBuilder = jasmine.createSpyObj('BffUrlBuilder', ['getLoginUrl']);
mockBffUrlBuilder.getLoginUrl.and.returnValue('http://localhost/bff/login');

TestBed.configureTestingModule({
providers: [
{ provide: DOCUMENT, useValue: mockDocument },
{ provide: BffUrlBuilder, useValue: mockBffUrlBuilder }
]
});
});

it('should redirect to login URL 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();
expect(mockBffUrlBuilder.getLoginUrl).toHaveBeenCalled();
expect(mockDocument.location.href).toBe('http://localhost/bff/login');
});

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);
expect(mockBffUrlBuilder.getLoginUrl).not.toHaveBeenCalled();
expect(mockDocument.location.href).toBe('');
});

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);
expect(mockBffUrlBuilder.getLoginUrl).not.toHaveBeenCalled();
expect(mockDocument.location.href).toBe('');
});

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();
expect(mockBffUrlBuilder.getLoginUrl).not.toHaveBeenCalled();
expect(mockDocument.location.href).toBe('');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*!
* @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';
import { BffUrlBuilder } from './bff-url-builder.service';
import { inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';

/* eslint-disable no-console */
export const bffAuthErrorInterceptor: HttpInterceptorFn = (req, next) => {
const bffUrlBuilder = inject(BffUrlBuilder);
const document = inject(DOCUMENT);

return next(req).pipe(
catchError((err: HttpErrorResponse) => {
console.log('%c[bffAuthErrorInterceptor] err: ', 'color: red;', err);
if (err.status === 401 && req.url.includes('/bff/')) {
const url = bffUrlBuilder.getLoginUrl();
console.log('%c[bffAuthErrorInterceptor] redirecting to login URL: ', 'color: yellow;', url);
document.location.href = url;
return EMPTY;
}

return throwError(() => err);
})
);
};
125 changes: 125 additions & 0 deletions lib/core/src/lib/auth/services/bff/bff-auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
Loading
Loading