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)
| Signal | Type | Description |
|---|---|---|
isAuthenticated | Signal<boolean> | Whether user is authenticated |
user | Signal<User | null> | Current user info |
isLoading | Signal<boolean> | Authentication in progress |
Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
login() | redirectUrl?: string | Promise<void> | Redirect to login |
loginPopup() | - | Promise<Result<void>> | Login via popup |
logout() | - | void | Logout user |
acquireToken() | - | Promise<Result<string>> | Get access token |
getUser() | - | User | null | Get current user |
hasRole() | role: string | boolean | Check 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
- Register Application in Azure Portal
- Platform Configuration: Add SPA platform
- Redirect URIs:
http://localhost:4200,https://your-app.com - API Permissions: Add required scopes (e.g.,
User.Read) - 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
}