Angular Integration

Angular Integration

This guide covers how to integrate Skycloak authentication into Angular applications using the Keycloak Angular adapter and Angular’s built-in security features.

ℹ️

Version Compatibility

  • keycloak-angular v19+ requires Angular 17+ and uses the new functional configuration with provideKeycloak
  • keycloak-angular v18.x supports Angular 15-16 and uses the legacy module-based approach
  • This guide covers both approaches below

Prerequisites

  • Angular 17+ application (recommended), or Angular 15-16 for legacy setup
  • Node.js 18+
  • Skycloak cluster with configured realm and client
  • Basic understanding of Angular services and guards

Quick Start (Angular 17+ with Standalone)

This is the recommended approach for Angular 17+ applications using standalone components.

1. Install Dependencies

npm install keycloak-angular keycloak-js

2. Configure with provideKeycloak

For Angular 17+ with standalone configuration:

// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideKeycloak, withAutoRefreshToken, AutoRefreshTokenService, UserActivityService, INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG } from 'keycloak-angular';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
    provideHttpClient(withInterceptorsFromDi()),
    provideKeycloak({
      config: {
        url: 'https://your-cluster-id.app.skycloak.io',
        realm: 'your-realm',
        clientId: 'your-angular-app',
      },
      initOptions: {
        onLoad: 'check-sso',
        silentCheckSsoRedirectUri: window.location.origin + '/assets/silent-check-sso.html',
        checkLoginIframe: false,
      },
      features: [
        withAutoRefreshToken({
          onInactivityTimeout: 'logout',
          sessionTimeout: 60000,
        }),
      ],
      providers: [AutoRefreshTokenService, UserActivityService],
    }),
    {
      provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
      useValue: {
        allowedList: [/\/api\/.*/],
        excludedList: ['/assets', '/public'],
      },
    },
  ],
});

3. Create Functional Auth Guard

// src/app/guards/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router, ActivatedRouteSnapshot } from '@angular/router';
import Keycloak from 'keycloak-js';

export const authGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
  const keycloak = inject(Keycloak);
  const router = inject(Router);

  const isAuthenticated = keycloak.authenticated ?? false;

  if (!isAuthenticated) {
    await keycloak.login({
      redirectUri: window.location.origin + '/' + route.url.join('/'),
    });
    return false;
  }

  // Check for required roles
  const requiredRoles = route.data['roles'] as string[] | undefined;
  if (!requiredRoles || requiredRoles.length === 0) {
    return true;
  }

  const userRoles = keycloak.realmAccess?.roles ?? [];
  const hasRequiredRoles = requiredRoles.every(role => userRoles.includes(role));

  if (!hasRequiredRoles) {
    return router.createUrlTree(['/access-denied']);
  }

  return true;
};

4. Configure Routes

// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';

export const routes: Routes = [
  { path: '', loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent) },
  { path: 'public', loadComponent: () => import('./pages/public/public.component').then(m => m.PublicComponent) },
  {
    path: 'profile',
    loadComponent: () => import('./pages/profile/profile.component').then(m => m.ProfileComponent),
    canActivate: [authGuard],
  },
  {
    path: 'admin',
    loadComponent: () => import('./pages/admin/admin.component').then(m => m.AdminComponent),
    canActivate: [authGuard],
    data: { roles: ['admin'] },
  },
  { path: '**', redirectTo: '' },
];

Legacy Setup (Angular 15-16 with NgModules)

For applications using the legacy module-based approach with Angular 15-16 or keycloak-angular v18.x.

1. Install Dependencies

npm install keycloak-angular@18 keycloak-js

2. Initialize Keycloak (Legacy)

Create a Keycloak initialization function:

// src/app/init/keycloak-init.factory.ts
import { KeycloakService } from 'keycloak-angular';

export function initializeKeycloak(keycloak: KeycloakService) {
  return () =>
    keycloak.init({
      config: {
        url: 'https://your-cluster-id.app.skycloak.io',
        realm: 'your-realm',
        clientId: 'your-angular-app',
      },
      initOptions: {
        onLoad: 'check-sso',
        silentCheckSsoRedirectUri:
          window.location.origin + '/assets/silent-check-sso.html',
        checkLoginIframe: false,
      },
      enableBearerInterceptor: true,
      bearerPrefix: 'Bearer',
      bearerExcludedUrls: ['/assets', '/public'],
    });
}

3. Configure App Module (Legacy)

// src/app/app.module.ts
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { KeycloakAngularModule, KeycloakService } from 'keycloak-angular';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { initializeKeycloak } from './init/keycloak-init.factory';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HttpClientModule,
    KeycloakAngularModule,
  ],
  providers: [
    {
      provide: APP_INITIALIZER,
      useFactory: initializeKeycloak,
      multi: true,
      deps: [KeycloakService],
    },
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

4. Create Auth Guard

// src/app/guards/auth.guard.ts
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, UrlTree } from '@angular/router';
import { KeycloakAuthGuard, KeycloakService } from 'keycloak-angular';

@Injectable({
  providedIn: 'root',
})
export class AuthGuard extends KeycloakAuthGuard {
  constructor(
    protected override readonly router: Router,
    protected readonly keycloak: KeycloakService
  ) {
    super(router, keycloak);
  }

  async isAccessAllowed(
    route: ActivatedRouteSnapshot
  ): Promise<boolean | UrlTree> {
    // Force login if not authenticated
    if (!this.authenticated) {
      await this.keycloak.login({
        redirectUri: window.location.origin + route.url.join('/'),
      });
      return false;
    }

    // Check for required roles
    const requiredRoles = route.data['roles'];
    if (!requiredRoles || requiredRoles.length === 0) {
      return true;
    }

    // Check if user has required roles
    const hasRequiredRoles = requiredRoles.every((role: string) =>
      this.roles.includes(role)
    );

    if (!hasRequiredRoles) {
      // Navigate to access denied page
      return this.router.createUrlTree(['/access-denied']);
    }

    return true;
  }
}

5. Protected Routes

// src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from './guards/auth.guard';
import { HomeComponent } from './components/home/home.component';
import { AdminComponent } from './components/admin/admin.component';
import { ProfileComponent } from './components/profile/profile.component';
import { PublicComponent } from './components/public/public.component';

const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'public', component: PublicComponent },
  {
    path: 'profile',
    component: ProfileComponent,
    canActivate: [AuthGuard],
  },
  {
    path: 'admin',
    component: AdminComponent,
    canActivate: [AuthGuard],
    data: { roles: ['admin'] },
  },
  { path: '**', redirectTo: '' },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {}

Authentication Service

Enhanced Auth Service

// src/app/services/auth.service.ts
import { Injectable } from '@angular/core';
import { KeycloakService } from 'keycloak-angular';
import { KeycloakProfile, KeycloakTokenParsed } from 'keycloak-js';
import { Observable, from, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';

export interface UserInfo {
  id: string;
  username: string;
  email: string;
  firstName: string;
  lastName: string;
  roles: string[];
  groups: string[];
  attributes: { [key: string]: any };
}

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  constructor(private keycloakService: KeycloakService) {}

  public getLoggedUser(): Observable<UserInfo | null> {
    if (!this.keycloakService.isLoggedIn()) {
      return of(null);
    }

    return from(this.keycloakService.loadUserProfile()).pipe(
      map((profile: KeycloakProfile) => {
        const token = this.keycloakService.getKeycloakInstance().tokenParsed as KeycloakTokenParsed;
        
        return {
          id: profile.id || token.sub || '',
          username: profile.username || '',
          email: profile.email || '',
          firstName: profile.firstName || '',
          lastName: profile.lastName || '',
          roles: this.keycloakService.getUserRoles(true),
          groups: token['groups'] || [],
          attributes: profile.attributes || {},
        };
      }),
      catchError(() => of(null))
    );
  }

  public login(redirectUri?: string): void {
    this.keycloakService.login({ redirectUri });
  }

  public logout(redirectUri?: string): void {
    this.keycloakService.logout(redirectUri);
  }

  public register(redirectUri?: string): void {
    this.keycloakService.register({ redirectUri });
  }

  public async getToken(): Promise<string> {
    try {
      await this.keycloakService.updateToken(30);
      return await this.keycloakService.getToken();
    } catch (error) {
      console.error('Failed to refresh token', error);
      throw error;
    }
  }

  public hasRole(role: string): boolean {
    return this.keycloakService.isUserInRole(role);
  }

  public hasRealmRole(role: string): boolean {
    return this.keycloakService.isUserInRole(role, 'realm');
  }

  public hasResourceRole(role: string, resource?: string): boolean {
    return this.keycloakService.isUserInRole(role, resource);
  }

  public hasAnyRole(roles: string[]): boolean {
    return roles.some(role => this.hasRole(role));
  }

  public hasAllRoles(roles: string[]): boolean {
    return roles.every(role => this.hasRole(role));
  }
}

User Profile Component

// src/app/components/profile/profile.component.ts
import { Component, OnInit } from '@angular/core';
import { AuthService, UserInfo } from '../../services/auth.service';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-profile',
  template: `
    <div class="profile-container" *ngIf="userInfo$ | async as user">
      <h2>User Profile</h2>
      <div class="profile-info">
        <p><strong>Username:</strong> {{ user.username }}</p>
        <p><strong>Email:</strong> {{ user.email }}</p>
        <p><strong>Name:</strong> {{ user.firstName }} {{ user.lastName }}</p>
        
        <div class="roles">
          <h3>Roles</h3>
          <ul>
            <li *ngFor="let role of user.roles">{{ role }}</li>
          </ul>
        </div>
        
        <div class="groups" *ngIf="user.groups.length > 0">
          <h3>Groups</h3>
          <ul>
            <li *ngFor="let group of user.groups">{{ group }}</li>
          </ul>
        </div>
      </div>
      
      <button (click)="logout()" class="logout-btn">Logout</button>
    </div>
  `,
})
export class ProfileComponent implements OnInit {
  userInfo$!: Observable<UserInfo | null>;

  constructor(private authService: AuthService) {}

  ngOnInit(): void {
    this.userInfo$ = this.authService.getLoggedUser();
  }

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

HTTP Interceptor

Token Interceptor with Retry Logic

// src/app/interceptors/token.interceptor.ts
import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor,
  HttpErrorResponse,
} from '@angular/common/http';
import { Observable, throwError, BehaviorSubject } from 'rxjs';
import { catchError, filter, take, switchMap, finalize } from 'rxjs/operators';
import { KeycloakService } from 'keycloak-angular';

@Injectable()
export class TokenInterceptor implements HttpInterceptor {
  private isRefreshing = false;
  private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);

  constructor(private keycloakService: KeycloakService) {}

  intercept(
    request: HttpRequest<unknown>,
    next: HttpHandler
  ): Observable<HttpEvent<unknown>> {
    // Skip interceptor for excluded URLs
    if (this.isExcludedUrl(request.url)) {
      return next.handle(request);
    }

    return next.handle(request).pipe(
      catchError((error: HttpErrorResponse) => {
        if (error.status === 401) {
          return this.handle401Error(request, next);
        }
        return throwError(() => error);
      })
    );
  }

  private handle401Error(
    request: HttpRequest<unknown>,
    next: HttpHandler
  ): Observable<HttpEvent<unknown>> {
    if (!this.isRefreshing) {
      this.isRefreshing = true;
      this.refreshTokenSubject.next(null);

      return from(this.keycloakService.updateToken(30)).pipe(
        switchMap((refreshed: boolean) => {
          if (refreshed) {
            this.refreshTokenSubject.next(refreshed);
            return next.handle(request);
          }
          // Refresh failed, redirect to login
          this.keycloakService.login();
          return throwError(() => new Error('Token refresh failed'));
        }),
        catchError((err) => {
          this.keycloakService.login();
          return throwError(() => err);
        }),
        finalize(() => {
          this.isRefreshing = false;
        })
      );
    }

    return this.refreshTokenSubject.pipe(
      filter((token) => token !== null),
      take(1),
      switchMap(() => next.handle(request))
    );
  }

  private isExcludedUrl(url: string): boolean {
    const excludedUrls = ['/assets', '/public', 'i18n'];
    return excludedUrls.some((excluded) => url.includes(excluded));
  }
}

Role-Based Components

Role Directive

// src/app/directives/has-role.directive.ts
import {
  Directive,
  Input,
  OnInit,
  TemplateRef,
  ViewContainerRef,
} from '@angular/core';
import { AuthService } from '../services/auth.service';

@Directive({
  selector: '[appHasRole]',
})
export class HasRoleDirective implements OnInit {
  @Input() appHasRole!: string | string[];
  @Input() appHasRoleElse?: TemplateRef<any>;

  constructor(
    private templateRef: TemplateRef<any>,
    private viewContainer: ViewContainerRef,
    private authService: AuthService
  ) {}

  ngOnInit(): void {
    const roles = Array.isArray(this.appHasRole)
      ? this.appHasRole
      : [this.appHasRole];

    if (this.authService.hasAnyRole(roles)) {
      this.viewContainer.createEmbeddedView(this.templateRef);
    } else if (this.appHasRoleElse) {
      this.viewContainer.createEmbeddedView(this.appHasRoleElse);
    } else {
      this.viewContainer.clear();
    }
  }
}

Usage in templates:

<!-- Show only for admins -->
<div *appHasRole="'admin'">
  <button (click)="deleteUser()">Delete User</button>
</div>

<!-- Show for multiple roles -->
<div *appHasRole="['admin', 'moderator']">
  <button (click)="moderateContent()">Moderate</button>
</div>

<!-- With else template -->
<div *appHasRole="'premium-user'; else standardUser">
  <app-premium-features></app-premium-features>
</div>
<ng-template #standardUser>
  <app-standard-features></app-standard-features>
</ng-template>

Permission Service

// src/app/services/permission.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError, shareReplay } from 'rxjs/operators';

interface Permission {
  resource: string;
  scopes: string[];
}

@Injectable({
  providedIn: 'root',
})
export class PermissionService {
  private permissions$?: Observable<Permission[]>;

  constructor(private http: HttpClient) {}

  loadPermissions(): Observable<Permission[]> {
    if (!this.permissions$) {
      this.permissions$ = this.http.get<Permission[]>('/api/permissions').pipe(
        shareReplay(1),
        catchError(() => of([]))
      );
    }
    return this.permissions$;
  }

  hasPermission(resource: string, scope: string): Observable<boolean> {
    return this.loadPermissions().pipe(
      map(permissions => {
        const permission = permissions.find(p => p.resource === resource);
        return permission ? permission.scopes.includes(scope) : false;
      })
    );
  }

  canAccess(resource: string): Observable<boolean> {
    return this.hasPermission(resource, 'read');
  }

  canEdit(resource: string): Observable<boolean> {
    return this.hasPermission(resource, 'write');
  }

  canDelete(resource: string): Observable<boolean> {
    return this.hasPermission(resource, 'delete');
  }
}

Silent Authentication

Silent Check SSO

Create src/assets/silent-check-sso.html:

<!DOCTYPE html>
<html>
<head>
  <title>Silent SSO Check</title>
</head>
<body>
  <script>
    parent.postMessage(location.href, location.origin);
  </script>
</body>
</html>

Auto Token Refresh

// src/app/services/token-refresh.service.ts
import { Injectable, OnDestroy } from '@angular/core';
import { KeycloakService } from 'keycloak-angular';
import { interval, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

@Injectable({
  providedIn: 'root',
})
export class TokenRefreshService implements OnDestroy {
  private destroy$ = new Subject<void>();
  private refreshInterval = 4 * 60 * 1000; // 4 minutes

  constructor(private keycloakService: KeycloakService) {}

  startTokenRefresh(): void {
    interval(this.refreshInterval)
      .pipe(takeUntil(this.destroy$))
      .subscribe(async () => {
        try {
          const refreshed = await this.keycloakService.updateToken(300);
          if (refreshed) {
            console.log('Token refreshed successfully');
          }
        } catch (error) {
          console.error('Failed to refresh token:', error);
          await this.keycloakService.login();
        }
      });
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Testing

Mock Keycloak Service

// src/app/testing/mock-keycloak.service.ts
import { Injectable } from '@angular/core';

@Injectable()
export class MockKeycloakService {
  private _isLoggedIn = true;
  private _roles = ['user', 'admin'];
  private _token = 'mock-token';

  init(): Promise<boolean> {
    return Promise.resolve(true);
  }

  isLoggedIn(): boolean {
    return this._isLoggedIn;
  }

  getUserRoles(allRoles?: boolean): string[] {
    return this._roles;
  }

  getToken(): Promise<string> {
    return Promise.resolve(this._token);
  }

  updateToken(minValidity: number): Promise<boolean> {
    return Promise.resolve(true);
  }

  login(options?: any): Promise<void> {
    return Promise.resolve();
  }

  logout(redirectUri?: string): Promise<void> {
    return Promise.resolve();
  }

  isUserInRole(role: string, resource?: string): boolean {
    return this._roles.includes(role);
  }

  loadUserProfile(): Promise<any> {
    return Promise.resolve({
      id: 'test-user-123',
      username: 'testuser',
      email: '[email protected]',
      firstName: 'Test',
      lastName: 'User',
    });
  }

  // Test helpers
  setLoggedIn(loggedIn: boolean): void {
    this._isLoggedIn = loggedIn;
  }

  setRoles(roles: string[]): void {
    this._roles = roles;
  }
}

Component Tests

// src/app/components/profile/profile.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { KeycloakService } from 'keycloak-angular';
import { ProfileComponent } from './profile.component';
import { AuthService } from '../../services/auth.service';
import { MockKeycloakService } from '../../testing/mock-keycloak.service';

describe('ProfileComponent', () => {
  let component: ProfileComponent;
  let fixture: ComponentFixture<ProfileComponent>;
  let authService: AuthService;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ProfileComponent],
      providers: [
        AuthService,
        { provide: KeycloakService, useClass: MockKeycloakService },
      ],
    }).compileComponents();

    fixture = TestBed.createComponent(ProfileComponent);
    component = fixture.componentInstance;
    authService = TestBed.inject(AuthService);
  });

  it('should display user information', async () => {
    fixture.detectChanges();
    await fixture.whenStable();

    const compiled = fixture.nativeElement;
    expect(compiled.querySelector('.profile-info')).toBeTruthy();
    expect(compiled.textContent).toContain('testuser');
    expect(compiled.textContent).toContain('[email protected]');
  });

  it('should show roles', async () => {
    fixture.detectChanges();
    await fixture.whenStable();

    const roles = fixture.nativeElement.querySelectorAll('.roles li');
    expect(roles.length).toBe(2);
    expect(roles[0].textContent).toContain('user');
    expect(roles[1].textContent).toContain('admin');
  });
});

Production Configuration

Environment-Specific Config

// src/environments/environment.prod.ts
export const environment = {
  production: true,
  keycloak: {
    url: 'https://auth.yourcompany.com',
    realm: 'production',
    clientId: 'angular-app',
  },
  apiUrl: 'https://api.yourcompany.com',
};

// src/environments/environment.ts
export const environment = {
  production: false,
  keycloak: {
    url: 'https://dev-auth.yourcompany.com',
    realm: 'development',
    clientId: 'angular-app-dev',
  },
  apiUrl: 'http://localhost:8080',
};

Error Handling

// src/app/services/error-handler.service.ts
import { Injectable } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { KeycloakService } from 'keycloak-angular';

@Injectable({
  providedIn: 'root',
})
export class ErrorHandlerService {
  constructor(
    private router: Router,
    private keycloakService: KeycloakService
  ) {}

  handleError(error: HttpErrorResponse): void {
    switch (error.status) {
      case 401:
        // Unauthorized - refresh token or redirect to login
        this.handleUnauthorized();
        break;
      case 403:
        // Forbidden - redirect to access denied
        this.router.navigate(['/access-denied']);
        break;
      case 404:
        // Not found
        this.router.navigate(['/not-found']);
        break;
      default:
        // Generic error
        console.error('An error occurred:', error);
    }
  }

  private async handleUnauthorized(): Promise<void> {
    try {
      const refreshed = await this.keycloakService.updateToken(30);
      if (!refreshed) {
        await this.keycloakService.login();
      }
    } catch {
      await this.keycloakService.login();
    }
  }
}

Troubleshooting

Common Issues

  1. Infinite Redirect Loop

    • Check redirect URI configuration in Skycloak
    • Verify onLoad option in init configuration
    • Ensure cookies are enabled
  2. Token Not Attached to Requests

    • Verify HTTP interceptor is provided
    • Check bearer excluded URLs configuration
    • Ensure interceptor order in providers
  3. Role Check Failures

    • Verify role mappings in Skycloak
    • Check realm vs client role configuration
    • Debug token claims in browser console

Debug Utilities

// src/app/utils/keycloak-debug.ts
export function debugKeycloak(keycloakService: KeycloakService): void {
  const keycloak = keycloakService.getKeycloakInstance();
  
  console.group('Keycloak Debug Info');
  console.log('Authenticated:', keycloak.authenticated);
  console.log('Subject:', keycloak.subject);
  console.log('Token:', keycloak.token);
  console.log('Parsed Token:', keycloak.tokenParsed);
  console.log('Refresh Token:', keycloak.refreshToken);
  console.log('Realm Roles:', keycloak.realmAccess?.roles);
  console.log('Resource Roles:', keycloak.resourceAccess);
  console.groupEnd();
}

Next Steps