Skip to main content

Authentication Service

Azure Entra ID (formerly Azure AD) integration for enterprise authentication with support for SPA (Authorization Code Flow + PKCE).

Features

  • Azure Entra ID: Enterprise SSO integration
  • PKCE Flow: Secure Authorization Code Flow with PKCE for SPAs
  • Token Management: Automatic token refresh
  • Route Guards: Protect routes with authentication
  • TraceId Integration: User ID propagated as TraceId
  • HTTP Interceptor: Automatic Bearer token injection
  • Silent Refresh: Background token renewal
  • Multi-tenant: Support for B2B and B2C scenarios

Installation

npm install @azure/msal-browser @tess-ui-library/core

Configuration

1. Environment Setup

Create environment configuration:

// src/environments/environment.ts
export const environment = {
production: false,
auth: {
clientId: 'YOUR_CLIENT_ID',
authority: 'https://login.microsoftonline.com/YOUR_TENANT_ID',
redirectUri: 'http://localhost:4200',
scopes: ['User.Read', 'api://your-api-id/access_as_user']
},
apiUrl: 'https://api.your-app.com'
};

2. App Configuration

// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideAuth } from '@tess-ui-library/core';
import { environment } from '../environments/environment';

export const appConfig: ApplicationConfig = {
providers: [
provideAuth({
clientId: environment.auth.clientId,
authority: environment.auth.authority,
redirectUri: environment.auth.redirectUri,
scopes: environment.auth.scopes,
cacheLocation: 'sessionStorage' // or 'localStorage'
}),
// ... other providers
]
};

Basic Usage

Login/Logout

import { Component, inject } from '@angular/core';
import { AuthService } from '@tess-ui-library/core';
import { ButtonModule } from 'primeng/button';

@Component({
selector: 'app-header',
standalone: true,
imports: [ButtonModule],
template: `
@if (authService.isAuthenticated()) {
<div class="user-info">
<span>Welcome, {{ authService.user()?.name }}</span>
<button pButton
label="Logout"
icon="pi pi-sign-out"
(click)="logout()">
</button>
</div>
} @else {
<button pButton
label="Login"
icon="pi pi-sign-in"
(click)="login()">
</button>
}
`
})
export class HeaderComponent {
authService = inject(AuthService);

login() {
this.authService.login();
}

logout() {
this.authService.logout();
}
}

Route Guards

// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from '@tess-ui-library/core';

export const routes: Routes = [
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./dashboard/dashboard.component')
},
{
path: 'admin',
canActivate: [authGuard],
data: { roles: ['Admin'] }, // Optional role check
loadComponent: () => import('./admin/admin.component')
},
{
path: 'login',
loadComponent: () => import('./login/login.component')
}
];

HTTP Interceptor

// src/app/app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from '@tess-ui-library/core';

export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor])
),
// Automatically adds Authorization header:
// Authorization: Bearer <access_token>
]
};

API Reference

AuthService

Signals (Read-only)

SignalTypeDescription
isAuthenticatedSignal<boolean>Whether user is authenticated
userSignal<User | null>Current user info
isLoadingSignal<boolean>Authentication in progress

Methods

MethodParametersReturnsDescription
login()redirectUrl?: stringPromise<void>Redirect to login
loginPopup()-Promise<Result<void>>Login via popup
logout()-voidLogout user
acquireToken()-Promise<Result<string>>Get access token
getUser()-User | nullGet current user
hasRole()role: stringbooleanCheck if user has role

User Model

interface User {
id: string; // User identifier
name: string; // Display name
email: string; // Email address
roles: string[]; // Assigned roles
tenantId: string; // Azure tenant ID
idToken: string; // ID token
accessToken: string; // Access token
expiresOn: Date; // Token expiration
}

Advanced Usage

Role-Based Access Control

import { Component, inject, OnInit } from '@angular/core';
import { AuthService } from '@tess-ui-library/core';

@Component({
selector: 'app-admin-panel',
template: `
@if (isAdmin) {
<div class="admin-panel">
<!-- Admin content -->
</div>
} @else {
<div class="access-denied">
<h2>Access Denied</h2>
<p>You do not have permission to view this page.</p>
</div>
}
`
})
export class AdminPanelComponent implements OnInit {
authService = inject(AuthService);
isAdmin = false;

ngOnInit() {
this.isAdmin = this.authService.hasRole('Admin');
}
}

Acquire Token for API Calls

import { inject } from '@angular/core';
import { AuthService } from '@tess-ui-library/core';
import { HttpClient } from '@angular/common/http';

export class UserService {
private authService = inject(AuthService);
private http = inject(HttpClient);

async getUserProfile() {
// Acquire token (with automatic refresh if needed)
const tokenResult = await this.authService.acquireToken();

if (!tokenResult.success) {
return Result.fail(tokenResult.errors);
}

// Token automatically added by authInterceptor
return this.http.get<UserProfile>('/api/user/profile').toPromise();
}
}

Handle Token Expiration

import { Component, OnInit, inject } from '@angular/core';
import { AuthService } from '@tess-ui-library/core';
import { Router } from '@angular/router';

@Component({
selector: 'app-root',
template: `<router-outlet />`
})
export class AppComponent implements OnInit {
private authService = inject(AuthService);
private router = inject(Router);

ngOnInit() {
// Listen for token expiration
this.authService.onTokenExpired().subscribe(() => {
console.warn('Token expired, redirecting to login...');
this.router.navigate(['/login']);
});
}
}

Configuration Options

MSAL Configuration

interface AuthConfig {
clientId: string; // Azure App Registration Client ID
authority: string; // Authority URL (tenant-specific)
redirectUri: string; // Redirect URI after login
postLogoutRedirectUri?: string; // Redirect URI after logout
scopes: string[]; // Requested scopes
cacheLocation?: 'localStorage' | 'sessionStorage'; // Token cache
storeAuthStateInCookie?: boolean; // For IE11/Edge support
}

Azure App Registration Setup

  1. Register Application in Azure Portal
  2. Platform Configuration: Add SPA platform
  3. Redirect URIs: http://localhost:4200, https://your-app.com
  4. API Permissions: Add required scopes (e.g., User.Read)
  5. ID Tokens: Enable ID tokens checkbox

Testing

Mock AuthService

// src/testing/mocks/auth.service.mock.ts
import { signal } from '@angular/core';

export const mockAuthService = {
isAuthenticated: signal(true),
user: signal({
id: 'test-user-id',
name: 'Test User',
email: 'test@example.com',
roles: ['User'],
tenantId: 'test-tenant',
idToken: 'mock-id-token',
accessToken: 'mock-access-token',
expiresOn: new Date(Date.now() + 3600000)
}),
isLoading: signal(false),
login: jest.fn(),
logout: jest.fn(),
acquireToken: jest.fn().mockResolvedValue(Result.ok('mock-token')),
hasRole: jest.fn().mockReturnValue(true)
};

Component Test with AuthService

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HeaderComponent } from './header.component';
import { AuthService } from '@tess-ui-library/core';
import { mockAuthService } from '../testing/mocks/auth.service.mock';

describe('HeaderComponent', () => {
let component: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HeaderComponent],
providers: [
{ provide: AuthService, useValue: mockAuthService }
]
}).compileComponents();

fixture = TestBed.createComponent(HeaderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should show logout button when authenticated', () => {
mockAuthService.isAuthenticated.set(true);
fixture.detectChanges();

const logoutBtn = fixture.nativeElement.querySelector('[label="Logout"]');
expect(logoutBtn).toBeTruthy();
});

it('should call authService.logout when logout clicked', () => {
const spy = jest.spyOn(component.authService, 'logout');
component.logout();
expect(spy).toHaveBeenCalled();
});
});

Troubleshooting

Redirect Loop

Problem: App keeps redirecting to login page.

Solution: Check redirect URI configuration:

// ✅ Ensure redirectUri matches Azure App Registration
auth: {
redirectUri: window.location.origin // Uses current domain
}

CORS Errors

Problem: CORS errors when calling API.

Solution: Add CORS policy in backend:

// ASP.NET Core backend
services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", builder =>
{
builder.WithOrigins("http://localhost:4200", "https://your-app.com")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});

Token Not Attached

Problem: Authorization header missing in API requests.

Solution: Ensure authInterceptor is registered:

// app.config.ts
provideHttpClient(
withInterceptors([authInterceptor]) // ← Required
)

Best Practices

1. Use Route Guards

// ✅ GOOD: Protect routes with guard
{
path: 'admin',
canActivate: [authGuard],
loadComponent: () => import('./admin/admin.component')
}

// ❌ BAD: Manual authentication check in component
ngOnInit() {
if (!this.authService.isAuthenticated()) {
this.router.navigate(['/login']);
}
}

2. Handle Token Refresh

// ✅ GOOD: Automatic token refresh
const tokenResult = await this.authService.acquireToken();

// ❌ BAD: Use cached token without refresh
const token = this.authService.user()?.accessToken;

3. Centralize Role Checks

// ✅ GOOD: Use hasRole method
if (this.authService.hasRole('Admin')) {
// Show admin features
}

// ❌ BAD: Manual role array check
if (this.authService.user()?.roles.includes('Admin')) {
// Brittle and verbose
}