Single Sign-On (SSO) using Azure Active Directory (Azure AD) is a common requirement for enterprise Angular applications. It improves security, reduces password fatigue, and centralizes identity management.
In this blog, we’ll walk through a real-world Angular implementation of Azure AD SSO using:
-
OAuth 2.0 Authorization Code Flow
-
PKCE (Proof Key for Code Exchange)
-
Azure AD v2.0 endpoints
-
Microsoft Graph API
We’ll also support traditional username/password login, making this a hybrid authentication system.
High-Level Architecture
Here’s how the Azure AD SSO flow works in our Angular SPA:
-
User clicks “Login with Azure AD”
-
Angular app redirects user to Azure AD
/authorizeendpoint -
User authenticates using corporate credentials
-
Azure AD redirects back with an authorization code
-
Angular exchanges the code for an access token
-
Angular fetches user profile from Microsoft Graph
-
App stores token + user details and marks user authenticated
Environment Configuration
First, configure Azure AD details in environment.ts:
export const environment = {
production: false,
authApiUrl: 'https://api.example.com/auth',
azure: {
clientId: 'YOUR_AZURE_CLIENT_ID',
authority: 'https://login.microsoftonline.com/YOUR_TENANT_ID',
redirectUrl: 'http://localhost:4200/auth-callback',
scopes: ['openid', 'profile', 'email', 'User.Read']
}
};
AuthService Overview
The AuthService handles:
-
Username/password login
-
Azure AD OAuth login
-
Token exchange using PKCE
-
Fetching user details from Graph API
-
Authentication state management
Key concepts used:
-
BehaviorSubjectfor auth state -
RxJS operators (
tap,switchMap) -
Secure token storage
Traditional Login (Username & Password)
login(username: string, password: string): Observable<LoginResponse> {
return this.http.post<LoginResponse>(`${this.apiUrl}/login`, {
username,
password
}).pipe(
tap(response => {
this.setAuthData(response.token, response.user, false);
this.isAuthenticatedSubject.next(true);
})
);
}
This flow is useful when:
-
Azure AD is unavailable
-
Supporting external users
-
Migrating legacy systems
Azure AD Login – Authorization Request
loginWithAzureAD(): void {
this.state = this.generateState();
localStorage.setItem('oauth_state', this.state);
this.codeVerifier = this.generateCodeVerifier();
const codeChallenge = this.generateCodeChallengePlain(this.codeVerifier);
localStorage.setItem('code_verifier', this.codeVerifier);
const authorizationUrl = new URL(
`${this.azureConfig.authority}/oauth2/v2.0/authorize`
);
authorizationUrl.searchParams.append('client_id', this.azureConfig.clientId);
authorizationUrl.searchParams.append('redirect_uri', this.azureConfig.redirectUrl);
authorizationUrl.searchParams.append('response_type', 'code');
authorizationUrl.searchParams.append('scope', this.azureConfig.scopes.join(' '));
authorizationUrl.searchParams.append('state', this.state);
authorizationUrl.searchParams.append('code_challenge', codeChallenge);
authorizationUrl.searchParams.append('code_challenge_method', 'plain');
window.location.href = authorizationUrl.toString();
}
Why PKCE?
-
Prevents authorization code interception
-
Mandatory for SPAs
-
No client secret needed
⚠️ Production Tip: Always use
S256instead ofplain.
Handling Azure Redirect & Token Exchange
Once Azure redirects back with a code, we exchange it for an access token:
exchangeCodeForToken(code: string): Observable<LoginResponse> {
const tokenEndpoint = `${this.azureConfig.authority}/oauth2/v2.0/token`;
const tokenParams = new URLSearchParams({
client_id: this.azureConfig.clientId,
grant_type: 'authorization_code',
code: code,
redirect_uri: this.azureConfig.redirectUrl,
code_verifier: localStorage.getItem('code_verifier') || '',
scope: this.azureConfig.scopes.join(' ')
});
return this.http.post<any>(tokenEndpoint, tokenParams.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
}
Fetching User Details from Microsoft Graph
private getAzureUserProfile(accessToken: string): Observable<any> {
return this.http.get('https://graph.microsoft.com/v1.0/me', {
headers: {
Authorization: `Bearer ${accessToken}`
}
});
}
This gives us:
-
Email
-
First name / Last name
-
Department
-
Job title
Which we map to our application user model.
Storing Authentication State
private setAuthData(token: string, user: CurrentUser): void {
localStorage.setItem('authToken', token);
localStorage.setItem('currentUser', JSON.stringify(user));
this.currentUserSubject.next(user);
this.isAuthenticatedSubject.next(true);
}
Authentication state is exposed via:
currentUser$ = this.currentUserSubject.asObservable();
isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
Perfect for:
-
Route guards
-
Role-based UI
-
Lazy-loaded modules
Logout Flow
logout(): Observable<any> {
return this.http.post(`${this.apiUrl}/logout`, {}).pipe(
tap(() => this.clearAuthData())
);
}
COPY PASTE
- import { Injectable } from '@angular/core';
- import { HttpClient, HttpHeaders } from '@angular/common/http';
- import { Observable, BehaviorSubject } from 'rxjs';
- import { tap, catchError, switchMap } from 'rxjs/operators';
- import { of } from 'rxjs';
- import { environment } from '../../../environments/environment';
-
- const API_BASE_URL = environment.authApiUrl;
-
- export interface LoginRequest {
- username: string;
- password: string;
- rememberMe: boolean;
- }
-
- export interface LoginResponse {
- token: string;
- user: {
- id: string;
- email: string;
- firstName: string;
- lastName: string;
- department: string;
- jobTitle?: string;
- roles: string[];
- };
- }
-
- export interface CurrentUser {
- id: string;
- email: string;
- firstName: string;
- lastName: string;
- department: string;
- jobTitle?: string;
- roles: string[];
- }
-
- export interface ADUserInfo {
- username: string;
- email: string;
- firstName: string;
- lastName: string;
- department: string;
- title?: string;
- }
-
- @Injectable({
- providedIn: 'root'
- })
- export class AuthService {
- private apiUrl = `${API_BASE_URL}`;
- private currentUserSubject = new BehaviorSubject<CurrentUser | null>(null);
- private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);
- public currentUser$ = this.currentUserSubject.asObservable();
- public isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
-
- // OAuth2 Configuration
- private azureConfig = environment.azure;
- private state: string = '';
- private codeVerifier: string = '';
-
- constructor(private http: HttpClient) {
- this.loadCurrentUser();
- }
-
- /**
- * Initiate login with username and password
- */
- login(username: string, password: string): Observable<LoginResponse> {
- return this.http.post<LoginResponse>(`${this.apiUrl}/login`, {
- username,
- password
- }).pipe(
- tap((response) => {
- this.setAuthData(response.token, response.user, false);
- this.isAuthenticatedSubject.next(true);
- }),
- catchError((error: any) => {
- console.error('Login error:', error);
- throw error;
- })
- );
- }
-
- /**
- * Initiate Azure AD OAuth2 login with PKCE
- */
- loginWithAzureAD(): void {
- // Generate state for CSRF protection
- this.state = this.generateState();
- localStorage.setItem('oauth_state', this.state);
-
- // Generate PKCE code verifier and challenge
- this.codeVerifier = this.generateCodeVerifier();
- const codeChallenge = this.generateCodeChallengePlain(this.codeVerifier);
- localStorage.setItem('code_verifier', this.codeVerifier);
-
- // Build authorization URL
- const authorizationUrl = new URL(`${this.azureConfig.authority}/oauth2/v2.0/authorize`);
- authorizationUrl.searchParams.append('client_id', this.azureConfig.clientId);
- authorizationUrl.searchParams.append('redirect_uri', this.azureConfig.redirectUrl);
- authorizationUrl.searchParams.append('response_type', 'code');
- authorizationUrl.searchParams.append('scope', this.azureConfig.scopes.join(' '));
- authorizationUrl.searchParams.append('state', this.state);
- authorizationUrl.searchParams.append('response_mode', 'query');
- authorizationUrl.searchParams.append('prompt', 'select_account');
- // PKCE parameters - using plain method for simplicity
- authorizationUrl.searchParams.append('code_challenge', codeChallenge);
- authorizationUrl.searchParams.append('code_challenge_method', 'plain');
-
- // Redirect to Azure AD
- window.location.href = authorizationUrl.toString();
- }
-
- /**
- * Exchange authorization code for access token (SPA flow - frontend exchanges directly with Azure)
- */
- exchangeCodeForToken(code: string): Observable<LoginResponse> {
- const codeVerifier = localStorage.getItem('code_verifier') || '';
- const tokenEndpoint = `${this.azureConfig.authority}/oauth2/v2.0/token`;
-
- // Step 1: Exchange code for access token directly with Azure
- const tokenParams = new URLSearchParams({
- client_id: this.azureConfig.clientId,
- grant_type: 'authorization_code',
- code: code,
- redirect_uri: this.azureConfig.redirectUrl,
- code_verifier: codeVerifier,
- scope: this.azureConfig.scopes.join(' ')
- });
-
- // Exchange code with Azure directly
- return this.http.post<any>(tokenEndpoint, tokenParams.toString(), {
- headers: new HttpHeaders({
- 'Content-Type': 'application/x-www-form-urlencoded'
- })
- }).pipe(
- // Step 2: Validate the Azure access token
- tap((azureResponse) => {
- // Validate token response structure
- if (!azureResponse || !azureResponse.access_token) {
- throw new Error('Invalid response from Azure: missing access_token');
- }
- if (azureResponse.error) {
- throw new Error(`Azure error: ${azureResponse.error} - ${azureResponse.error_description}`);
- }
- // Validate token format (JWT format: header.payload.signature)
- const tokenParts = azureResponse.access_token.split('.');
- if (tokenParts.length !== 3) {
- throw new Error('Invalid access token format');
- }
- console.log('Azure token validation successful');
- }),
- // Step 3: Get user profile details from Azure Graph API
- switchMap((azureResponse) => {
- return this.getAzureUserProfile(azureResponse.access_token).pipe(
- tap((profile) => {
- console.log('User profile retrieved:', profile.mail);
-
- // Store the Azure access token and user profile directly
- localStorage.removeItem('oauth_state');
- localStorage.removeItem('code_verifier');
-
- // Create user object from Azure profile
- const user: CurrentUser = {
- id: profile.id || '',
- email: profile.mail || '',
- firstName: profile.givenName || '',
- lastName: profile.surname || '',
- department: profile.department || '',
- jobTitle: profile.jobTitle,
- roles: ['user']
- };
-
- // Store user data using Azure token as auth token
- this.setAuthData(azureResponse.access_token, user, false);
- this.isAuthenticatedSubject.next(true);
- })
- );
- }),
- switchMap(() => {
- // Return empty observable to complete the chain
- return of({ token: '', user: this.getCurrentUserValue() } as LoginResponse);
- }),
- catchError((error: any) => {
- console.error('Token exchange error:', error);
- localStorage.removeItem('oauth_state');
- localStorage.removeItem('code_verifier');
- throw error;
- })
- );
- }
-
- /**
- * Handle OAuth callback from Azure AD
- */
- handleAuthCallback(token: string, user: any, rememberMe: boolean = false): void {
- this.setAuthData(token, user, rememberMe);
- this.isAuthenticatedSubject.next(true);
- }
-
- logout(): Observable<any> {
- return this.http.post(`${this.apiUrl}/logout`, {}).pipe(
- tap(() => {
- this.clearAuthData();
- this.isAuthenticatedSubject.next(false);
- }),
- catchError((error: any) => {
- // Clear auth data even if logout API call fails
- this.clearAuthData();
- this.isAuthenticatedSubject.next(false);
- return of(null);
- })
- );
- }
-
- getCurrentUser(): Observable<CurrentUser> {
- return this.http.get<CurrentUser>(`${this.apiUrl}/current-user`);
- }
-
- isAuthenticated(): boolean {
- return !!this.getToken();
- }
-
- getToken(): string | null {
- return localStorage.getItem('authToken');
- }
-
- setToken(token: string): void {
- localStorage.setItem('authToken', token);
- this.isAuthenticatedSubject.next(true);
- }
-
- getCurrentUserValue(): CurrentUser | null {
- return this.currentUserSubject.value;
- }
-
- hasRole(role: string): boolean {
- const user = this.currentUserSubject.value;
- return user ? user.roles.includes(role) : false;
- }
-
- hasAnyRole(roles: string[]): boolean {
- const user = this.currentUserSubject.value;
- return user ? roles.some(role => user.roles.includes(role)) : false;
- }
-
- private setAuthData(token: string, user: CurrentUser, rememberMe: boolean): void {
- localStorage.setItem('authToken', token);
- localStorage.setItem('currentUser', JSON.stringify(user));
- this.currentUserSubject.next(user);
-
- if (rememberMe) {
- localStorage.setItem('rememberMe', 'true');
- }
- }
-
- clearAuthData(): void {
- localStorage.removeItem('authToken');
- localStorage.removeItem('currentUser');
- localStorage.removeItem('rememberMe');
- this.currentUserSubject.next(null);
- }
-
- private loadCurrentUser(): void {
- const token = this.getToken();
- const userStr = localStorage.getItem('currentUser');
-
- if (token && userStr) {
- try {
- const user = JSON.parse(userStr);
- this.currentUserSubject.next(user);
- this.isAuthenticatedSubject.next(true);
- } catch (e) {
- this.clearAuthData();
- }
- }
- }
-
- /**
- * Generate random state for OAuth CSRF protection
- */
- private generateState(): string {
- return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
- }
-
- /**
- * Generate PKCE code verifier (43-128 characters)
- */
- private generateCodeVerifier(): string {
- const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
- let result = '';
- const length = 128;
-
- for (let i = 0; i < length; i++) {
- result += characters.charAt(Math.floor(Math.random() * characters.length));
- }
-
- return result;
- }
-
- /**
- * For PKCE with plain method, challenge = verifier
- * (In production, use S256 with proper SHA256 hashing)
- */
- private generateCodeChallengePlain(codeVerifier: string): string {
- return codeVerifier;
- }
-
- /**
- * Decode and validate JWT token
- */
- private decodeToken(token: string): any {
- try {
- const parts = token.split('.');
- if (parts.length !== 3) {
- throw new Error('Invalid token format');
- }
-
- // Decode payload (second part)
- const payload = parts[1];
- const decoded = JSON.parse(atob(payload));
-
- // Check if token is expired
- if (decoded.exp) {
- const expirationTime = decoded.exp * 1000; // Convert to milliseconds
- if (Date.now() >= expirationTime) {
- throw new Error('Token has expired');
- }
- }
-
- return decoded;
- } catch (error) {
- console.error('Error decoding token:', error);
- throw error;
- }
- }
-
- /**
- * Get user profile details from Azure Graph API
- */
- private getAzureUserProfile(accessToken: string): Observable<any> {
- const graphApiUrl = 'https://graph.microsoft.com/v1.0/me';
-
- return this.http.get<any>(graphApiUrl, {
- headers: new HttpHeaders({
- 'Authorization': `Bearer ${accessToken}`,
- 'Content-Type': 'application/json'
- })
- }).pipe(
- tap((profile) => {
- // Validate profile response
- if (!profile || !profile.mail) {
- throw new Error('Invalid profile: missing email');
- }
- console.log('Profile details:', {
- id: profile.id,
- mail: profile.mail,
- displayName: profile.displayName,
- givenName: profile.givenName,
- surname: profile.surname,
- department: profile.department,
- jobTitle: profile.jobTitle,
- mobilePhone: profile.mobilePhone,
- officeLocation: profile.officeLocation
- });
- }),
- catchError((error) => {
- console.error('Failed to fetch user profile from Azure Graph:', error);
- throw new Error(`Failed to fetch user profile: ${error.message}`);
- })
- );
- }
- }


Post a Comment
0Comments