Angular Azure AD SSO Integration Using OAuth2 (PKCE) – Step by Step Guide

Lawson Borges
By -
0

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:

  1. User clicks “Login with Azure AD”

  2. Angular app redirects user to Azure AD /authorize endpoint

  3. User authenticates using corporate credentials

  4. Azure AD redirects back with an authorization code

  5. Angular exchanges the code for an access token

  6. Angular fetches user profile from Microsoft Graph

  7. 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:

  • BehaviorSubject for 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 S256 instead of plain.


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
  1. import { Injectable } from '@angular/core';  
  2. import { HttpClient, HttpHeaders } from '@angular/common/http';  
  3. import { Observable, BehaviorSubject } from 'rxjs';  
  4. import { tap, catchError, switchMap } from 'rxjs/operators';  
  5. import { of } from 'rxjs';  
  6. import { environment } from '../../../environments/environment';  
  7.   
  8. const API_BASE_URL = environment.authApiUrl;  
  9.   
  10. export interface LoginRequest {  
  11.     username: string;  
  12.     password: string;  
  13.     rememberMe: boolean;  
  14. }  
  15.   
  16. export interface LoginResponse {  
  17.     token: string;  
  18.     user: {  
  19.         id: string;  
  20.         email: string;  
  21.         firstName: string;  
  22.         lastName: string;  
  23.         department: string;  
  24.         jobTitle?: string;  
  25.         roles: string[];  
  26.     };  
  27. }  
  28.   
  29. export interface CurrentUser {  
  30.     id: string;  
  31.     email: string;  
  32.     firstName: string;  
  33.     lastName: string;  
  34.     department: string;  
  35.     jobTitle?: string;  
  36.     roles: string[];  
  37. }  
  38.   
  39. export interface ADUserInfo {  
  40.     username: string;  
  41.     email: string;  
  42.     firstName: string;  
  43.     lastName: string;  
  44.     department: string;  
  45.     title?: string;  
  46. }  
  47.   
  48. @Injectable({  
  49.     providedIn: 'root'  
  50. })  
  51. export class AuthService {  
  52.     private apiUrl = `${API_BASE_URL}`;  
  53.     private currentUserSubject = new BehaviorSubject<CurrentUser | null>(null);  
  54.     private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);  
  55.     public currentUser$ = this.currentUserSubject.asObservable();  
  56.     public isAuthenticated$ = this.isAuthenticatedSubject.asObservable();  
  57.   
  58.     // OAuth2 Configuration  
  59.     private azureConfig = environment.azure;  
  60.     private state: string = '';  
  61.     private codeVerifier: string = '';  
  62.   
  63.     constructor(private http: HttpClient) {  
  64.         this.loadCurrentUser();  
  65.     }  
  66.   
  67.     /** 
  68.      * Initiate login with username and password 
  69.      */  
  70.     login(username: string, password: string): Observable<LoginResponse> {  
  71.         return this.http.post<LoginResponse>(`${this.apiUrl}/login`, {  
  72.             username,  
  73.             password  
  74.         }).pipe(  
  75.             tap((response) => {  
  76.                 this.setAuthData(response.token, response.user, false);  
  77.                 this.isAuthenticatedSubject.next(true);  
  78.             }),  
  79.             catchError((error: any) => {  
  80.                 console.error('Login error:', error);  
  81.                 throw error;  
  82.             })  
  83.         );  
  84.     }  
  85.   
  86.     /** 
  87.      * Initiate Azure AD OAuth2 login with PKCE 
  88.      */  
  89.     loginWithAzureAD(): void {  
  90.         // Generate state for CSRF protection  
  91.         this.state = this.generateState();  
  92.         localStorage.setItem('oauth_state'this.state);  
  93.   
  94.         // Generate PKCE code verifier and challenge  
  95.         this.codeVerifier = this.generateCodeVerifier();  
  96.         const codeChallenge = this.generateCodeChallengePlain(this.codeVerifier);  
  97.         localStorage.setItem('code_verifier'this.codeVerifier);  
  98.   
  99.         // Build authorization URL  
  100.         const authorizationUrl = new URL(`${this.azureConfig.authority}/oauth2/v2.0/authorize`);  
  101.         authorizationUrl.searchParams.append('client_id'this.azureConfig.clientId);  
  102.         authorizationUrl.searchParams.append('redirect_uri'this.azureConfig.redirectUrl);  
  103.         authorizationUrl.searchParams.append('response_type''code');  
  104.         authorizationUrl.searchParams.append('scope'this.azureConfig.scopes.join(' '));  
  105.         authorizationUrl.searchParams.append('state'this.state);  
  106.         authorizationUrl.searchParams.append('response_mode''query');  
  107.         authorizationUrl.searchParams.append('prompt''select_account');  
  108.         // PKCE parameters - using plain method for simplicity  
  109.         authorizationUrl.searchParams.append('code_challenge', codeChallenge);  
  110.         authorizationUrl.searchParams.append('code_challenge_method''plain');  
  111.   
  112.         // Redirect to Azure AD  
  113.         window.location.href = authorizationUrl.toString();  
  114.     }  
  115.   
  116.     /** 
  117.      * Exchange authorization code for access token (SPA flow - frontend exchanges directly with Azure) 
  118.      */  
  119.     exchangeCodeForToken(code: string): Observable<LoginResponse> {  
  120.         const codeVerifier = localStorage.getItem('code_verifier') || '';  
  121.         const tokenEndpoint = `${this.azureConfig.authority}/oauth2/v2.0/token`;  
  122.   
  123.         // Step 1: Exchange code for access token directly with Azure  
  124.         const tokenParams = new URLSearchParams({  
  125.             client_id: this.azureConfig.clientId,  
  126.             grant_type: 'authorization_code',  
  127.             code: code,  
  128.             redirect_uri: this.azureConfig.redirectUrl,  
  129.             code_verifier: codeVerifier,  
  130.             scope: this.azureConfig.scopes.join(' ')  
  131.         });  
  132.   
  133.         // Exchange code with Azure directly  
  134.         return this.http.post<any>(tokenEndpoint, tokenParams.toString(), {  
  135.             headers: new HttpHeaders({  
  136.                 'Content-Type''application/x-www-form-urlencoded'  
  137.             })  
  138.         }).pipe(  
  139.             // Step 2: Validate the Azure access token  
  140.             tap((azureResponse) => {  
  141.                 // Validate token response structure  
  142.                 if (!azureResponse || !azureResponse.access_token) {  
  143.                     throw new Error('Invalid response from Azure: missing access_token');  
  144.                 }  
  145.                 if (azureResponse.error) {  
  146.                     throw new Error(`Azure error: ${azureResponse.error} - ${azureResponse.error_description}`);  
  147.                 }  
  148.                 // Validate token format (JWT format: header.payload.signature)  
  149.                 const tokenParts = azureResponse.access_token.split('.');  
  150.                 if (tokenParts.length !== 3) {  
  151.                     throw new Error('Invalid access token format');  
  152.                 }  
  153.                 console.log('Azure token validation successful');  
  154.             }),  
  155.             // Step 3: Get user profile details from Azure Graph API  
  156.             switchMap((azureResponse) => {  
  157.                 return this.getAzureUserProfile(azureResponse.access_token).pipe(  
  158.                     tap((profile) => {  
  159.                         console.log('User profile retrieved:', profile.mail);  
  160.   
  161.                         // Store the Azure access token and user profile directly  
  162.                         localStorage.removeItem('oauth_state');  
  163.                         localStorage.removeItem('code_verifier');  
  164.   
  165.                         // Create user object from Azure profile  
  166.                         const user: CurrentUser = {  
  167.                             id: profile.id || '',  
  168.                             email: profile.mail || '',  
  169.                             firstName: profile.givenName || '',  
  170.                             lastName: profile.surname || '',  
  171.                             department: profile.department || '',  
  172.                             jobTitle: profile.jobTitle,  
  173.                             roles: ['user']  
  174.                         };  
  175.   
  176.                         // Store user data using Azure token as auth token  
  177.                         this.setAuthData(azureResponse.access_token, user, false);  
  178.                         this.isAuthenticatedSubject.next(true);  
  179.                     })  
  180.                 );  
  181.             }),  
  182.             switchMap(() => {  
  183.                 // Return empty observable to complete the chain  
  184.                 return of({ token: '', user: this.getCurrentUserValue() } as LoginResponse);  
  185.             }),  
  186.             catchError((error: any) => {  
  187.                 console.error('Token exchange error:', error);  
  188.                 localStorage.removeItem('oauth_state');  
  189.                 localStorage.removeItem('code_verifier');  
  190.                 throw error;  
  191.             })  
  192.         );  
  193.     }  
  194.   
  195.     /** 
  196.      * Handle OAuth callback from Azure AD 
  197.      */  
  198.     handleAuthCallback(token: string, user: any, rememberMe: boolean = false): void {  
  199.         this.setAuthData(token, user, rememberMe);  
  200.         this.isAuthenticatedSubject.next(true);  
  201.     }  
  202.   
  203.     logout(): Observable<any> {  
  204.         return this.http.post(`${this.apiUrl}/logout`, {}).pipe(  
  205.             tap(() => {  
  206.                 this.clearAuthData();  
  207.                 this.isAuthenticatedSubject.next(false);  
  208.             }),  
  209.             catchError((error: any) => {  
  210.                 // Clear auth data even if logout API call fails  
  211.                 this.clearAuthData();  
  212.                 this.isAuthenticatedSubject.next(false);  
  213.                 return of(null);  
  214.             })  
  215.         );  
  216.     }  
  217.   
  218.     getCurrentUser(): Observable<CurrentUser> {  
  219.         return this.http.get<CurrentUser>(`${this.apiUrl}/current-user`);  
  220.     }  
  221.   
  222.     isAuthenticated(): boolean {  
  223.         return !!this.getToken();  
  224.     }  
  225.   
  226.     getToken(): string | null {  
  227.         return localStorage.getItem('authToken');  
  228.     }  
  229.   
  230.     setToken(token: string): void {  
  231.         localStorage.setItem('authToken', token);  
  232.         this.isAuthenticatedSubject.next(true);  
  233.     }  
  234.   
  235.     getCurrentUserValue(): CurrentUser | null {  
  236.         return this.currentUserSubject.value;  
  237.     }  
  238.   
  239.     hasRole(role: string): boolean {  
  240.         const user = this.currentUserSubject.value;  
  241.         return user ? user.roles.includes(role) : false;  
  242.     }  
  243.   
  244.     hasAnyRole(roles: string[]): boolean {  
  245.         const user = this.currentUserSubject.value;  
  246.         return user ? roles.some(role => user.roles.includes(role)) : false;  
  247.     }  
  248.   
  249.     private setAuthData(token: string, user: CurrentUser, rememberMe: boolean): void {  
  250.         localStorage.setItem('authToken', token);  
  251.         localStorage.setItem('currentUser', JSON.stringify(user));  
  252.         this.currentUserSubject.next(user);  
  253.   
  254.         if (rememberMe) {  
  255.             localStorage.setItem('rememberMe''true');  
  256.         }  
  257.     }  
  258.   
  259.     clearAuthData(): void {  
  260.         localStorage.removeItem('authToken');  
  261.         localStorage.removeItem('currentUser');  
  262.         localStorage.removeItem('rememberMe');  
  263.         this.currentUserSubject.next(null);  
  264.     }  
  265.   
  266.     private loadCurrentUser(): void {  
  267.         const token = this.getToken();  
  268.         const userStr = localStorage.getItem('currentUser');  
  269.   
  270.         if (token && userStr) {  
  271.             try {  
  272.                 const user = JSON.parse(userStr);  
  273.                 this.currentUserSubject.next(user);  
  274.                 this.isAuthenticatedSubject.next(true);  
  275.             } catch (e) {  
  276.                 this.clearAuthData();  
  277.             }  
  278.         }  
  279.     }  
  280.   
  281.     /** 
  282.      * Generate random state for OAuth CSRF protection 
  283.      */  
  284.     private generateState(): string {  
  285.         return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);  
  286.     }  
  287.   
  288.     /** 
  289.      * Generate PKCE code verifier (43-128 characters) 
  290.      */  
  291.     private generateCodeVerifier(): string {  
  292.         const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';  
  293.         let result = '';  
  294.         const length = 128;  
  295.   
  296.         for (let i = 0; i < length; i++) {  
  297.             result += characters.charAt(Math.floor(Math.random() * characters.length));  
  298.         }  
  299.   
  300.         return result;  
  301.     }  
  302.   
  303.     /** 
  304.      * For PKCE with plain method, challenge = verifier 
  305.      * (In production, use S256 with proper SHA256 hashing) 
  306.      */  
  307.     private generateCodeChallengePlain(codeVerifier: string): string {  
  308.         return codeVerifier;  
  309.     }  
  310.   
  311.     /** 
  312.      * Decode and validate JWT token 
  313.      */  
  314.     private decodeToken(token: string): any {  
  315.         try {  
  316.             const parts = token.split('.');  
  317.             if (parts.length !== 3) {  
  318.                 throw new Error('Invalid token format');  
  319.             }  
  320.   
  321.             // Decode payload (second part)  
  322.             const payload = parts[1];  
  323.             const decoded = JSON.parse(atob(payload));  
  324.   
  325.             // Check if token is expired  
  326.             if (decoded.exp) {  
  327.                 const expirationTime = decoded.exp * 1000; // Convert to milliseconds  
  328.                 if (Date.now() >= expirationTime) {  
  329.                     throw new Error('Token has expired');  
  330.                 }  
  331.             }  
  332.   
  333.             return decoded;  
  334.         } catch (error) {  
  335.             console.error('Error decoding token:', error);  
  336.             throw error;  
  337.         }  
  338.     }  
  339.   
  340.     /** 
  341.      * Get user profile details from Azure Graph API 
  342.      */  
  343.     private getAzureUserProfile(accessToken: string): Observable<any> {  
  344.         const graphApiUrl = 'https://graph.microsoft.com/v1.0/me';  
  345.   
  346.         return this.http.get<any>(graphApiUrl, {  
  347.             headers: new HttpHeaders({  
  348.                 'Authorization': `Bearer ${accessToken}`,  
  349.                 'Content-Type''application/json'  
  350.             })  
  351.         }).pipe(  
  352.             tap((profile) => {  
  353.                 // Validate profile response  
  354.                 if (!profile || !profile.mail) {  
  355.                     throw new Error('Invalid profile: missing email');  
  356.                 }  
  357.                 console.log('Profile details:', {  
  358.                     id: profile.id,  
  359.                     mail: profile.mail,  
  360.                     displayName: profile.displayName,  
  361.                     givenName: profile.givenName,  
  362.                     surname: profile.surname,  
  363.                     department: profile.department,  
  364.                     jobTitle: profile.jobTitle,  
  365.                     mobilePhone: profile.mobilePhone,  
  366.                     officeLocation: profile.officeLocation  
  367.                 });  
  368.             }),  
  369.             catchError((error) => {  
  370.                 console.error('Failed to fetch user profile from Azure Graph:', error);  
  371.                 throw new Error(`Failed to fetch user profile: ${error.message}`);  
  372.             })  
  373.         );  
  374.     }  
  375. }  

Post a Comment

0Comments

Post a Comment (0)