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.
+
+
+ Redirecting to 121…
+
+
+
+ Unknown user account or authentication failed
+