diff --git a/interfaces/Portalicious/.env.example b/interfaces/Portalicious/.env.example index 51425a10f7..09fe3df1f7 100644 --- a/interfaces/Portalicious/.env.example +++ b/interfaces/Portalicious/.env.example @@ -24,6 +24,7 @@ USE_SSO_AZURE_ENTRA= # Make sure to set these values the same for the 121-service. AZURE_ENTRA_CLIENT_ID= AZURE_ENTRA_TENANT_ID= +AZURE_ENTRA_URL=https://login.microsoftonline.com # API: NG_URL_121_SERVICE_API=http://localhost:3000/api diff --git a/interfaces/Portalicious/package-lock.json b/interfaces/Portalicious/package-lock.json index 81e7ce9da0..ae771f0fc5 100644 --- a/interfaces/Portalicious/package-lock.json +++ b/interfaces/Portalicious/package-lock.json @@ -15,6 +15,8 @@ "@angular/platform-browser": "^18.2.11", "@angular/platform-browser-dynamic": "^18.2.11", "@angular/router": "^18.2.11", + "@azure/msal-angular": "^3.1.0", + "@azure/msal-browser": "^3.27.0", "@microsoft/applicationinsights-web": "^3.3.4", "@tanstack/angular-query-experimental": "^5.60.0", "angular-mentions": "^1.5.0", @@ -1064,18 +1066,37 @@ "node": ">=18.0.0" } }, + "node_modules/@azure/msal-angular": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@azure/msal-angular/-/msal-angular-3.1.0.tgz", + "integrity": "sha512-a0A1Ogjp6DDsU7LJ7LKdLurWoczHEFErrckbbrkbV/h4J5LmcBpu32DN2u5dBiUg2ngYg6MtPbVIVD39ZtysTA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@azure/msal-browser": "^3.27.0", + "rxjs": "^7.0.0" + } + }, "node_modules/@azure/msal-browser": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.20.0.tgz", - "integrity": "sha512-ErsxbfCGIwdqD8jipqdxpfAGiUEQS7MWUe39Rjhl0ZVPsb1JEe9bZCe2+0g23HDH6DGyCAtnTNN9scPtievrMQ==", - "dev": true, + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.27.0.tgz", + "integrity": "sha512-+b4ZKSD8+vslCtVRVetkegEhOFMLP3rxDWJY212ct+2r6jVg6OSQKc1Qz3kCoXo0FgwaXkb+76TMZfpHp8QtgA==", "dependencies": { - "@azure/msal-common": "14.14.0" + "@azure/msal-common": "14.16.0" }, "engines": { "node": ">=0.8.0" } }, + "node_modules/@azure/msal-browser/node_modules/@azure/msal-common": { + "version": "14.16.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.0.tgz", + "integrity": "sha512-1KOZj9IpcDSwpNiQNjt0jDYZpQvNZay7QAEi/5DLubay40iGYtLzya/jbjRPLyOTZhEKyL1MzPuw2HqBCjceYA==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@azure/msal-common": { "version": "14.14.0", "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.14.0.tgz", diff --git a/interfaces/Portalicious/package.json b/interfaces/Portalicious/package.json index 91d76d5f31..bca54277fa 100644 --- a/interfaces/Portalicious/package.json +++ b/interfaces/Portalicious/package.json @@ -34,6 +34,8 @@ "@angular/router": "^18.2.11", "@microsoft/applicationinsights-web": "^3.3.4", "@tanstack/angular-query-experimental": "^5.60.0", + "@azure/msal-angular": "^3.1.0", + "@azure/msal-browser": "^3.27.0", "angular-mentions": "^1.5.0", "chart.js": "^4.4.6", "chartjs-plugin-datalabels": "^2.2.0", diff --git a/interfaces/Portalicious/src/app/app.component.spec.ts b/interfaces/Portalicious/src/app/app.component.spec.ts index dbc889d711..0fc3cf2b89 100644 --- a/interfaces/Portalicious/src/app/app.component.spec.ts +++ b/interfaces/Portalicious/src/app/app.component.spec.ts @@ -1,6 +1,12 @@ +import { provideHttpClient } from '@angular/common/http'; import { TestBed } from '@angular/core/testing'; import { RouterModule } from '@angular/router'; +import { + provideTanStackQuery, + QueryClient, +} from '@tanstack/angular-query-experimental'; + import { AppComponent } from '~/app.component'; import { getAppConfig } from '~/app.config'; @@ -9,6 +15,18 @@ describe('AppComponent', () => { await TestBed.configureTestingModule({ ...getAppConfig, imports: [AppComponent, RouterModule.forRoot([])], + providers: [ + provideHttpClient(), + provideTanStackQuery( + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + }, + }, + }), + ), + ], teardown: { destroyAfterEach: false }, }).compileComponents(); }); diff --git a/interfaces/Portalicious/src/app/app.component.ts b/interfaces/Portalicious/src/app/app.component.ts index d55e0d2f1d..7e089a9686 100644 --- a/interfaces/Portalicious/src/app/app.component.ts +++ b/interfaces/Portalicious/src/app/app.component.ts @@ -4,13 +4,16 @@ import { Component, inject, LOCALE_ID, + OnDestroy, OnInit, } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { MessageService, PrimeNGConfig } from 'primeng/api'; import { ToastModule } from 'primeng/toast'; +import { Subscription } from 'rxjs'; +import { AuthService } from '~/services/auth.service'; import { ToastService } from '~/services/toast.service'; import { Locale } from '~/utils/locale'; @@ -24,10 +27,12 @@ import { Locale } from '~/utils/locale'; templateUrl: './app.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AppComponent implements OnInit { +export class AppComponent implements OnInit, OnDestroy { + private primengConfig = inject(PrimeNGConfig); + private locale = inject(LOCALE_ID); + private readonly authService = inject(AuthService); + private authSubscriptions: Subscription[] = []; toastKey = ToastService.TOAST_KEY; - primengConfig = inject(PrimeNGConfig); - locale = inject(LOCALE_ID); ngOnInit() { this.primengConfig.setTranslation({ @@ -40,5 +45,13 @@ export class AppComponent implements OnInit { apply: $localize`:@@generic-apply:Apply`, clear: $localize`:@@generic-clear:Clear`, }); + + this.authSubscriptions = this.authService.initializeSubscriptions(); + } + + ngOnDestroy(): void { + for (const subscription of this.authSubscriptions) { + subscription.unsubscribe(); + } } } diff --git a/interfaces/Portalicious/src/app/app.config.ts b/interfaces/Portalicious/src/app/app.config.ts index ebb3aebd52..5f0fbb60f4 100644 --- a/interfaces/Portalicious/src/app/app.config.ts +++ b/interfaces/Portalicious/src/app/app.config.ts @@ -16,6 +16,7 @@ import { } from '@tanstack/angular-query-experimental'; import { routes } from '~/app.routes'; +import { AuthService } from '~/services/auth.service'; import { Locale } from '~/utils/locale'; export function getAppConfig(locale: Locale): ApplicationConfig { @@ -34,6 +35,7 @@ export function getAppConfig(locale: Locale): ApplicationConfig { }, }), ), + ...AuthService.APP_PROVIDERS, { provide: LOCALE_ID, useValue: locale }, ], }; diff --git a/interfaces/Portalicious/src/app/app.routes.ts b/interfaces/Portalicious/src/app/app.routes.ts index dc94acd5c4..a96dc63db2 100644 --- a/interfaces/Portalicious/src/app/app.routes.ts +++ b/interfaces/Portalicious/src/app/app.routes.ts @@ -7,6 +7,7 @@ import { authCapabilitiesGuard } from '~/guards/auth-capabilities.guard'; import { projectPermissionsGuard } from '~/guards/project-permissions-guard'; export enum AppRoutes { + authCallback = 'auth-callback', changePassword = 'change-password', login = 'login', project = 'project', @@ -27,6 +28,13 @@ export const routes: Routes = [ loadComponent: () => import('~/pages/login/login.page').then((x) => x.LoginPageComponent), }, + { + path: AppRoutes.authCallback, + loadComponent: () => + import('~/pages/auth-callback/auth-callback.page').then( + (x) => x.AuthCallbackPageComponent, + ), + }, { path: AppRoutes.changePassword, title: $localize`:Browser-tab-title@@page-title-change-password:Change password`, diff --git a/interfaces/Portalicious/src/app/domains/user/user.api.service.ts b/interfaces/Portalicious/src/app/domains/user/user.api.service.ts index 99b1aa39c1..ecc05a3c11 100644 --- a/interfaces/Portalicious/src/app/domains/user/user.api.service.ts +++ b/interfaces/Portalicious/src/app/domains/user/user.api.service.ts @@ -67,6 +67,13 @@ export class UserApiService extends DomainApiService { }); } + getCurrent() { + return this.generateQueryOptions<{ user: User }>({ + path: [`${BASE_ENDPOINT}/current`], + queryKey: [BASE_ENDPOINT], + }); + } + createUser({ username, displayName, diff --git a/interfaces/Portalicious/src/app/pages/auth-callback/auth-callback.page.html b/interfaces/Portalicious/src/app/pages/auth-callback/auth-callback.page.html new file mode 100644 index 0000000000..6986b1ea58 --- /dev/null +++ b/interfaces/Portalicious/src/app/pages/auth-callback/auth-callback.page.html @@ -0,0 +1,6 @@ + +
+ +

Redirecting to 121…

+
+
diff --git a/interfaces/Portalicious/src/app/pages/auth-callback/auth-callback.page.ts b/interfaces/Portalicious/src/app/pages/auth-callback/auth-callback.page.ts new file mode 100644 index 0000000000..c72c1ca3c5 --- /dev/null +++ b/interfaces/Portalicious/src/app/pages/auth-callback/auth-callback.page.ts @@ -0,0 +1,27 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + OnInit, +} from '@angular/core'; + +import { ProgressSpinnerModule } from 'primeng/progressspinner'; + +import { PageLayoutComponent } from '~/components/page-layout/page-layout.component'; +import { AuthService } from '~/services/auth.service'; + +@Component({ + selector: 'app-auth-callback', + standalone: true, + imports: [PageLayoutComponent, ProgressSpinnerModule], + templateUrl: './auth-callback.page.html', + styles: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AuthCallbackPageComponent implements OnInit { + private authService = inject(AuthService); + + ngOnInit() { + this.authService.handleAuthCallback(); + } +} diff --git a/interfaces/Portalicious/src/app/pages/login/login.page.html b/interfaces/Portalicious/src/app/pages/login/login.page.html index ab22451596..c33b981538 100644 --- a/interfaces/Portalicious/src/app/pages/login/login.page.html +++ b/interfaces/Portalicious/src/app/pages/login/login.page.html @@ -16,7 +16,13 @@

Log in

> Use your work email to log in.

- + +

{ + const returnUrl: unknown = this.route.snapshot.queryParams.returnUrl; + if (typeof returnUrl !== 'string') { + return undefined; + } + return returnUrl; + }); + constructor() { + const currentNavigation = this.router.getCurrentNavigation(); + const authError: unknown = + currentNavigation?.extras.state?.[AUTH_ERROR_IN_STATE_KEY]; + + if (typeof authError === 'string') { + this.authError = authError; + } + } LoginComponent = this.authService.LoginComponent; } diff --git a/interfaces/Portalicious/src/app/services/auth.service.ts b/interfaces/Portalicious/src/app/services/auth.service.ts index 8d5dbdedc6..3e9ca2c1d5 100644 --- a/interfaces/Portalicious/src/app/services/auth.service.ts +++ b/interfaces/Portalicious/src/app/services/auth.service.ts @@ -4,48 +4,32 @@ import { Router } from '@angular/router'; import { PermissionEnum } from '@121-service/src/user/enum/permission.enum'; import { AppRoutes } from '~/app.routes'; -import { User } from '~/domains/user/user.model'; import { IAuthStrategy } from '~/services/auth/auth-strategy.interface'; import { BasicAuthStrategy } from '~/services/auth/strategies/basic-auth/basic-auth.strategy'; import { MsalAuthStrategy } from '~/services/auth/strategies/msal-auth/msal-auth.strategy'; import { LogEvent, LogService } from '~/services/log.service'; +import { + getReturnUrlFromLocalStorage, + getUserFromLocalStorage, + LOCAL_STORAGE_AUTH_USER_KEY, + LocalStorageUser, + setReturnUrlInLocalStorage, + setUserInLocalStorage, +} from '~/utils/local-storage'; import { environment } from '~environment'; -export type LocalStorageUser = Pick< - User, - | 'expires' - | 'isAdmin' - | 'isEntraUser' - | 'isOrganizationAdmin' - | 'permissions' - | 'username' ->; +const AuthStrategy = environment.use_sso_azure_entra + ? MsalAuthStrategy + : BasicAuthStrategy; -const LOCAL_STORAGE_AUTH_USER_KEY = 'logged-in-user-portalicious'; - -export function getUserFromLocalStorage(): LocalStorageUser | null { - const rawUser = localStorage.getItem(LOCAL_STORAGE_AUTH_USER_KEY); - - if (!rawUser) { - return null; - } - - let user: LocalStorageUser; - - try { - user = JSON.parse(rawUser) as LocalStorageUser; - } catch { - console.warn('AuthService: Invalid token'); - return null; - } - - return user; -} +export const AUTH_ERROR_IN_STATE_KEY = 'AUTH_ERROR'; @Injectable({ providedIn: 'root', }) export class AuthService { + public static APP_PROVIDERS = AuthStrategy.APP_PROVIDERS; + private readonly logService = inject(LogService); private readonly injector = inject(Injector); private readonly router = inject(Router); @@ -53,11 +37,11 @@ export class AuthService { private readonly authStrategy: IAuthStrategy; constructor() { - const AuthStrategy = environment.use_sso_azure_entra - ? MsalAuthStrategy - : BasicAuthStrategy; + this.authStrategy = this.injector.get(AuthStrategy); + } - this.authStrategy = this.injector.get(AuthStrategy); + initializeSubscriptions() { + return this.authStrategy.initializeSubscriptions(); } public get isLoggedIn(): boolean { @@ -80,22 +64,6 @@ export class AuthService { return this.authStrategy.LoginComponent; } - private setUserInStorage(user: User): void { - const userToStore: LocalStorageUser = { - username: user.username, - permissions: user.permissions, - isAdmin: user.isAdmin, - isOrganizationAdmin: user.isOrganizationAdmin, - isEntraUser: user.isEntraUser, - expires: user.expires, - }; - - localStorage.setItem( - LOCAL_STORAGE_AUTH_USER_KEY, - JSON.stringify(userToStore), - ); - } - get user(): LocalStorageUser | null { const user = getUserFromLocalStorage(); @@ -117,14 +85,13 @@ export class AuthService { returnUrl?: string, ) { this.logService.logEvent(LogEvent.userLogin); - const user = await this.authStrategy.login(credentials); - this.setUserInStorage(user); - if (returnUrl) { - return this.router.navigate([returnUrl]); + setReturnUrlInLocalStorage(returnUrl); } - - return this.router.navigate(['/']); + const user = await this.authStrategy.login(credentials); + // Note: SSO never resolves so the code below this line is not executed in the SSO case + setUserInLocalStorage(user); + return this.router.navigate(['/', AppRoutes.authCallback]); } public async logout() { @@ -207,4 +174,9 @@ export class AuthService { }), ); } + public handleAuthCallback() { + const returnUrl = getReturnUrlFromLocalStorage(); + + this.authStrategy.handleAuthCallback(returnUrl ?? '/'); + } } diff --git a/interfaces/Portalicious/src/app/services/auth/auth-strategy.interface.ts b/interfaces/Portalicious/src/app/services/auth/auth-strategy.interface.ts index f76dd3c10e..34084a7b11 100644 --- a/interfaces/Portalicious/src/app/services/auth/auth-strategy.interface.ts +++ b/interfaces/Portalicious/src/app/services/auth/auth-strategy.interface.ts @@ -1,21 +1,27 @@ -import { Type } from '@angular/core'; +import { EnvironmentProviders, Provider, Type } from '@angular/core'; + +import { Subscription } from 'rxjs'; import { User } from '~/domains/user/user.model'; -import { LocalStorageUser } from '~/services/auth.service'; +import { LocalStorageUser } from '~/utils/local-storage'; export abstract class IAuthStrategy { + public static readonly APP_PROVIDERS: (EnvironmentProviders | Provider)[]; + LoginComponent: Type; ChangePasswordComponent: null | Type; + public abstract initializeSubscriptions(): Subscription[]; public abstract login(credentials: { username: string; password?: string; }): Promise; - public abstract logout(user: LocalStorageUser | null): Promise; + public abstract logout(user: LocalStorageUser | null): Promise; public abstract changePassword(data: { user: LocalStorageUser | null; password: string; newPassword: string; }): Promise; public abstract isUserExpired(user: LocalStorageUser | null): boolean; + public abstract handleAuthCallback(nextPageUrl: string): void; } diff --git a/interfaces/Portalicious/src/app/services/auth/strategies/basic-auth/basic-auth.login.component.ts b/interfaces/Portalicious/src/app/services/auth/strategies/basic-auth/basic-auth.login.component.ts index d4de1d848b..bb17735c51 100644 --- a/interfaces/Portalicious/src/app/services/auth/strategies/basic-auth/basic-auth.login.component.ts +++ b/interfaces/Portalicious/src/app/services/auth/strategies/basic-auth/basic-auth.login.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component, - computed, inject, + input, } from '@angular/core'; import { FormControl, @@ -10,7 +10,6 @@ import { ReactiveFormsModule, Validators, } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; import { injectMutation } from '@tanstack/angular-query-experimental'; import { AutoFocusModule } from 'primeng/autofocus'; @@ -42,15 +41,7 @@ type LoginFormGroup = }) export class BasicAuthLoginComponent { private authService = inject(AuthService); - private route = inject(ActivatedRoute); - - private returnUrl = computed(() => { - const returnUrl: unknown = this.route.snapshot.queryParams.returnUrl; - if (typeof returnUrl !== 'string') { - return undefined; - } - return returnUrl; - }); + returnUrl = input(undefined); formGroup = new FormGroup({ email: new FormControl('', { diff --git a/interfaces/Portalicious/src/app/services/auth/strategies/basic-auth/basic-auth.strategy.ts b/interfaces/Portalicious/src/app/services/auth/strategies/basic-auth/basic-auth.strategy.ts index 5d8054fed7..131227c375 100644 --- a/interfaces/Portalicious/src/app/services/auth/strategies/basic-auth/basic-auth.strategy.ts +++ b/interfaces/Portalicious/src/app/services/auth/strategies/basic-auth/basic-auth.strategy.ts @@ -1,20 +1,29 @@ import { inject, Injectable } from '@angular/core'; +import { Router } from '@angular/router'; import { UserApiService } from '~/domains/user/user.api.service'; -import { LocalStorageUser } from '~/services/auth.service'; import { IAuthStrategy } from '~/services/auth/auth-strategy.interface'; import { BasicAuthChangePasswordComponent } from '~/services/auth/strategies/basic-auth/basic-auth.change-password.component'; import { BasicAuthLoginComponent } from '~/services/auth/strategies/basic-auth/basic-auth.login.component'; +import { LocalStorageUser } from '~/utils/local-storage'; @Injectable({ providedIn: 'root', }) export class BasicAuthStrategy implements IAuthStrategy { private readonly userApiService = inject(UserApiService); + private readonly router = inject(Router); + + static readonly APP_PROVIDERS = []; public LoginComponent = BasicAuthLoginComponent; public ChangePasswordComponent = BasicAuthChangePasswordComponent; + public initializeSubscriptions() { + // Not applicable for basic auth + return []; + } + public async login(credentials: { username: string; password: string }) { try { const user = await this.userApiService.login(credentials); @@ -61,4 +70,8 @@ export class BasicAuthStrategy implements IAuthStrategy { public isUserExpired(user: LocalStorageUser | null): boolean { return !user?.expires || Date.parse(user.expires) < Date.now(); } + + public handleAuthCallback(nextPageUrl: string): void { + void this.router.navigate([nextPageUrl]); + } } diff --git a/interfaces/Portalicious/src/app/services/auth/strategies/msal-auth/msal-auth.app-providers.ts b/interfaces/Portalicious/src/app/services/auth/strategies/msal-auth/msal-auth.app-providers.ts new file mode 100644 index 0000000000..fe7dd1e62d --- /dev/null +++ b/interfaces/Portalicious/src/app/services/auth/strategies/msal-auth/msal-auth.app-providers.ts @@ -0,0 +1,106 @@ +import { HTTP_INTERCEPTORS } from '@angular/common/http'; + +import { + MSAL_GUARD_CONFIG, + MSAL_INSTANCE, + MSAL_INTERCEPTOR_CONFIG, + MsalBroadcastService, + MsalGuard, + MsalGuardConfiguration, + MsalInterceptor, + MsalInterceptorConfiguration, + MsalService, +} from '@azure/msal-angular'; +import { + BrowserCacheLocation, + InteractionType, + IPublicClientApplication, + LogLevel, + PublicClientApplication, +} from '@azure/msal-browser'; + +import { AppRoutes } from '~/app.routes'; +import { isIframed } from '~/utils/is-iframed'; +import { environment } from '~environment'; + +function MSALInstanceFactory(): IPublicClientApplication { + return new PublicClientApplication({ + auth: { + clientId: environment.azure_ad_client_id, + authority: `${environment.azure_ad_url}/${environment.azure_ad_tenant_id}`, + redirectUri: `${window.location.origin}/${AppRoutes.authCallback}`, + postLogoutRedirectUri: `${window.location.origin}/${AppRoutes.login}`, + navigateToLoginRequestUrl: false, + }, + cache: { + cacheLocation: BrowserCacheLocation.LocalStorage, + }, + system: { + allowNativeBroker: false, // Disables WAM Broker + allowRedirectInIframe: false, + loggerOptions: { + loggerCallback: (_level, message, containsPii) => { + console.log(containsPii ? '👤' : '🌐', message); + }, + piiLoggingEnabled: !environment.production, + logLevel: LogLevel.Info, + }, + }, + }); +} + +function MSALInterceptorConfigFactory(): MsalInterceptorConfiguration { + const protectedResourceMap = new Map([ + [ + 'https://graph.microsoft.com/v1.0/me', + ['openid, offline_access, User.read'], + ], + // list open endpoints here first, without scopes + [`${environment.url_121_service_api}/users/login`, null], + // then catch all other protected endpoints with this wildcard + [ + `${environment.url_121_service_api}/*`, + [`api://${environment.azure_ad_client_id}/User.read`], + ], + ]); + + return { + interactionType: isIframed() + ? InteractionType.Popup + : InteractionType.Redirect, + protectedResourceMap, + }; +} + +function MSALGuardConfigFactory(): MsalGuardConfiguration { + return { + interactionType: isIframed() + ? InteractionType.Popup + : InteractionType.Redirect, + }; +} + +export function getMsalAuthAppProviders() { + return [ + { + provide: HTTP_INTERCEPTORS, + useClass: MsalInterceptor, + multi: true, + }, + { + provide: MSAL_INSTANCE, + useFactory: MSALInstanceFactory, + }, + { + provide: MSAL_GUARD_CONFIG, + useFactory: MSALGuardConfigFactory, + }, + { + provide: MSAL_INTERCEPTOR_CONFIG, + useFactory: MSALInterceptorConfigFactory, + }, + MsalService, + MsalGuard, + MsalBroadcastService, + ]; +} diff --git a/interfaces/Portalicious/src/app/services/auth/strategies/msal-auth/msal-auth.login.component.html b/interfaces/Portalicious/src/app/services/auth/strategies/msal-auth/msal-auth.login.component.html new file mode 100644 index 0000000000..e3416112b0 --- /dev/null +++ b/interfaces/Portalicious/src/app/services/auth/strategies/msal-auth/msal-auth.login.component.html @@ -0,0 +1,24 @@ + + + + + + diff --git a/interfaces/Portalicious/src/app/services/auth/strategies/msal-auth/msal-auth.login.component.ts b/interfaces/Portalicious/src/app/services/auth/strategies/msal-auth/msal-auth.login.component.ts new file mode 100644 index 0000000000..766a1dd7f1 --- /dev/null +++ b/interfaces/Portalicious/src/app/services/auth/strategies/msal-auth/msal-auth.login.component.ts @@ -0,0 +1,69 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, +} from '@angular/core'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; + +import { injectMutation } from '@tanstack/angular-query-experimental'; +import { AutoFocusModule } from 'primeng/autofocus'; +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; + +import { FormDefaultComponent } from '~/components/form/form-default.component'; +import { FormFieldWrapperComponent } from '~/components/form-field-wrapper/form-field-wrapper.component'; +import { AuthService } from '~/services/auth.service'; +import { generateFieldErrors } from '~/utils/form-validation'; + +type LoginFormSsoGroup = + (typeof MsalAuthLoginComponent)['prototype']['formGroup']; + +@Component({ + selector: 'app-msal-auth.login', + standalone: true, + imports: [ + ButtonModule, + FormFieldWrapperComponent, + ReactiveFormsModule, + ProgressSpinnerModule, + InputTextModule, + AutoFocusModule, + ReactiveFormsModule, + FormFieldWrapperComponent, + FormDefaultComponent, + ], + templateUrl: './msal-auth.login.component.html', + styles: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MsalAuthLoginComponent { + private authService = inject(AuthService); + returnUrl = input(undefined); + + formGroup = new FormGroup({ + email: new FormControl('', { + nonNullable: true, + // eslint-disable-next-line @typescript-eslint/unbound-method + validators: [Validators.required, Validators.email], + }), + }); + formFieldErrors = generateFieldErrors(this.formGroup, { + email: (control) => { + if (!control.invalid) { + return; + } + return $localize`Enter a valid email address`; + }, + }); + loginMutation = injectMutation(() => ({ + mutationFn: ({ email }: ReturnType) => + this.authService.login({ username: email }, this.returnUrl()), + })); +} diff --git a/interfaces/Portalicious/src/app/services/auth/strategies/msal-auth/msal-auth.strategy.ts b/interfaces/Portalicious/src/app/services/auth/strategies/msal-auth/msal-auth.strategy.ts index 5a23f57e37..74f6fe68b2 100644 --- a/interfaces/Portalicious/src/app/services/auth/strategies/msal-auth/msal-auth.strategy.ts +++ b/interfaces/Portalicious/src/app/services/auth/strategies/msal-auth/msal-auth.strategy.ts @@ -1,10 +1,129 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; +import { Router } from '@angular/router'; -import { BasicAuthStrategy } from '~/services/auth/strategies/basic-auth/basic-auth.strategy'; +import { MsalService } from '@azure/msal-angular'; +import { AuthenticationResult } from '@azure/msal-browser'; +import { injectQueryClient } from '@tanstack/angular-query-experimental'; + +import { AppRoutes } from '~/app.routes'; +import { UserApiService } from '~/domains/user/user.api.service'; +import { User } from '~/domains/user/user.model'; +import { AUTH_ERROR_IN_STATE_KEY } from '~/services/auth.service'; +import { IAuthStrategy } from '~/services/auth/auth-strategy.interface'; +import { getMsalAuthAppProviders } from '~/services/auth/strategies/msal-auth/msal-auth.app-providers'; +import { MsalAuthLoginComponent } from '~/services/auth/strategies/msal-auth/msal-auth.login.component'; +import { isIframed } from '~/utils/is-iframed'; +import { LocalStorageUser, setUserInLocalStorage } from '~/utils/local-storage'; +import { environment } from '~environment'; @Injectable({ providedIn: 'root', }) -export class MsalAuthStrategy extends BasicAuthStrategy { - // TODO: Remove "extends BasicAuthStrategy" and implement MSAL authentication +export class MsalAuthStrategy implements IAuthStrategy { + static readonly APP_PROVIDERS = getMsalAuthAppProviders(); + + public LoginComponent = MsalAuthLoginComponent; + public ChangePasswordComponent = null; + + private readonly msalService = inject(MsalService); + private readonly router = inject(Router); + private readonly userApiService = inject(UserApiService); + private queryClient = injectQueryClient(); + + constructor() { + this.msalService.initialize(); + } + + public initializeSubscriptions() { + return [this.msalService.handleRedirectObservable().subscribe()]; // Subscribing to handleRedirectObservable before any other functions both initializes the application and ensures redirects are handled + } + + public async login(credentials: { username: string }) { + if (!isIframed()) { + this.msalService.loginRedirect({ + scopes: [`api://${environment.azure_ad_client_id}/User.read`], + loginHint: credentials.username, + }); + } else { + throw new Error('TODO: AB#31469 Implement loginPopup for iframe'); + } + + // The user is being fetched & set in msal-auth.callback.component + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject(new Error('SSO login timed out')); + }, 60000); + }); + } + + public async logout(user: LocalStorageUser | null) { + if (!user?.username) { + return; + } + + const currentUser = this.msalService.instance.getAccountByUsername( + user.username, + ); + + if (!currentUser) { + return this.router.navigate(['/', AppRoutes.login]); + } + + const logoutRequest: Record = { + account: currentUser, + authority: `${environment.azure_ad_url}/${currentUser.tenantId}`, + mainWindowRedirectUri: `${window.location.origin}/${AppRoutes.login}`, + postLogoutRedirectUri: `${window.location.origin}/${AppRoutes.login}`, + }; + if (isIframed()) { + this.msalService.logoutPopup(logoutRequest); + } else { + this.msalService.logoutRedirect(logoutRequest); + } + return; + } + + public async changePassword() { + return Promise.reject( + new Error('This should never be called for MSAL service.'), + ); + } + + public isUserExpired() { + // Not applicable for MSAL + return false; + } + + public handleAuthCallback(nextPageUrl: string) { + const subscription = this.msalService + .handleRedirectObservable() + .subscribe((data: AuthenticationResult | null) => { + if (!data) { + return; + } + subscription.unsubscribe(); + + void this.refreshUserAndNavigate(nextPageUrl); + }); + } + + async refreshUserAndNavigate(nextPageUrl: string) { + try { + const currentUser = await this.queryClient.fetchQuery( + this.userApiService.getCurrent()(), + ); + setUserInLocalStorage(currentUser.user); + await this.router.navigate([nextPageUrl], { + // set to undefined because otherwise the auth error lingers in some local/session storage + state: undefined, + }); + } catch { + // TODO: AB#31489 Return a more generic endpoint from the back-end + await this.router.navigate(['/', AppRoutes.login], { + state: { + [AUTH_ERROR_IN_STATE_KEY]: $localize`Unknown user account or authentication failed`, + }, + }); + } + } } diff --git a/interfaces/Portalicious/src/app/services/http-wrapper.service.ts b/interfaces/Portalicious/src/app/services/http-wrapper.service.ts index 8785d252be..c77d06640c 100644 --- a/interfaces/Portalicious/src/app/services/http-wrapper.service.ts +++ b/interfaces/Portalicious/src/app/services/http-wrapper.service.ts @@ -14,7 +14,7 @@ import { catchError, tap } from 'rxjs/operators'; import { InterfaceNames } from '@121-service/src/shared/enum/interface-names.enum'; -import { getUserFromLocalStorage } from '~/services/auth.service'; +import { getUserFromLocalStorage } from '~/utils/local-storage'; import { environment } from '~environment'; interface PerformRequestParams { diff --git a/interfaces/Portalicious/src/app/utils/is-iframed.ts b/interfaces/Portalicious/src/app/utils/is-iframed.ts new file mode 100644 index 0000000000..d8a8e39bd2 --- /dev/null +++ b/interfaces/Portalicious/src/app/utils/is-iframed.ts @@ -0,0 +1,6 @@ +/** + * Whether the current page is loaded in an iframe. + */ +export function isIframed(): boolean { + return window.self !== window.top; +} diff --git a/interfaces/Portalicious/src/app/utils/local-storage.ts b/interfaces/Portalicious/src/app/utils/local-storage.ts new file mode 100644 index 0000000000..4f92faf69c --- /dev/null +++ b/interfaces/Portalicious/src/app/utils/local-storage.ts @@ -0,0 +1,55 @@ +import { User } from '~/domains/user/user.model'; +export const LOCAL_STORAGE_AUTH_USER_KEY = 'logged-in-user-portalicious'; +export const LOCAL_STORAGE_RETURN_URL = 'return-url-portalicious'; +export type LocalStorageUser = Pick< + User, + | 'expires' + | 'isAdmin' + | 'isEntraUser' + | 'isOrganizationAdmin' + | 'permissions' + | 'username' +>; + +export function setUserInLocalStorage(user: User): void { + const userToStore: LocalStorageUser = { + username: user.username, + permissions: user.permissions, + isAdmin: user.isAdmin, + isOrganizationAdmin: user.isOrganizationAdmin, + isEntraUser: user.isEntraUser, + expires: user.expires, + }; + + localStorage.setItem( + LOCAL_STORAGE_AUTH_USER_KEY, + JSON.stringify(userToStore), + ); +} + +export function getUserFromLocalStorage(): LocalStorageUser | null { + const rawUser = localStorage.getItem(LOCAL_STORAGE_AUTH_USER_KEY); + + if (!rawUser) { + return null; + } + + let user: LocalStorageUser; + + try { + user = JSON.parse(rawUser) as LocalStorageUser; + } catch { + console.warn('AuthService: Invalid token'); + return null; + } + + return user; +} + +export function setReturnUrlInLocalStorage(returnUrl: string): void { + localStorage.setItem(LOCAL_STORAGE_RETURN_URL, returnUrl); +} + +export function getReturnUrlFromLocalStorage(): null | string { + return localStorage.getItem(LOCAL_STORAGE_RETURN_URL); +} diff --git a/interfaces/Portalicious/src/environments/environment.ts b/interfaces/Portalicious/src/environments/environment.ts index 1e771e6a13..71c5744f7a 100644 --- a/interfaces/Portalicious/src/environments/environment.ts +++ b/interfaces/Portalicious/src/environments/environment.ts @@ -20,4 +20,5 @@ export const environment = { use_sso_azure_entra: false, // Enable Azure AD login azure_ad_client_id: '', azure_ad_tenant_id: '', + azure_ad_url: '', }; diff --git a/interfaces/Portalicious/src/environments/environment.ts.template.js b/interfaces/Portalicious/src/environments/environment.ts.template.js index b7b01de7b4..5ed55daba8 100644 --- a/interfaces/Portalicious/src/environments/environment.ts.template.js +++ b/interfaces/Portalicious/src/environments/environment.ts.template.js @@ -19,5 +19,6 @@ export const environment = { use_sso_azure_entra: ${process.env.USE_SSO_AZURE_ENTRA || 'false'}, azure_ad_client_id: '${process.env.AZURE_ENTRA_CLIENT_ID || ''}', azure_ad_tenant_id: '${process.env.AZURE_ENTRA_TENANT_ID || ''}', + azure_ad_url: '${process.env.AZURE_ENTRA_URL || ''}', }; `; diff --git a/interfaces/Portalicious/src/locale/messages.nl.xlf b/interfaces/Portalicious/src/locale/messages.nl.xlf index d8b6a811d4..12c6091d57 100644 --- a/interfaces/Portalicious/src/locale/messages.nl.xlf +++ b/interfaces/Portalicious/src/locale/messages.nl.xlf @@ -1448,6 +1448,14 @@ Payment not found. Please check the URL and try again. Payment not found. Please check the URL and try again. + + Redirecting to 121… + Redirecting to 121… + + + Unknown user account or authentication failed + Unknown user account or authentication failed + \ No newline at end of file diff --git a/interfaces/Portalicious/src/locale/messages.xlf b/interfaces/Portalicious/src/locale/messages.xlf index 7eaa02d653..f64ed71376 100644 --- a/interfaces/Portalicious/src/locale/messages.xlf +++ b/interfaces/Portalicious/src/locale/messages.xlf @@ -1088,6 +1088,12 @@ Payment not found. Please check the URL and try again. + + Redirecting to 121… + + + Unknown user account or authentication failed + \ No newline at end of file